CheaperGen

指南

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} 获取的对象相同。

POST https://your-app.com/hooks/cheapergen
{
  "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-Typestring
application/json
User-Agentstring
CheaperGen-Webhooks/1.0

验证签名#

在信任 Webhook 前务必验证签名,因为任何人都可以向公开 URL 发送 POST 请求。标头格式如下:

CheaperGen-Signature: t=1760520151,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
  1. 按 , 拆分标头,然后读取 t(时间戳)和 v1(签名)的值。
  2. 构造签名字符串:时间戳、字面值 .,然后拼接原样接收的 原始请求正文。
  3. 使用你的签名密钥作为密钥,对该字符串计算 HMAC-SHA256,并将结果编码为十六进制。
  4. 使用恒定时间比较,将结果与 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——我们会认真阅读每一封邮件。