Webhook
任务完成或失败时,主动通知你的服务器。
配置 Webhook 后,通过 API 创建的任务结束时,我们会向你的地址发送一个 HTTPS POST 请求,不需要再轮询任务状态。
配置地址
- 打开控制台的 Webhook 页面,点击“添加地址”。
- 填写公网可访问的
https://地址,选择要订阅的事件。 - 保存后复制签名密钥(以
whsec_开头,只显示一次),保存到你的服务配置中。 - 点击“发送测试事件”确认你的服务能收到请求。
每个账号最多 5 个地址,每个地址有独立的密钥。可以为测试环境和生产环境分别添加地址,互不影响。
事件类型
| 事件 | 触发时机 | data |
|---|---|---|
synthesis.completed | 合成任务完成,音频可下载 | 任务对象 |
synthesis.failed | 合成任务失败(积分自动退还) | 任务对象,含 error 与 refund |
batch.completed | 批量任务的所有条目都已结束 | 批量对象 |
webhook.test | 在控制台点击“发送测试事件” | { endpoint_id, message } |
只有通过 API 创建的任务会触发事件,网页版中的操作不会发送回调。
请求格式
POST /your/webhook/path HTTP/1.1
Content-Type: application/json
User-Agent: VoiceAILabs-Webhooks/1.0
VoiceAILabs-Event: synthesis.completed
VoiceAILabs-Delivery: 5f0e2c1a-…
VoiceAILabs-Signature: t=1791520000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
{
"id": "evt_3b9f0c…",
"object": "event",
"type": "synthesis.completed",
"created_at": "2026-10-09T02:00:06.000Z",
"data": {
"id": "9a3e6b1c-…",
"object": "synthesis",
"type": "tts",
"status": "completed",
"audio_url": "https://cdn.voiceailabs.com/…/9a3e6b1c.mp3",
"duration_ms": 3240,
"credits_used": 16
}
}
id是事件 ID:同一事件即使重试多次,id也不变,请用它去重。VoiceAILabs-Delivery是本次投递的 ID,可在控制台的投递记录中对应查找。
验证签名
每个请求都带有 VoiceAILabs-Signature 头,格式为 t=<Unix 秒>,v1=<签名>。签名是用你的密钥对 <t>.<原始请求体> 计算的 HMAC-SHA256(十六进制)。
请按以下步骤验证,任一步不通过就返回 400 并丢弃该请求:
- 从请求头取出
t和v1。 - 检查
t与当前时间相差不超过 5 分钟,防止重放攻击。 - 用原始请求体(未经 JSON 解析和重新序列化的字节)计算签名,并与
v1做常量时间比较。
import crypto from "node:crypto";
import express from "express";
const SECRET = process.env.VAL_WEBHOOK_SECRET; // whsec_...
const TOLERANCE_SECONDS = 300;
function verify(rawBody, header) {
const parts = Object.fromEntries(header.split(",").map((part) => part.split("=")));
const timestamp = Number(parts.t);
if (!timestamp || !parts.v1) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false;
const expected = crypto.createHmac("sha256", SECRET).update(`${timestamp}.${rawBody}`).digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(parts.v1, "hex");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
const app = express();
// 必须拿到原始请求体,不能先用 express.json() 解析
app.post("/webhooks/voiceailabs", express.raw({ type: "application/json" }), async (req, res) => {
const rawBody = req.body.toString("utf8");
if (!verify(rawBody, req.get("VoiceAILabs-Signature") || "")) {
return res.status(400).send("invalid signature");
}
const event = JSON.parse(rawBody);
// 先返回 2xx,再异步处理耗时逻辑
res.sendStatus(204);
if (await alreadyHandled(event.id)) return;
if (event.type === "synthesis.completed") {
await saveAudio(event.data.id, event.data.audio_url);
}
});import hashlib
import hmac
import json
import os
import time
from flask import Flask, abort, request
SECRET = os.environ["VAL_WEBHOOK_SECRET"].encode() # whsec_...
TOLERANCE_SECONDS = 300
app = Flask(__name__)
def verify(raw_body: bytes, header: str) -> bool:
try:
parts = dict(part.split("=", 1) for part in header.split(","))
timestamp = int(parts["t"])
except (KeyError, ValueError):
return False
if abs(time.time() - timestamp) > TOLERANCE_SECONDS:
return False
expected = hmac.new(SECRET, f"{timestamp}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))
@app.post("/webhooks/voiceailabs")
def handle_webhook():
raw_body = request.get_data() # 原始字节
if not verify(raw_body, request.headers.get("VoiceAILabs-Signature", "")):
abort(400)
event = json.loads(raw_body)
if event["type"] == "synthesis.completed":
save_audio(event["data"]["id"], event["data"]["audio_url"])
return "", 204响应与重试
- 你的服务在 10 秒内返回任意
2xx状态码即视为投递成功。耗时的处理(如下载音频)请放到后台执行。 - 返回其他状态码、超时、连接失败或返回 3xx 跳转都视为失败。失败后会在 1 分钟、5 分钟、30 分钟、2 小时、6 小时、12 小时后重试,共 7 次。全部失败后不再自动重试,可以在控制台的投递记录中手动“重新投递”。
- 同一事件可能送达多次(例如你的服务处理成功但响应超时),请用事件
id去重。 - 事件不保证按发生顺序送达;如果需要最新状态,可以用
GET /syntheses/{id}再确认。
安全建议
- 只信任签名验证通过的请求,不要仅凭来源 IP 判断。
- 密钥泄露后,在控制台“重置密钥”。旧密钥立即失效,请同步更新你的服务。
- 回调地址必须是公网
https地址,不支持内网地址、本机地址和跳转。本地开发可以使用 ngrok、Cloudflare Tunnel 等工具暴露一个临时的 https 地址。 - 停用或删除地址后,不再向它发送事件;停用期间产生的事件不会补发。