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
| Type | When |
|---|---|
tts.job.completed | The job produced audio. The event carries a short-lived download URL. |
tts.job.failed | The 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
httpsonly. There is nohttpoption, 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 anhttpURL.- 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
3xxresponse 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
tis the unix timestamp of this delivery attempt.v1isHMAC-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:
- Use the raw request body bytes. Not a re-serialised object.
JSON.parsethenJSON.stringifychanges 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. - Reject a stale
t. Use a tolerance of 300 seconds. Do not use0— that disables the recency check entirely. - Ignore any scheme that is not
v1. If a future elementv2=appears, a verifier that accepts "any element that matches" can be downgraded. - 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 sameid. tandv1change between duplicates. Every attempt is signed afresh. Never dedupe on the signature.- Do not use
createdfor 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. attempttells a retry from a first delivery. It is 1-based, and theVieneu-Deliveryheader identifies one specific HTTP attempt.
Other headers
| Header | Meaning |
|---|---|
Vieneu-Signature | t=…,v1=… — see above. |
Vieneu-Event-Id | Same value as id in the body. Convenient for dedupe at the edge. |
Vieneu-Event-Type | Same value as type in the body. |
Vieneu-Delivery | Identifies ONE http attempt. Not a dedupe key. |
What we expect from your endpoint
- Answer
2xx. Anything else — including any3xx— 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.
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:
- The delivery row is marked
DEAD, visible atGET /v1/webhooks/{id}/deliveries. - 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:
| Code | Meaning |
|---|---|
http_4xx / http_5xx | Your endpoint answered with that status. |
redirect_refused | Your endpoint returned a 3xx. Point it at the final URL. |
connect_timeout / response_timeout | Your endpoint did not answer in time. |
connection_refused / dns_failure / tls_failure | We could not reach it. |
blocked_private_address | The URL resolves to a non-public address. |
endpoint_unavailable | The endpoint was deleted or disabled mid-flight. |
audio_not_uploaded | The 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.