Webhook 回调
在你自己的地址接收已完成的任务,校验签名,处理重试和重复投递。
有了 Webhook,任务完成时 PopNow 会主动告诉你,不用一遍遍去问。它有两种:
| 任务回调 | 账户级端点 | |
|---|---|---|
| 怎么设置 | 每次 createTask 带上 callBackUrl | 在 API 控制台登记一次 |
| 推送什么 | 这一个任务 | 账户里所有符合你所选事件的任务 |
| 签名 | X-Webhook-Signature(见下文) | Standard Webhooks 规范的请求头(见下文) |
大多数接入只需要任务回调。
1. 拿到签名密钥
每次投递都用你账户的 Webhook 签名密钥签名。打开 API 控制台 的 Webhooks 页,生成(或轮换)密钥,保存在服务器上,记为 POPNOW_WEBHOOK_SECRET。它只显示一次。轮换立即生效,包括还在重试中的投递,所以轮换后请马上更新接收端。
2. 传入 callBackUrl
{
"model": "alibaba/wan-3.0/image-to-video",
"input": { "image_url": "https://media.popnow.ai/movie/lighthouse.webp" },
"callBackUrl": "https://your-app.example.com/hooks/popnow"
}这个地址必须是公开的 HTTPS,端口为 443(或不写端口),不能带用户名或密码,而且解析出来只能是公网地址——创建任务时检查一次,连接时还会再查一次。http://localhost 不可能收到;本地开发时请用 Cloudflare Tunnel 或 ngrok 这类隧道,或者改用轮询。地址不合法会返回 400,msg 为 "Invalid callback URL"。
3. 收到的内容
任务进入终态时,PopNow 发送一次 POST:
POST /hooks/popnow HTTP/1.1
Content-Type: application/json
User-Agent: PopNow-Webhook/1
X-Webhook-Timestamp: 1787045531
X-Webhook-Signature: 9tXk3…=
X-Webhook-Payload-Version: 2请求体和 recordInfo 对这个任务的返回完全一样:
{
"code": 200,
"msg": "success",
"data": {
"taskId": "job_01JH8Z4Q5W2N7R3K9M6P1C0VXD",
"model": "alibaba/wan-3.0/image-to-video",
"state": "succeeded",
"output": { "assets": [{ "url": "https://…", "mime": "video/mp4", "durationSeconds": 5 }] },
"credits": 34,
"cost": "0.34",
"settled": true
},
"request_id": "whd_01JH8Z…"
}- 即使任务失败,
code也是200:它描述的是这次投递,不是任务本身。请看data.state。 request_id是投递 ID,重试时保持不变,用它来忽略重复投递。output里的签名链接约 20 分钟后过期。请保存taskId,不要保存链接。
4. 校验签名
signature = base64( HMAC-SHA256( secret, "<taskId>.<timestamp>.<sha256_hex(raw_body)>" ) )taskId 取请求体里的 data.taskId,timestamp 是 X-Webhook-Timestamp 请求头,最后一段是原始请求体的 SHA-256,小写十六进制。要对你收到的字节做哈希,不要先解析再序列化:键的顺序和空白一变,结果就不一样了。
import crypto from 'node:crypto';
import express from 'express';
const app = express();
const SECRET = process.env.POPNOW_WEBHOOK_SECRET;
// express.raw keeps the exact bytes; express.json would throw them away.
app.post('/hooks/popnow', express.raw({ type: 'application/json', limit: '10mb' }), (req, res) => {
const ts = Number(req.headers['x-webhook-timestamp']);
const sig = String(req.headers['x-webhook-signature'] ?? '');
// Refuse anything older than five minutes, however valid its signature.
if (!Number.isInteger(ts) || Math.abs(Date.now() / 1000 - ts) > 300) return res.status(401).end();
let event;
try { event = JSON.parse(req.body.toString('utf8')); } catch { return res.status(400).end(); }
const digest = crypto.createHash('sha256').update(req.body).digest('hex');
const expected = crypto.createHmac('sha256', SECRET)
.update(`${event.data?.taskId}.${ts}.${digest}`).digest('base64');
const ok = expected.length === sig.length && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig));
if (!ok) return res.status(401).end();
res.status(200).end(); // acknowledge first…
enqueue(event.request_id, event.data); // …then do the work
});import hashlib, hmac, base64, json, os, time
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["POPNOW_WEBHOOK_SECRET"].encode()
@app.post("/hooks/popnow")
def popnow_hook():
raw = request.get_data() # the exact bytes
ts = request.headers.get("X-Webhook-Timestamp", "")
sig = request.headers.get("X-Webhook-Signature", "")
if not ts.isdigit() or abs(time.time() - int(ts)) > 300:
abort(401)
event = json.loads(raw)
digest = hashlib.sha256(raw).hexdigest()
message = f'{event["data"]["taskId"]}.{ts}.{digest}'.encode()
expected = base64.b64encode(hmac.new(SECRET, message, hashlib.sha256).digest()).decode()
if not hmac.compare_digest(expected, sig):
abort(401)
enqueue(event["request_id"], event["data"]) # do the slow work elsewhere
return "", 200新接入的 X-Webhook-Payload-Version 都是 2。版本 1 是为老接收端保留的旧格式;万一收到,任务 ID 在顶层的 task_id。
尽快响应
15 秒内返回任意 2xx 就算收到。其他情况——别的状态码、超时、连接被拒——都会重试。请先确认收到,再把耗时的工作(下载、转码、通知用户)放到后台任务里做。
重试与重复
投递失败后会再重试 7 次,间隔依次为 10 秒、30 秒、2 分钟、10 分钟、30 分钟、2 小时和 6 小时。每次重试都会用新的时间戳重新签名,所以请始终拿时间戳和当前时间比,而不是和第一次投递比。最后一次重试也失败后,这次投递会在 API 控制台里标记为失败,你可以在那里手动重发。
所以同一个任务可能到达不止一次。请让处理逻辑幂等:给 request_id 建唯一索引,再次遇到时直接回 200,什么都不做。
如果你的服务器停机时间超过了重试窗口,用 GET /api/v1/jobs 列出最近的任务,再用 recordInfo 补读漏掉的。
账户级端点
账户级端点是在 API 控制台里登记一次的地址,同时选好要接收的事件。它会收到账户里所有任务的事件,你就不必每次都传 callBackUrl。
| 事件 | 何时发送 |
|---|---|
task.completed | 下面四种中的任意一种。除非需要区分,否则订阅这一个就好 |
task.succeeded | 任务成功完成 |
task.failed | 任务失败;冻结的积分已退回 |
task.expired | 任务超过截止时间;冻结的积分已退回 |
task.canceled | 只为历史记录保留 |
投递遵循 Standard Webhooks 格式,现成的库可以直接用:
webhook-id: msg_01k3m8x9q2z4v7n5p6r8s0t1w3
webhook-timestamp: 1787045531
webhook-signature: v1,base64( HMAC-SHA256( key, "<webhook-id>.<webhook-timestamp>.<raw_body>" ) )和任务回调有两点不同:签的是请求体本身(没有 SHA-256 这一步);HMAC 密钥是密钥字符串里 whsec_ 之后那部分做 Base64 解码得到的字节,而不是那个字符串本身。轮换密钥期间,请求头里可能有两个用空格隔开的签名,任意一个匹配就接受。webhook-id 在重试时保持不变,用它忽略重复。事件里的 data.object 是事件产生那一刻的任务记录。

