Webhook

任务完成或失败时,主动通知你的服务器。

配置 Webhook 后,通过 API 创建的任务结束时,我们会向你的地址发送一个 HTTPS POST 请求,不需要再轮询任务状态。

配置地址

  1. 打开控制台的 Webhook 页面,点击“添加地址”。
  2. 填写公网可访问的 https:// 地址,选择要订阅的事件。
  3. 保存后复制签名密钥(以 whsec_ 开头,只显示一次),保存到你的服务配置中。
  4. 点击“发送测试事件”确认你的服务能收到请求。

每个账号最多 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 并丢弃该请求:

  1. 从请求头取出 t 和 v1。
  2. 检查 t 与当前时间相差不超过 5 分钟,防止重放攻击。
  3. 用原始请求体(未经 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 地址。
  • 停用或删除地址后,不再向它发送事件;停用期间产生的事件不会补发。