设计实践

长时间的生成任务不必一直轮询:Pika 会主动回调 Webhook

长时间的生成任务不必一直轮询:Pika 会在任务完成或失败时主动回调你的 webhook。本篇写一个接收端:按 Standard Webhooks 规范用原始请求字节验签,按 webhook-id 去重(重试和重放都会复用这个 id),把结果记录到 SQLite。另外写一个本地发送脚本,模拟正常、重放、失败、篡改、过期五种情况。

BINBHO · 图文教程

长时间的生成任务不必一直轮询:Pika 会在任务完成或失败时主动回调你的 webhook。本篇写一个接收端:按 Standard Webhooks 规范用原始请求字节验签,按 webhook-id 去重(重试和重放都会复用这个 id),把结果记录到 SQLite。另外写一个本地发送脚本,模拟正常、重放、失败、篡改、过期五种情况。

先看清成品(box 上实测,完全离线,不需要 Pika Key)

  • 测试: pytest 2 项通过。

  • 五种情况:

    情况 结果
    正常完成事件 200,已记录
    同一个 webhook-id 重放 200,标记为重复,忽略
    失败事件 200,记录错误码 content_moderation
    body 被篡改 400 “No matching signature found”
    时间戳是 10 分钟前 400 “Message timestamp too old”
  • 数据库: 只有 2 条记录。

没有在 box 上跑的部分: 没有收到过 Pika 的真实回调。真实回调要求地址是公网可访问的 HTTPS(443 端口),不能是内网地址。本篇的接收端只监听 127.0.0.1,上线时要放在反向代理或隧道后面,并且要 HTTPS。签名逻辑使用 Pika 文档推荐的 standardwebhooks 库,测试中的签名由本地的 secret 生成。

合规提醒:PIKA_API_KEY 和 webhook 签名密钥只放在服务器端,不要写进浏览器代码、URL、截图或聊天里(Pika 文档原话也是这样要求的)。输入的图片和视频必须是你有权使用的,不要用他人肖像冒充真人,不要侵犯版权或商标。生成的内容发布时要按《人工智能生成合成内容标识办法》加上“AI 生成”标识,并遵守 Pika 的使用条款。Pika API 是预付费的,没有免费的生成额度,也没有测试环境,每次生成都按实时报价扣费。本文不列价格,示例输出里的金额已用“x.xx”代替,请以运行时的报价为准。

做完之后你会得到什么?

成果 说明
webhook_receiver.py Flask 接收端 /pika/webhooks:验签 → 以 webhook-id 为主键写入 SQLite(重复的 id 直接忽略)→ 尽快返回 2xx
send_test_events.py 用同一个 secret 签名,发送 5 种测试事件
test_verify.py 原始字节能验过;重新序列化后的 JSON 验不过

需要准备哪些环境?

项目 要求 说明
Python 3.9 以上 pip install flask standardwebhooks requests pytest
签名密钥 whsec_ 开头 在控制台的 Webhooks 页面获取;单次请求的 webhook_url 用组织级密钥(GET /v1/webhooks/secret)。本地测试可以自己生成一个
公网地址 HTTPS 443 端口 只有接收真实回调时才需要

文件结构

48-pika-C-src/
├── webhook_receiver.py
├── send_test_events.py
└── test_verify.py

第 1 步:代码

webhook_receiver.py:

"""接收 Pika 的 webhook(任务完成/失败时 Pika 主动回调),验签、去重后记录结果。
文档(dev.pika.art/llms/webhooks):
  - 签名遵循 Standard Webhooks:请求头 webhook-id / webhook-timestamp / webhook-signature
  - 必须用“原始请求字节”验签,重新序列化的 JSON 验不过
  - 重试和重放会复用 webhook-id → 按 webhook-id 去重;投递不保证顺序,用 data.id 对应任务
  - 事件:media.job.completed、media.job.failed(看 data.error.code)、webhook.test(控制台的“发送测试”)
  - webhook 里的 billing 永远是 null,不能用来记账(记账见 48-pika-B)
  - 回调地址必须是公网可访问的 HTTPS 443 端口:本地开发要放在反向代理 / 隧道后面,本脚本只监听 127.0.0.1
环境变量:PIKA_WEBHOOK_SECRET(whsec_ 开头,在控制台 Webhooks 页或 GET /v1/webhooks/secret 获取)、DB(默认 webhooks.db)
用法:python webhook_receiver.py   (监听 127.0.0.1:18490,路径 /pika/webhooks)"""
import json, os, sqlite3, sys
from flask import Flask, request
from standardwebhooks import Webhook, WebhookVerificationError

