# Webhooks

Source: https://docs.vieneu.io/vi/docs/cloud-api/webhooks

`POST /v1/tts` chạy bất đồng bộ. Thay vì polling `GET /v1/tts/{jobId}` cho tới khi
trạng thái đổi, hãy đăng ký một endpoint HTTPS và chúng tôi sẽ POST một sự kiện có
chữ ký tới đó ngay khi job vào trạng thái cuối.

Polling vẫn dùng được, và vẫn là phương án khôi phục khi một lần gửi thất bại — xem
[Khi chúng tôi ngừng gửi](#when-we-give-up).

## Sự kiện {#events}

| Loại | Khi nào |
| --- | --- |
| `tts.job.completed` | Job đã tạo ra audio. Sự kiện mang theo một URL tải về có hiệu lực ngắn. |
| `tts.job.failed` | Job thất bại vĩnh viễn sau khi đã thử lại hết. Token đã được hoàn. |

Hiện chỉ `POST /v1/tts` sinh ra job, nên đó là hai loại sự kiện duy nhất. Mọi
route tổng hợp giọng nói khác (`/v1/audio/speech`, `/v1/tts/stream`, `/v1/dialogue`,
`/v1/dub`, `/v1/srt`) đều trả kết quả ngay trong response và không sinh sự kiện nào.

## Đăng ký endpoint {#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` chỉ trả về một lần duy nhất và không bao giờ trả lại.** Hãy lưu ngay.
Nếu làm mất, gọi `POST /v1/webhooks/{id}/rotate-secret` rồi cập nhật receiver của bạn.

### Yêu cầu với địa chỉ nhận {#destination-requirements}

- **Chỉ `https`.** Không có lựa chọn `http`, ở bất kỳ chế độ nào. Sự kiện mang theo
  một URL tải audio dạng presigned, và URL đó chính là một bearer credential — chữ
  ký bảo vệ tính toàn vẹn chứ không bảo vệ tính bí mật. Khi phát triển ở máy local,
  hãy dùng tunnel (ngrok, Cloudflare Tunnel, hay tương tự) thay vì một URL `http`.
- **Chỉ địa chỉ công khai.** Các dải private, loopback, link-local, CGNAT và reserved
  đều bị từ chối — cả lúc bạn đăng ký endpoint, lẫn tại đúng thời điểm của mỗi lần
  gửi. Một hostname phân giải ra địa chỉ công khai lúc đăng ký nhưng sau đó phân giải
  ra địa chỉ nội bộ sẽ bị từ chối ngay khi kết nối.
- **Không redirect.** Response `3xx` bị coi là gửi thất bại. Hãy trỏ endpoint thẳng
  tới URL cuối cùng.
- **Không nhúng thông tin đăng nhập vào URL.** Dạng `https://user:pass@…` bị từ chối.

### Giới hạn theo một API key {#scoping-to-one-api-key}

Truyền `apiKeyId` khi đăng ký để chỉ nhận những job được gửi bằng key đó. Bỏ trống
thì endpoint nhận sự kiện của mọi key trong tài khoản. Tiện để traffic của key test
không đổ vào receiver production.

## Nội dung sự kiện {#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"
  }
}
```

Một sự kiện thất bại trông như sau:

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

Hãy rẽ nhánh theo `error.code`, đừng bao giờ theo `error.message`. Các mã gồm
`synthesis_failed`, `synthesis_unavailable`, `synthesis_timeout`,
`content_rejected` và `quota_exceeded`; còn message là văn xuôi và có thể bị viết
lại.

### Về `audio_url` {#about-audio_url}

`audio_url` được tạo mới cho **từng lần thử gửi** và hết hạn một giờ sau lần thử
đó — `audio_url_expires_at` cho biết chính xác thời điểm. Nó cố tình ngắn hạn hơn
URL 24 giờ mà `GET /v1/tts/{jobId}` trả về, bởi nội dung sự kiện có thể nằm lại
trong log của bạn. Hãy tải sớm, hoặc gọi `GET /v1/tts/{jobId}` để lấy URL mới bất
cứ khi nào cần.

Sự kiện mang đủ mọi thứ bạn cần để xử lý job mà không phải gọi API lần thứ hai. Nó
**không** mang theo văn bản đã gửi đi.

## Xác minh chữ ký {#verifying-the-signature}

Mọi request đều mang header `Vieneu-Signature`:

```
Vieneu-Signature: t=1756684800,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
```

- `t` là unix timestamp của **chính lần thử gửi này**.
- `v1` là `HMAC-SHA256(secret, "<t>." + rawBody)`, mã hoá hex.

Trong lúc xoay secret, header sẽ mang **một phần tử `v1=` cho mỗi secret còn hiệu
lực**. Hãy đối chiếu chữ ký với từng phần tử và chấp nhận nếu có bất kỳ cái nào khớp.

Bốn quy tắc đáng lưu ý:

1. **Dùng đúng bytes thô của request body.** Không phải một object đã serialize lại.
   `JSON.parse` rồi `JSON.stringify` sẽ đổi thứ tự key và cách escape unicode, và
   chữ ký sẽ sai lúc được lúc không — kiểu lỗi cực kỳ khó truy. Hãy đọc raw body
   trước khi bất kỳ middleware JSON nào chạm vào nó.
2. **Từ chối `t` đã cũ.** Dùng dung sai **300 giây**. Đừng dùng `0` — như vậy là tắt
   hẳn phần kiểm tra thời gian.
3. **Bỏ qua mọi scheme không phải `v1`.** Nếu sau này xuất hiện phần tử `v2=`, một
   trình xác minh kiểu "khớp phần tử nào cũng được" có thể bị hạ cấp (downgrade).
4. **So sánh kiểu constant-time.** `crypto.timingSafeEqual`, không phải `===`.

### Node.js {#nodejs}

```js
const crypto = require('crypto');

