# Webhooks

Source: https://docs.vieneu.io/docs/cloud-api/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](#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

```bash
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"
      }'
```

```json
{
  "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

```json
{
  "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:

```json
{
  "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

```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:

```js
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

```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

| 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 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.

:::note 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

```bash
curl https://api.vieneu.io/api/v1/webhooks/{id}/deliveries \
  -H "Authorization: Bearer vn_sk_…"
```

```json
[
  {
    "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

```bash
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.
