Skip to main content

Webhooks

POST /v1/tts is asynchronous. Instead of polling GET /v1/tts/{jobId} until the status changes, register an HTTPS endpoint and we will POST a signed event to it the moment the job reaches a terminal state.

Polling still works and is still the recovery path when a delivery fails — see When we give up.

Events​

TypeWhen
tts.job.completedThe job produced audio. The event carries a short-lived download URL.
tts.job.failedThe job failed permanently after all retries. Tokens have been refunded.

Only POST /v1/tts produces jobs today, so those are the only two event types. Every other synthesis route (/v1/audio/speech, /v1/tts/stream, /v1/dialogue, /v1/dub, /v1/srt) answers inline and produces no event.

Register an endpoint​

curl -X POST https://api.vieneu.io/api/v1/webhooks \
-H "Authorization: Bearer vn_sk_…" \
-H "Content-Type: application/json" \
-d '{
"url": "https://api.example.com/hooks/vieneu",
"description": "Production receiver"
}'
{
"id": "665f1a2b3c4d5e6f7a8b9c0d",
"url": "https://api.example.com/hooks/vieneu",
"events": ["tts.job.completed", "tts.job.failed"],
"status": "ENABLED",
"secretPrefix": "whsec_a1b2c3",
"secret": "whsec_2f9c…"
}

secret is returned once and never again. Store it now. If you lose it, call POST /v1/webhooks/{id}/rotate-secret and update your receiver.

Destination requirements​

  • https only. There is no http option, in any mode. Events carry a presigned download URL for your audio, which is a bearer credential — the signature protects integrity, not confidentiality. For local development use a tunnel (ngrok, Cloudflare Tunnel, the like) rather than an http URL.
  • Public addresses only. Private, loopback, link-local, CGNAT and reserved ranges are refused — when you register the endpoint, and again at the moment of every delivery. A hostname that resolves publicly at registration and privately later is refused at connect time.
  • No redirects. A 3xx response is treated as a failed delivery. Point the endpoint at the final URL.
  • No credentials in the URL. https://user:pass@… is refused.

Scoping to one API key​

Pass apiKeyId when registering to receive only the jobs submitted with that key. Omit it and the endpoint receives events for every key on the account. Useful for keeping a test key's traffic off your production receiver.

The event body​

{
"id": "evt_9f2c1e043b8a4d218f770c1a2b3c4d5e",
"type": "tts.job.completed",
"api_version": "2026-09-01",
"created": 1756684800,
"attempt": 1,
"data": {
"job_id": "0f7c1a2b-3c4d-5e6f-7a8b-9c0d1e2f3a4b",
"status": "completed",
"voice_id": "Ngọc Lan",
"engine": "v4",
"character_count": 412,
"token_cost": 1236,
"duration_seconds": 27.4,
"processing_time_ms": 8120,
"audio_url": "https://…s3…?X-Amz-Signature=…",
"audio_url_expires_at": "2026-09-01T11:00:00.000Z"
}
}

A failure looks like this:

{
"id": "evt_1a4d…",
"type": "tts.job.failed",
"api_version": "2026-09-01",
"created": 1756684800,
"attempt": 1,
"data": {
"job_id": "0f7c1a2b-3c4d-5e6f-7a8b-9c0d1e2f3a4b",
"status": "failed",
"voice_id": "Ngọc Lan",
"engine": "v4",
"character_count": 412,
"token_cost": 1236,
"error": {
"code": "synthesis_unavailable",
"message": "No synthesis worker could complete the job. Tokens were refunded."
},
"refunded": true
}
}

Branch on error.code, never on error.message. The codes are synthesis_failed, synthesis_unavailable, synthesis_timeout, content_rejected and quota_exceeded; the message is prose and may be reworded.

About audio_url​

audio_url is minted fresh for each delivery attempt and expires one hour after that attempt — audio_url_expires_at tells you exactly when. It is deliberately shorter-lived than the 24-hour URL GET /v1/tts/{jobId} returns, because an event body may end up in your logs. Download promptly, or call GET /v1/tts/{jobId} for a fresh URL whenever you need one.

The event carries everything you need to act on the job without a second API call. It does not carry the submitted text.

