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ạ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
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ọnhttp, ở 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 URLhttp. - 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
3xxbị 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
tlà unix timestamp của chính lần thử gửi này.v1là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 ý:
- Dùng đúng bytes thô của request body. Không phải một object đã serialize lại.
JSON.parserồiJSON.stringifysẽ đổ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ó. - Từ chối
tđã cũ. Dùng dung sai 300 giây. Đừng dùng0— như vậy là tắt hẳn phần kiểm tra thời gian. - 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). - 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 đúngidđó. tvàv1khá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. attemptcho biết đây là lần gửi đầu hay một lần thử lại. Nó đếm từ 1, còn headerVieneu-Deliveryđịnh danh đúng một lần thử HTTP cụ thể.
Các header khác
| 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
- 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.
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:
- Bản ghi delivery được đánh dấu
DEAD, xem được tạiGET /v1/webhooks/{id}/deliveries. - 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_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
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.