app = Flask(__name__)
SECRET = os.environ.get("PIKA_WEBHOOK_SECRET") or sys.exit("请先设置 PIKA_WEBHOOK_SECRET")
wh = Webhook(SECRET)
db = sqlite3.connect(os.environ.get("DB", "webhooks.db"), check_same_thread=False)
db.execute("CREATE TABLE IF NOT EXISTS events (webhook_id TEXT PRIMARY KEY, type TEXT, job_id TEXT, status TEXT, video_url TEXT, error_code TEXT, received_at TEXT DEFAULT CURRENT_TIMESTAMP)")

@app.post("/pika/webhooks")
def receive():
    raw = request.get_data()                       # 原始字节,不要用 request.get_json() 再 dumps
    try: event = wh.verify(raw, dict(request.headers))
    except WebhookVerificationError as e:
        print(f"✗ 验签失败:{e}", flush=True); return {"error": "invalid signature"}, 400
    wid = request.headers["webhook-id"]
    d = event.get("data") or {}
    try:
        db.execute("INSERT INTO events (webhook_id, type, job_id, status, video_url, error_code) VALUES (?,?,?,?,?,?)",
                   (wid, event["type"], d.get("id"), d.get("status"), ((d.get("output") or {}).get("video") or {}).get("url"), (d.get("error") or {}).get("code")))
        db.commit()
    except sqlite3.IntegrityError:
        print(f"↷ {wid} 已处理过(重试/重放),忽略", flush=True); return {"ok": True, "duplicate": True}
    print(f"✓ {event['type']} job={d.get('id')} status={d.get('status')}" + (f" error={d['error']['code']}" if d.get("error") else ""), flush=True)
    return {"ok": True}                            # 尽快返回 2xx;下载视频等耗时操作放到后台任务里做

if __name__ == "__main__":
    app.run(host="127.0.0.1", port=18490)

send_test_events.py:

"""本地测试:用同一个 secret 按 Standard Webhooks 签名,模拟 Pika 发几种回调(正常、重放、篡改、过期、重新序列化)。
用法:PIKA_WEBHOOK_SECRET=whsec_... python send_test_events.py"""
import json, os, time, uuid
from datetime import datetime, timezone
import requests
from standardwebhooks import Webhook

URL = os.environ.get("URL", "http://127.0.0.1:18490/pika/webhooks")
wh = Webhook(os.environ["PIKA_WEBHOOK_SECRET"])

def send(label, payload, wid=None, ts=None, tamper=False):
    wid = wid or f"msg_{uuid.uuid4()}"; ts = ts or datetime.now(timezone.utc)
    body = json.dumps(payload, ensure_ascii=False)
    sig = wh.sign(wid, ts, body)
    if tamper: body = body.replace("completed", "completed ")
    r = requests.post(URL, data=body.encode(), headers={"content-type": "application/json", "webhook-id": wid,
                      "webhook-timestamp": str(int(ts.timestamp())), "webhook-signature": sig}, timeout=10)
    print(f"{label:<28} → HTTP {r.status_code} {r.text.strip()}"); return wid

job = {"id": "media_demo-1", "status": "completed", "model_id": "pika/pika-2.5/image-to-video", "runtime_s": 42.7,
       "output": {"media_type": "video", "video": {"url": "https://api.dev.pika.art/v1/files/output.mp4", "content_type": "video/mp4"}},
       "error": None, "usage": {"video_output_tokens": 50638}, "billing": None}
ev = {"type": "media.job.completed", "timestamp": datetime.now(timezone.utc).isoformat(), "data": job}
w = send("1 正常的完成事件", ev)
send("2 同一个 webhook-id 重放", ev, wid=w)
send("3 失败事件", {**ev, "type": "media.job.failed", "data": {**job, "id": "media_demo-2", "status": "failed", "output": None, "error": {"code": "content_moderation", "message": "…"}}})
send("4 body 被篡改", ev, tamper=True)
send("5 时间戳是 10 分钟前", ev, ts=datetime.fromtimestamp(time.time() - 600, timezone.utc))