Verifying the signature​

Every request carries a Vieneu-Signature header:

Vieneu-Signature: t=1756684800,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
  • t is the unix timestamp of this delivery attempt.
  • v1 is HMAC-SHA256(secret, "<t>." + rawBody), hex-encoded.

During a secret rotation the header carries one v1= element per live secret. Check the signature against each of them and accept if any matches.

Four rules that matter:

  1. Use the raw request body bytes. Not a re-serialised object. JSON.parse then JSON.stringify changes key order and unicode escaping, and the signature will fail intermittently in a way that is very hard to debug. Read the raw body before any JSON middleware touches it.
  2. Reject a stale t. Use a tolerance of 300 seconds. Do not use 0 — that disables the recency check entirely.
  3. Ignore any scheme that is not v1. If a future element v2= appears, a verifier that accepts "any element that matches" can be downgraded.
  4. Compare in constant time. crypto.timingSafeEqual, not ===.

Node.js​

const crypto = require('crypto');

/**
* Verify a Vieneu webhook signature.
*
* @param {Buffer|string} payload RAW request body — not a parsed object.
* @param {string} header The `Vieneu-Signature` header value.
* @param {string} secret Your `whsec_…` signing secret.
* @param {number} toleranceSeconds Max age of `t`. 300 is the documented value.
* @returns {boolean}
*/
function verifySignature(payload, header, secret, toleranceSeconds = 300) {
const body = Buffer.isBuffer(payload) ? payload : Buffer.from(payload, 'utf8');

let timestamp = null;
const signatures = [];
for (const element of String(header || '').split(',')) {
const idx = element.indexOf('=');
if (idx === -1) continue;
const key = element.slice(0, idx).trim();
const value = element.slice(idx + 1).trim();
if (key === 't') timestamp = Number(value);
// Only v1. Ignoring unknown schemes is what stops a downgrade.
else if (key === 'v1') signatures.push(value);
}
if (timestamp === null || !Number.isFinite(timestamp)) return false;
if (signatures.length === 0) return false;

// Replay window. A tolerance of 0 disables this check — don't.
if (toleranceSeconds > 0) {
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - timestamp) > toleranceSeconds) return false;
}

const expected = crypto
.createHmac('sha256', secret)
.update(Buffer.concat([Buffer.from(timestamp + '.', 'utf8'), body]))
.digest('hex');

return signatures.some((candidate) => {
// timingSafeEqual throws on a length mismatch, so check length first —
// the length of a hex digest is not itself a secret.
if (candidate.length !== expected.length) return false;
try {
return crypto.timingSafeEqual(
Buffer.from(candidate, 'hex'),
Buffer.from(expected, 'hex'),
);
} catch (err) {
return false;
}
});
}

Wire it up in Express, taking care to keep the raw body:

const express = require('express');
const app = express();

app.post(
'/hooks/vieneu',
express.raw({ type: 'application/json' }),
(req, res) => {
const ok = verifySignature(
req.body, // Buffer, thanks to express.raw
req.get('Vieneu-Signature'),
process.env.VIENEU_WEBHOOK_SECRET,
);
if (!ok) return res.sendStatus(400);

// Answer FIRST, work afterwards. We time out at 10 seconds.
res.sendStatus(200);

const event = JSON.parse(req.body.toString('utf8'));
if (alreadyProcessed(event.id)) return; // at-least-once — dedupe on id
void handle(event);
},
);

Python​

import hashlib
import hmac
import time


def verify_signature(payload: bytes, header: str, secret: str, tolerance_seconds: int = 300) -> bool:
timestamp = None
signatures = []
for element in (header or "").split(","):
key, sep, value = element.partition("=")
if not sep:
continue
key, value = key.strip(), value.strip()
if key == "t":
try:
timestamp = int(value)
except ValueError:
return False
elif key == "v1": # only v1; ignore any other scheme
signatures.append(value)

if timestamp is None or not signatures:
return False
if tolerance_seconds > 0 and abs(int(time.time()) - timestamp) > tolerance_seconds:
return False

expected = hmac.new(
secret.encode("utf-8"),
f"{timestamp}.".encode("utf-8") + payload,
hashlib.sha256,
).hexdigest()

