PopNowAPI 文档
用 API 构建
正文

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,小写十六进制。要对你收到的字节做哈希,不要先解析再序列化:键的顺序和空白一变,结果就不一样了。

Node.js (Express)
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
});
Python (Flask)
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 是事件产生那一刻的任务记录。

本页内容