/**
 * Xác minh chữ ký webhook của Vieneu.
 *
 * @param {Buffer|string} payload  Raw request body — KHÔNG phải object đã parse.
 * @param {string} header          Giá trị header `Vieneu-Signature`.
 * @param {string} secret          Signing secret `whsec_…` của bạn.
 * @param {number} toleranceSeconds  Tuổi tối đa của `t`. 300 là giá trị nêu trong tài liệu.
 * @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);
    // Chỉ v1. Bỏ qua scheme lạ chính là thứ chặn downgrade.
    else if (key === 'v1') signatures.push(value);
  }
  if (timestamp === null || !Number.isFinite(timestamp)) return false;
  if (signatures.length === 0) return false;

  // Cửa sổ chống replay. Dung sai 0 là tắt hẳn kiểm tra này — đừng làm vậy.
  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 ném lỗi khi lệch độ dài, nên kiểm độ dài trước —
    // độ dài của một hex digest tự nó không phải là bí mật.
    if (candidate.length !== expected.length) return false;
    try {
      return crypto.timingSafeEqual(
        Buffer.from(candidate, 'hex'),
        Buffer.from(expected, 'hex'),
      );
    } catch (err) {
      return false;
    }
  });
}
```

Ghép vào Express, nhớ giữ nguyên 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, nhờ express.raw
      req.get('Vieneu-Signature'),
      process.env.VIENEU_WEBHOOK_SECRET,
    );
    if (!ok) return res.sendStatus(400);

    // Trả lời TRƯỚC, xử lý sau. Chúng tôi timeout ở 10 giây.
    res.sendStatus(200);

    const event = JSON.parse(req.body.toString('utf8'));
    if (alreadyProcessed(event.id)) return; // at-least-once — khử trùng lặp theo id
    void handle(event);
  },
);
```

### Python {#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":            # chỉ v1; bỏ qua mọi scheme khác
            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)
```

## Cam kết gửi {#delivery-guarantees}

**At-least-once, không đảm bảo thứ tự.** Cụ thể:

- **Trùng lặp là chuyện bình thường.** Hãy thiết kế receiver idempotent. Khử trùng
  lặp theo `id` — giá trị này ổn định với một job và một loại sự kiện cho trước:
  cùng một trạng thái cuối, khi được phát lại sau một cú crash hay sau khi queue
  gửi lại, vẫn mang đúng `id` đó.
- **`t` và `v1` khác nhau giữa các bản trùng.** Mỗi lần gửi đều được ký lại từ đầu.
  Tuyệt đối đừng khử trùng lặp theo chữ ký.
- **Đừng dùng `created` để sắp thứ tự hay khử trùng lặp.** Đó là timestamp phục vụ
  chẩn đoán. Sự kiện của hai job có thể đến theo thứ tự bất kỳ, và một lần thử lại
  của sự kiện cũ hoàn toàn có thể đến sau một sự kiện mới hơn.
- **`attempt` cho biết đây là lần gửi đầu hay một lần thử lại.** Nó đếm từ 1, còn
  header `Vieneu-Delivery` định danh đúng một lần thử HTTP cụ thể.

### Các header khác {#other-headers}

| Header | Ý nghĩa |
| --- | --- |
| `Vieneu-Signature` | `t=…,v1=…` — xem ở trên. |
| `Vieneu-Event-Id` | Cùng giá trị với `id` trong body. Tiện để khử trùng lặp ngay ở lớp biên. |
| `Vieneu-Event-Type` | Cùng giá trị với `type` trong body. |
| `Vieneu-Delivery` | Định danh MỘT lần thử http. Không phải khoá khử trùng lặp. |

## Chúng tôi trông đợi gì ở endpoint của bạn {#what-we-expect-from-your-endpoint}

- **Trả về `2xx`.** Mọi thứ khác — kể cả `3xx` — đều là gửi thất bại.
- **Trả lời trong vòng 10 giây.** Xác nhận trước, xử lý sau.
- **Giữ response nhỏ gọn.** Chúng tôi ngừng đọc ngay khi đã nhận 64 KB (chunk đang
  truyền vẫn được đọc nốt, nên thực tế có thể nhận thêm một ít) rồi bỏ đi. Chúng tôi
  không bao giờ ghi log, lưu trữ hay trả lại response body của bạn.

## Thử lại và backoff {#retries-and-backoff}

Một lần gửi thất bại sẽ được thử lại, tổng cộng tối đa **6 lần**, khoảng cách giữa
các lần giãn dần. Đo trên đúng thư viện queue chúng tôi đang chạy, các lần thử rơi
vào những mốc sau:

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

Vậy toàn bộ cửa sổ dài khoảng **28,5 phút** từ đầu tới cuối, và khoảng cách dài nhất
giữa hai lần thử — khoảng ngay trước lần cuối — là **15,5 phút**.

Con số thứ hai mới là con số để bạn thiết kế theo. **Hãy đặt mọi ngưỡng "quá hạn"
hay ngưỡng đối soát lớn hơn 15,5 phút.** Một sweeper coi lần gửi là thất lạc sau
mười lăm phút sẽ liên tục gặp những lần gửi thực ra chỉ đang chờ hết backoff cuối
cùng, rồi đẩy lại vào hàng đợi những việc chưa hề mất.

:::note Trang này từng ghi 15 phút
Lịch thử lại từng được mô tả là "+30s, +1m, +2m, +4m, +8m, khoảng 15 phút". Đó là
phép tính trên công thức sai — queue tính mỗi độ trễ theo `(2^attempts − 1) × 30s`,
chứ không phải `30s × 2^attempts`, và điều đó làm cả cửa sổ lẫn từng khoảng cách bên
trong dài lên gần gấp đôi. Các con số ở trên được đọc thẳng từ thư viện đang cài,
chứ không phải nhớ lại.
:::

## Khi chúng tôi ngừng gửi {#when-we-give-up}

Sau lần thử cuối cùng, sự kiện bị đưa vào **dead-letter**: nó sẽ không được gửi lại
nữa. Hai việc xảy ra:

1. Bản ghi delivery được đánh dấu `DEAD`, xem được tại
   `GET /v1/webhooks/{id}/deliveries`.
2. Một thông báo xuất hiện trong tài khoản Vieneu của bạn.

Audio không mất. `GET /v1/tts/{jobId}` vẫn trả về job kèm một URL tải mới có hạn 24
giờ. Nếu receiver của bạn từng down, hãy đối soát lại từ đó.

## Tự chẩn đoán {#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
  }
]
```

Các giá trị `lastError` bạn có thể gặp:

| Mã | Ý nghĩa |
| --- | --- |
| `http_4xx` / `http_5xx` | Endpoint của bạn trả về đúng status đó. |
| `redirect_refused` | Endpoint của bạn trả về `3xx`. Hãy trỏ nó thẳng tới URL cuối cùng. |
| `connect_timeout` / `response_timeout` | Endpoint của bạn không trả lời kịp. |
| `connection_refused` / `dns_failure` / `tls_failure` | Chúng tôi không tới được nó. |
| `blocked_private_address` | URL phân giải ra một địa chỉ không công khai. |
| `endpoint_unavailable` | Endpoint bị xoá hoặc bị tắt giữa chừng. |
| `audio_not_uploaded` | Audio chưa upload xong. Sẽ thử lại. |

## Xoay signing secret {#rotating-the-signing-secret}

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

Secret mới chỉ trả về một lần. Secret cũ vẫn xác minh được thêm **24 giờ**, và trong
cửa sổ đó mỗi sự kiện mang một `v1=` cho mỗi secret còn hiệu lực — nhờ vậy bạn deploy
được secret mới mà không đánh rơi sự kiện nào đã ký bằng secret cũ. Đoạn verifier ở
trên đã xử lý sẵn việc này: nó chấp nhận nếu bất kỳ `v1` nào khớp.
