Webhooks

任务结束时接收签名的 POST 回调:事件类型、请求头、负载、重试,以及 Node.js 与 Python 签名校验示例。

创建任务时设置 callback_url,任务一进入最终状态,我们就会把任务对象 POST 到该地址,无需轮询。

事件

事件触发时机
task.succeeded任务成功,output 中是结果地址。
task.failed任务失败(含超时),积分已退还。
task.canceled任务被取消,积分已退还。

每个任务最多发送一个事件,对应其最终状态。没有设置 callback_url 的任务不发送。

请求格式

HTTP
POST /webhooks/genkiln HTTP/1.1
Host: your-app.com
Content-Type: application/json
User-Agent: genkiln-webhooks/1.0
Genkiln-Event: task.succeeded
Genkiln-Delivery: dlv_01JC8ZT6V2B9Q4K7M1N3P5R8SX
Genkiln-Signature: t=1793669519,v1=42795bb28583b93ce4d9a0edddda87676f8e619e096d25285a470a905847cb0c

{"id":"task_01JC8ZQ0E6H2P4N7K9T3W5Y8VA","object":"task","model":"veo-3.1-fast","status":"succeeded", ...}
请求头说明
Genkiln-Event事件类型,例如 task.succeeded。
Genkiln-Delivery本次事件的唯一 ID。每次重试保持不变,可用于去重。
Genkiln-Signaturet=<Unix 秒>,v1=<十六进制 HMAC-SHA256>。见校验签名。

请求体就是任务对象,与 GET /v1/tasks/{id} 返回的内容相同,包含你的 metadata。

响应与重试

  • 请在 10 秒内返回任意 2xx 状态码。耗时的工作(下载文件、写数据库)放在响应之后或放进队列。
  • 其他情况都视为失败:非 2xx 状态码、超时、连接错误或重定向(我们不跟随重定向,请直接使用最终地址)。
  • 投递失败后分别在 1 分钟、5 分钟、30 分钟、2 小时后重试,总共 5 次。最后一次仍失败则标记为投递失败。你随时可以通过查询任务获取任务。
  • 由于存在重试,同一事件可能收到多次。请按 Genkiln-Delivery 或任务 id + status 去重。
  • callback_url 必须始终是公网 https 地址,每次投递前都会重新检查。

签名密钥

每个账号有一个 Webhook 签名密钥,以 whsec_ 开头,可在控制台查看或轮换。轮换后立即对之后的所有投递生效(包括重试),请尽快更新你的服务器配置。

校验签名

签名可以证明请求来自我们且未被篡改。校验步骤:

  1. 读取原始请求体字节,在解析 JSON 之前。
  2. 将 Genkiln-Signature 按 , 拆分出 t 和 v1。
  3. 以完整密钥(包括 whsec_ 前缀)为 key,对字符串 t + "." + 原始请求体 计算 HMAC-SHA256,并转为十六进制。
  4. 用常量时间比较函数与 v1 比较。
  5. 如果 t 与当前时间相差超过 5 分钟,拒绝请求,防止重放。

Node.js

Node.js
import crypto from 'node:crypto';

export function verifyGenkilnSignature(
  rawBody,
  header,
  secret,
  toleranceSec = 300
) {
  if (!header) return false;
  const parts = {};
  for (const item of header.split(',')) {
    const i = item.indexOf('=');
    if (i > 0) parts[item.slice(0, i).trim()] = item.slice(i + 1).trim();
  }
  const t = Number(parts.t);
  if (!Number.isInteger(t) || !parts.v1) return false;
  if (Math.abs(Math.floor(Date.now() / 1000) - t) > toleranceSec) return false;

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${t}.${rawBody}`)
    .digest();
  const received = Buffer.from(parts.v1, 'hex');
  return (
    received.length === expected.length &&
    crypto.timingSafeEqual(received, expected)
  );
}

使用 Express 时,为这个回调接口保留原始请求体:

Node.js
import express from 'express';

const app = express();

app.post(
  '/webhooks/genkiln',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const ok = verifyGenkilnSignature(
      req.body.toString('utf8'),
      req.get('Genkiln-Signature'),
      process.env.GENKILN_WEBHOOK_SECRET
    );
    if (!ok) return res.status(400).send('invalid signature');

    const task = JSON.parse(req.body.toString('utf8'));
    res.sendStatus(200); // respond first, then process
    handleTask(req.get('Genkiln-Event'), req.get('Genkiln-Delivery'), task);
  }
);

Python

Python
import hashlib
import hmac
import time


def verify_genkiln_signature(raw_body: bytes, header: str, secret: str, tolerance_sec: int = 300) -> bool:
    if not header:
        return False
    parts = {}
    for item in header.split(","):
        key, sep, value = item.partition("=")
        if sep:
            parts[key.strip()] = value.strip()
    try:
        t = int(parts["t"])
        received = parts["v1"]
    except (KeyError, ValueError):
        return False
    if abs(int(time.time()) - t) > tolerance_sec:
        return False

    signed = f"{t}.".encode() + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, received)

使用 Flask:

Python
import json
import os

from flask import Flask, request

app = Flask(__name__)


@app.post("/webhooks/genkiln")
def genkiln_webhook():
    raw = request.get_data()  # raw bytes, not request.json
    if not verify_genkiln_signature(
        raw, request.headers.get("Genkiln-Signature", ""), os.environ["GENKILN_WEBHOOK_SECRET"]
    ):
        return "invalid signature", 400

    task = json.loads(raw)
    event = request.headers.get("Genkiln-Event")
    delivery_id = request.headers.get("Genkiln-Delivery")
    # enqueue (event, delivery_id, task) for processing, then:
    return "", 200

请对收到的原始字节做校验。先解析 JSON 再重新序列化会改变字节内容,签名将无法匹配。

本地测试

回调地址必须能通过公网 https 访问,localhost 不行。开发时可以用内网穿透工具暴露本地服务,并把隧道的 https 地址作为 callback_url。