指南
Webhooks
接收 generation.succeeded 和 generation.failed Webhook,并在 Node.js 或 Python 中验证 CheaperGen-Signature HMAC-SHA256 标头。
提交生成任务时传入 webhook_url,即可替代轮询。任务进入最终状态时,我们会向该 URL 发送 HTTPS POST,其中包含完整的生成任务对象,并使用账户密钥进行签名。
配置 Webhook#
在任意 POST /api/v1/run/{endpoint} 请求正文中添加 webhook_url 即可。该地址必须是可从互联网访问的 HTTPS URL。
curl -X POST https://cheapergen.com/api/v1/run/bytedance/seedance-2.0-fast/text-to-video \
-H "Authorization: Bearer $CHEAPERGEN_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"prompt": "黄金时刻,陶艺家缓缓将陶土塑成花瓶,光线中尘埃浮动,浅景深,镜头缓慢推进",
"aspect_ratio": "16:9",
"resolution": "720p",
"duration": 5,
"webhook_url": "https://your-app.com/hooks/cheapergen"
}'事件#
generation.succeededevent- 生成任务已完成。
data.output包含媒体 URL,data.cost包含最终费用。 generation.failedevent- 生成任务失败。
data.error会说明失败原因(reason、message)和处理方法(hint、retryable),详情见 失败原因;冻结金额已退还,data.cost.amount_usd为 0。
取消的生成任务不会触发 Webhook——任务由你取消,因此你已经知道结果。
请求负载#
请求正文为 JSON,包含事件类型 type 和位于 data 中的生成任务,与通过 GET /generations/{id} 获取的对象相同。
{
"type": "generation.succeeded",
"data": {
"id": "gen_8c2QmX1nq0k4Lw7Z",
"object": "generation",
"model": "bytedance/seedance-2.0-fast",
"endpoint": "bytedance/seedance-2.0-fast/text-to-video",
"mode": "text-to-video",
"status": "succeeded",
"input": {
"prompt": "黄金时刻,陶艺家缓缓将陶土塑成花瓶,光线中尘埃浮动,浅景深,镜头缓慢推进",
"aspect_ratio": "16:9",
"resolution": "720p",
"duration": 5,
"generate_audio": true
},
"output": {
"video": {
"url": "https://cdn.cheapergen.com/outputs/gen_8c2QmX1nq0k4Lw7Z.mp4",
"content_type": "video/mp4"
}
},
"error": null,
"cost": {
"amount_usd": 0.4494,
"currency": "usd",
"estimated": false
},
"source": "api",
"created_at": "2026-10-15T09:21:04.512Z",
"started_at": "2026-10-15T09:21:09.031Z",
"completed_at": "2026-10-15T09:22:31.870Z",
"retention": "1d",
"expires_at": "2026-10-16T09:22:31.870Z",
"purged_at": null
}
}标头#
CheaperGen-Signaturestring- 时间戳和签名:
t=<unix seconds>,v1=<hex HMAC-SHA256>。 CheaperGen-Webhook-Idstring- 此通知的唯一 ID,例如
whd_3f9c2a…。每次重试都保持不变,可据此去重。 Content-Typestringapplication/jsonUser-AgentstringCheaperGen-Webhooks/1.0
验证签名#
在信任 Webhook 前务必验证签名,因为任何人都可以向公开 URL 发送 POST 请求。标头格式如下:
CheaperGen-Signature: t=1760520151,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd- 按
,拆分标头,然后读取t(时间戳)和v1(签名)的值。 - 构造签名字符串:时间戳、字面值
.,然后拼接原样接收的 原始请求正文。 - 使用你的签名密钥作为密钥,对该字符串计算 HMAC-SHA256,并将结果编码为十六进制。
- 使用恒定时间比较,将结果与
v1比较;为防止重放攻击,还应拒绝早于几分钟前的时间戳。
你的签名密钥以 whsec_ 开头,可在 控制面板 的 API 密钥旁查看。请像保护 API 密钥一样,仅在服务器端保存签名密钥。
你的代码也可以用 API 密钥获取它:GET /api/v1/webhooks/secret 会返回 { "key": "whsec_…" }。如果账号还没有签名密钥,第一次调用时会自动创建。
curl https://cheapergen.com/api/v1/webhooks/secret \
-H "Authorization: Bearer $CHEAPERGEN_API_KEY"使用原始请求正文
请使用收到的原始字节进行验证。解析 JSON 后再重新序列化会改变空格和键顺序,导致签名不匹配。Node.js#
import crypto from "node:crypto";
const TOLERANCE_SECONDS = 300; // reject deliveries older than 5 minutes
/**
* Verify a CheaperGen webhook.
* @param rawBody the request body exactly as received (string or Buffer) — not re-serialized JSON
* @param header the value of the "CheaperGen-Signature" header
* @param secret your signing secret (whsec_…)
*/
export function verifyCheaperGenSignature(rawBody, header, secret) {
const parts = Object.fromEntries(
String(header ?? "")
.split(",")
.map((kv) => kv.trim().split("=", 2)),
);
const t = Number(parts.t);
const v1 = parts.v1;
if (!Number.isFinite(t) || !v1) return false;
if (Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.${rawBody}`)
.digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(v1, "hex");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}Python#
import hashlib, hmac, time
TOLERANCE_SECONDS = 300 # reject deliveries older than 5 minutes
def verify_cheapergen_signature(raw_body: bytes, header: str, secret: str) -> bool:
"""Verify a CheaperGen webhook. raw_body must be the exact bytes received."""
try:
parts = dict(kv.strip().split("=", 1) for kv in (header or "").split(","))
t, v1 = int(parts["t"]), parts["v1"]
except (KeyError, ValueError):
return False
if abs(time.time() - t) > TOLERANCE_SECONDS:
return False
signed = f"{t}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)Webhook 发送与重试#
- 请在 10 秒内 响应,返回任意 2xx 状态码以确认送达。系统不会跟随重定向。
- 如果你的端点超时或返回非 2xx 状态,我们会在最长 1 小时 内重试:大约在首次尝试后 1、3、8、18、38 和 60 分钟(共 7 次尝试),之后停止。返回
410 Gone会立即停止重试。 - 每次尝试都会用新的
t重新签名,因此重试和首次送达一样能通过 5 分钟时效校验。 - Webhook 可能重复送达,极少数情况下也可能乱序。请确保处理程序具备幂等性:以
CheaperGen-Webhook-Id标头为键(同一通知的每次重试都相同),并忽略已处理过的通知。 - Webhook 用于发送通知,不是唯一可信数据源。如果漏掉通知,始终可以通过
GET /api/v1/generations/{id}获取生成任务;定期核对仍处于待处理状态的任务,即可覆盖各种异常情况。
本地测试#
Webhook URL 必须是可公开访问的 HTTPS 地址。开发时,可使用隧道(例如 cloudflared tunnel 或 ngrok http 3000)公开本地服务器,并将隧道的 HTTPS URL 用作 webhook_url。
有不清楚或缺少的内容?发邮件至 support@cheapergen.com——我们会认真阅读每一封邮件。