test_verify.py:

import base64, json, os
from datetime import datetime, timezone
import pytest
from standardwebhooks import Webhook, WebhookVerificationError
SECRET = "whsec_" + base64.b64encode(b"0123456789abcdef0123456789abcdef").decode()

def signed(body):
    w = Webhook(SECRET); ts = datetime.now(timezone.utc)
    return {"webhook-id": "msg_1", "webhook-timestamp": str(int(ts.timestamp())), "webhook-signature": w.sign("msg_1", ts, body)}

def test_raw_bytes_verify():
    body = '{"type": "media.job.completed", "data": {"id": "m1"}}'
    assert Webhook(SECRET).verify(body.encode(), signed(body))["data"]["id"] == "m1"

def test_reserialized_json_fails():
    body = '{"type": "media.job.completed", "data": {"id": "m1"}}'
    h = signed(body); again = json.dumps(json.loads(body), separators=(",", ":"))
    with pytest.raises(WebhookVerificationError): Webhook(SECRET).verify(again.encode(), h)

第 2 步:运行

$ export PIKA_WEBHOOK_SECRET="whsec_$(head -c 24 /dev/urandom | base64)"    # 本地测试用;正式使用填控制台给的密钥
$ python webhook_receiver.py &
$ python send_test_events.py
1 正常的完成事件                    → HTTP 200 {"ok":true}
2 同一个 webhook-id 重放          → HTTP 200 {"duplicate":true,"ok":true}
3 失败事件                       → HTTP 200 {"ok":true}
4 body 被篡改                   → HTTP 400 {"error":"invalid signature"}
5 时间戳是 10 分钟前                → HTTP 400 {"error":"invalid signature"}

# 接收端日志
✓ media.job.completed job=media_demo-1 status=completed
↷ msg_f73030a0-… 已处理过(重试/重放),忽略
✓ media.job.failed job=media_demo-2 status=failed error=content_moderation
✗ 验签失败:No matching signature found
✗ 验签失败:Message timestamp too old

检查点:数据库里只有 2 条记录(completed 和 failed),重放的那次没有重复写入。

调试示例

1. 重新序列化的 JSON 验不过。 Flask 里最顺手的写法是 request.get_json(),再用 json.dumps 转回字符串去验签。test_verify.py 的第二个测试复现了这个错误:同样的内容,只是序列化方式不同(这里是去掉了空格),签名就对不上了,抛出 WebhookVerificationError。Pika 文档也专门强调要传原始字节,所以代码里用的是 request.get_data()。

2. 后台日志是空的。 接收端用 nohup 放到后台运行,测试全部通过,日志文件里却什么都没有。原因是 Python 的标准输出在不连接终端时是块缓冲的。给 print 加上 flush=True 之后,日志就实时写入了。如果用 systemd 或 Docker 运行,也可以设置 PYTHONUNBUFFERED=1。

常见问题

现象 原因 处理
全部验签失败 密钥用错了:endpoint 的密钥和组织级密钥是不同的 单次请求的 webhook_url 用组织级密钥
Message timestamp too old 服务器时间不准,或者请求被延迟、重放 校准时间(NTP)
同一个任务收到多次回调 重试最长持续约 55 小时 按 webhook-id 去重(本文的做法)
回调地址注册失败 不是公网 HTTPS 443,或者是内网地址 用正式域名和证书

模板规则

  • 验签用原始字节,并且用规范的库。
  • 以 webhook-id 去重,以 data.id 对应任务。
  • 回调处理要快:收到后马上返回 2xx,耗时的工作交给后台。

项目资源

用途 地址
Pika API 首页(llms.txt) https://dev.pika.art/llms.txt
Pika API 开发者站点 https://dev.pika.art/
Pika Webhooks 规格 https://dev.pika.art/llms/webhooks
Standard Webhooks 规范 https://www.standardwebhooks.com/
standardwebhooks(Python 库) https://pypi.org/project/standardwebhooks/

有了验签和去重,webhook 才能放心交给生产环境,不用担心伪造的或重复的回调。