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-Signature | t=<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_ 开头,可在控制台查看或轮换。轮换后立即对之后的所有投递生效(包括重试),请尽快更新你的服务器配置。
校验签名
签名可以证明请求来自我们且未被篡改。校验步骤:
- 读取原始请求体字节,在解析 JSON 之前。
- 将
Genkiln-Signature按,拆分出t和v1。 - 以完整密钥(包括
whsec_前缀)为 key,对字符串t + "." + 原始请求体计算HMAC-SHA256,并转为十六进制。 - 用常量时间比较函数与
v1比较。 - 如果
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。