Webhook
在控制台“Webhook”页配置端点后,任务终态、订单与余额事件会以签名的 POST 推送到你的服务;失败自动重试最长 72 小时,投递记录可重放。
事件类型
| 事件 | 触发时机 | data 要点 |
|---|---|---|
| task.completed | 视频任务成功 | id, model, status, usage, billing, result_url |
| task.failed / task.timeout / task.cancelled | 任务失败 / 超时 / 被取消 | id, status, error, billing |
| order.paid | 充值或购买订单入账 | order_no, credit_micro_usd, order_type, plan_id, pack_id, fulfilment_skipped |
| order.refunded | 运营方冲正订单时触发(充值余额不支持退款) | order_no, full |
| balance.low | 可用余额跌破提醒阈值(每次跌破一次;充值回到阈值以上后才会再次触发) | available_micro_usd, threshold_micro_usd |
| subscription.expiring | 套餐即将到期(按通知设置的提前天数,每个套餐一次) | subscription_id, plan_name, expires_at |
| subscription.expired | 套餐已到期 | subscription_id, plan_name, expired_at |
| webhook.test | 控制台点击“发送测试” | endpoint_id, message |
- order.paid 的 fulfilment_skipped(bool)为 true 表示:订阅或资源包订单所用的优惠码在入账时已经失效,套餐或资源包没有开通,实付金额已经存入余额。这时 credit_micro_usd 是实际入账的金额,也就是扣除折扣后的实付额。
请求格式
每次投递是一个 JSON POST;X-Event-Id 在重试与重放中保持不变,请据此去重。
| 字段 | 类型 | 说明 |
|---|---|---|
| X-Event-Id | header | 事件唯一标识(如 task:<id>:succeeded)。 |
| X-Event-Type | header | 事件类型。 |
| X-Delivery-Id | header | 本次投递记录 ID,重放会产生新的 ID。 |
| X-Timestamp | header | 发送时间(Unix 秒)。 |
| X-Signature | header | t=<unix>,v1=<hex> — HMAC_SHA256(secret, "<unix>.<body>")。端点投递用该端点的密钥签名;任务 callback_url 投递用账户的任务回调签名密钥签名,格式相同。 |
{
"id": "task:cgt-2026…:succeeded",
"type": "task.completed",
"created_at": "2026-09-02T00:10:00Z",
"data": {
"id": "cgt-2026…", "client_request_id": "9d72…", "model": "seedance-2.0", "status": "succeeded",
"usage": {"completion_tokens": 246840, "total_tokens": 246840},
"billing": {"charged_micro_usd": 1819440, "frozen_micro_usd": 2500000},
"result_url": "https://…/video.mp4", "storage_status": "pending"
}
}校验签名
用端点创建时显示的密钥重新计算 HMAC,并拒绝时间戳偏差超过 5 分钟的请求;比较时使用常量时间函数。
import hmac, hashlib, time
def verify(secret: str, header: str, body: bytes, tolerance: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
ts, sig = parts["t"], parts["v1"]
if abs(time.time() - int(ts)) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{ts}.".encode() + body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, sig)import crypto from "node:crypto";
export function verify(secret, header, rawBody, tolerance = 300) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > tolerance) return false;
const expected = crypto.createHmac("sha256", secret).update(`${parts.t}.`).update(rawBody).digest("hex");
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}- 请用原始请求体(未解析的 bytes)计算签名。
- 端点连续失败会在控制台显示;密钥泄露时可随时轮换。
任务回调签名
创建视频等异步任务时传入的 callback_url,在任务进入终态后收到的 POST 同样带 X-Signature 请求头,格式与端点 Webhook 相同:t=<unix>,v1=hex(HMAC_SHA256(secret, "<unix>." + 原始请求体))。
签名密钥是账户级的“任务回调签名密钥”,以 whcb_ 开头,在第一次投递任务回调时自动生成。控制台 Webhook 页可以查看一次明文,之后只显示末四位;遗失后只能轮换出一把新密钥。
轮换后的 24 小时内旧密钥仍然有效:这段时间的请求头是 X-Signature: t=<unix>,v1=<新签名>,v1=<旧签名>,任意一个 v1 匹配即通过。请把接收方更新为新密钥,并让验签逻辑遍历所有 v1。这 24 小时内不能再次轮换(返回 409),以免接收方仍在用的原密钥立即失效。
校验时用原始请求体计算 HMAC,用常量时间函数比较,并拒绝时间戳与当前时间相差超过 5 分钟的请求,防止回调被截获后重放。
import crypto from "node:crypto";
import express from "express";
const secret = process.env.LINK42_CALLBACK_SECRET; // whcb_…
// X-Signature: t=<unix>,v1=<hex>[,v1=<hex>] — during the 24 hours after a
// rotation it carries one v1 per secret; any one matching is enough.
function verify(header, rawBody, tolerance = 300) {
const pairs = (header ?? "").split(",").map((part) => part.trim().split("="));
const t = pairs.find(([name]) => name === "t")?.[1];
const signatures = pairs.filter(([name, value]) => name === "v1" && value).map(([, value]) => value);
if (!t || signatures.length === 0 || Math.abs(Date.now() / 1000 - Number(t)) > tolerance) return false;
const expected = Buffer.from(crypto.createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest("hex"));
return signatures.some((signature) => signature.length === expected.length && crypto.timingSafeEqual(expected, Buffer.from(signature)));
}
const app = express();
// Keep the raw body: the signature covers the exact bytes that were sent.
app.post("/link42/callback", express.raw({ type: "application/json" }), (req, res) => {
if (!verify(req.get("X-Signature"), req.body)) return res.status(401).end();
const event = JSON.parse(req.body.toString("utf8"));
console.log(event.type, event.data?.status);
res.sendStatus(200);
});import hashlib
import hmac
import os
import time
from flask import Flask, abort, request
SECRET = os.environ["LINK42_CALLBACK_SECRET"] # whcb_…
app = Flask(__name__)
def verify(header: str, body: bytes, tolerance: int = 300) -> bool:
# X-Signature: t=<unix>,v1=<hex>[,v1=<hex>] - during the 24 hours after a
# rotation it carries one v1 per secret; any one matching is enough.
pairs = [part.strip().split("=", 1) for part in header.split(",") if "=" in part]
timestamps = [value for name, value in pairs if name == "t"]
signatures = [value for name, value in pairs if name == "v1" and value]
try:
if not timestamps or not signatures or abs(time.time() - int(timestamps[0])) > tolerance:
return False
except ValueError:
return False
expected = hmac.new(SECRET.encode(), f"{timestamps[0]}.".encode() + body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, signature) for signature in signatures)
@app.post("/link42/callback")
def callback():
# request.get_data() is the raw body the signature covers.
if not verify(request.headers.get("X-Signature", ""), request.get_data()):
abort(401)
event = request.get_json()
print(event["type"], event["data"].get("status"))
return "", 200- 签名覆盖原始字节:不要在解析或重新序列化 JSON 之后再计算。
- 密钥泄露时在控制台 Webhook 页轮换;示例中的 LINK42_CALLBACK_SECRET 是你保存密钥的环境变量名,可自行命名。
重试与重放
非 2xx 响应或超时(默认 10 秒)视为失败,按 30s、1m、2m … 指数退避重试,最长 6 小时一次,累计 72 小时或 20 次后标记为放弃。404 / 405 / 410 视为永久失败。控制台“投递记录”可查看每次结果并手动重放。
端点连续失败的时间超过 24 小时、且失败次数也达到阈值(默认 50 次)时,会被自动停用并通知你。停用期间的新事件以 paused 状态保留,不会丢失;在控制台重新启用后会恢复投递。你自己手动停用的端点不接收新事件。