Chuyển tới nội dung chính

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.

Sự kiện​

LoạiKhi nào
tts.job.completedJob đã tạo ra audio. Sự kiện mang theo một URL tải về có hiệu lực ngắn.
tts.job.failedJob 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​

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

  • 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​

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​

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

{
"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​

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ý​

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​

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:

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​

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​

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​

HeaderÝ nghĩa
Vieneu-Signaturet=…,v1=… — xem ở trên.
Vieneu-Event-IdCùng giá trị với id trong body. Tiện để khử trùng lặp ngay ở lớp biên.
Vieneu-Event-TypeCù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​

  • 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​

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.

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​

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​

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

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

MãÝ nghĩa
http_4xx / http_5xxEndpoint của bạn trả về đúng status đó.
redirect_refusedEndpoint của bạn trả về 3xx. Hãy trỏ nó thẳng tới URL cuối cùng.
connect_timeout / response_timeoutEndpoint của bạn không trả lời kịp.
connection_refused / dns_failure / tls_failureChúng tôi không tới được nó.
blocked_private_addressURL phân giải ra một địa chỉ không công khai.
endpoint_unavailableEndpoint bị xoá hoặc bị tắt giữa chừng.
audio_not_uploadedAudio chưa upload xong. Sẽ thử lại.

Xoay signing secret​

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.