return any(hmac.compare_digest(candidate, expected) for candidate in signatures)

Delivery guarantees​

At-least-once, unordered. Concretely:

  • Duplicates are normal. Design your receiver to be idempotent. Dedupe on id, which is stable for a given job and event type — the same terminal state re-emitted after a crash or a queue redelivery carries the same id.
  • t and v1 change between duplicates. Every attempt is signed afresh. Never dedupe on the signature.
  • Do not use created for ordering or deduplication. It is a diagnostic timestamp. Two jobs' events can arrive in either order, and a retry of an older event can land after a newer one.
  • attempt tells a retry from a first delivery. It is 1-based, and the Vieneu-Delivery header identifies one specific HTTP attempt.

Other headers​

HeaderMeaning
Vieneu-Signaturet=…,v1=… — see above.
Vieneu-Event-IdSame value as id in the body. Convenient for dedupe at the edge.
Vieneu-Event-TypeSame value as type in the body.
Vieneu-DeliveryIdentifies ONE http attempt. Not a dedupe key.

What we expect from your endpoint​

  • Answer 2xx. Anything else — including any 3xx — is a failed delivery.
  • Answer within 10 seconds. Acknowledge first, do the work after.
  • Keep the response small. We stop reading as soon as 64 KB has arrived (we finish the chunk in flight, so a little more may cross the wire) and discard it. We never log, store or return your response body.

Retries and backoff​

A failed delivery is retried up to 6 attempts, with the gaps growing each time. Measured against the queue library we actually run, they fire at:

t+0     t+0.5m    t+2.0m    t+5.5m    t+13.0m    t+28.5m

So the window is about 28.5 minutes end to end, and the longest single gap between two attempts — the one before the last — is 15.5 minutes.

That second number is the one to build against. Size any staleness or reconciliation threshold above 15.5 minutes. A sweeper that treats a delivery as stranded after fifteen will keep finding deliveries that are simply waiting out their final backoff, and will re-enqueue work that was never lost.

This page used to say 15 minutes

It described the schedule as "+30s, +1m, +2m, +4m, +8m, about 15 minutes". That was arithmetic on the wrong formula — the queue computes each delay as (2^attempts − 1) × 30s, not 30s × 2^attempts, which roughly doubles both the window and every gap inside it. The numbers above were read off the installed library rather than recalled.

When we give up​

After the last attempt the event is dead-lettered: it will not be sent again. Two things happen:

  1. The delivery row is marked DEAD, visible at GET /v1/webhooks/{id}/deliveries.
  2. A notification appears in your Vieneu account.

The audio is not lost. GET /v1/tts/{jobId} still returns the job and a fresh 24-hour download URL. If your receiver was down, reconcile from there.

Self-diagnosis​

curl https://api.vieneu.io/api/v1/webhooks/{id}/deliveries \
-H "Authorization: Bearer vn_sk_…"
[
{
"id": "665f…",
"eventId": "evt_9f2c…",
"eventType": "tts.job.completed",
"status": "DEAD",
"attempts": 6,
"lastStatusCode": 502,
"lastError": "http_502",
"lastDurationMs": 143,
"lastAttemptAt": "2026-09-01T10:15:02Z",
"deliveredAt": null
}
]

lastError values you may see:

CodeMeaning
http_4xx / http_5xxYour endpoint answered with that status.
redirect_refusedYour endpoint returned a 3xx. Point it at the final URL.
connect_timeout / response_timeoutYour endpoint did not answer in time.
connection_refused / dns_failure / tls_failureWe could not reach it.
blocked_private_addressThe URL resolves to a non-public address.
endpoint_unavailableThe endpoint was deleted or disabled mid-flight.
audio_not_uploadedThe audio had not finished uploading yet. Retried.

Rotating the signing secret​

curl -X POST https://api.vieneu.io/api/v1/webhooks/{id}/rotate-secret \
-H "Authorization: Bearer vn_sk_…"

The new secret is returned once. The previous secret keeps verifying for 24 hours, and during that window each event carries one v1= per live secret — so you can deploy the new secret without dropping events signed with the old one. The verifier above already handles this: it accepts if any v1 matches.