AI 工具

用 Replicate Webhook 接收预测事件:HMAC 验签、重放防护与去重

预测时间长时,与其一直轮询,不如在创建时传 webhook,让 Replicate 在状态变化时 POST 给你。 这篇做 replicatewebhook.py:按官方方法验证 HMAC-SHA256 签名,拒绝过期请求,按 webhook-id 去重,忽略终态之后迟到的事件,并把结果写进 predictions.jsonl。

BINBHO · 图文教程

预测时间长时,与其一直轮询,不如在创建时传 webhook,让 Replicate 在状态变化时 POST 给你。 这篇做 replicate_webhook.py:按官方方法验证 HMAC-SHA256 签名,拒绝过期请求,按 webhook-id 去重,忽略终态之后迟到的事件,并把结果写进 predictions.jsonl。

先看清成品

  • 用 Standard Webhooks 的官方测试数据验证了验签实现(同一个签名也出现在 Replicate 文档的示例里)。
  • 在 box 上真实运行:5 次投递(处理中、成功、重复、迟到、错误密钥)全部按预期处理。
  • 真实问题:直接用测试数据验签返回“时间戳超出容差”,因为那个时间戳是 2021 年的;验签函数因此加了可注入的 now 参数。5 项 pytest 通过。

合规提醒:只用 Replicate 官方 HTTP API 和你自己的 API Token(replicate.com/account/api-tokens),Token 只放服务端,不共享账号。按运行时间计费,价格以 replicate.com/pricing 为准,本文不写价格。每个模型有自己的许可证和使用限制(在模型页面查看),生成内容要遵守 Replicate 服务条款,不生成侵权或违法内容,不冒用真人肖像。

运行说明:我没有 Replicate Token,没有跑过真实预测(也就没有产生费用)。真实跑过的是:用无效 Token 调用官方 API(2026-10-21 21:09–21:40,UTC+8,返回原样贴在文中),以及全部本地代码。webhook 内容和签名是我用自己生成的测试密钥做的,字段照官方 HTTP 参考里的 prediction 对象。

做完之后你会得到什么?

成果 说明
replicate_webhook.py 验签 + 去重 + 顺序处理
send_test_webhook.py 本地模拟投递
samples/ 示例预测事件(我按官方字段写的)
test_replicate_webhook.py 5 项测试

需要准备哪些环境?

项目 说明
Replicate 账号 注册后在 replicate.com/account/api-tokens 创建 Token(形如 r8_…)
Python 3.9 以上,pip install requests pytest
环境变量 export REPLICATE_API_TOKEN=r8_...
公网 HTTPS 地址 真实使用时 Replicate 要能访问到;本文只在本机测试

关键事实

事实 来源
请求头:webhook-id、webhook-timestamp(秒)、webhook-signature(空格分隔的多个签名,形如 v1,xxx) 官方 验证 webhook
签名内容 {webhook-id}.{webhook-timestamp}.{原始body},用密钥 whsec_ 后面的部分(Base64)做 HMAC-SHA256;建议常量时间比较 同上
密钥用 GET /v1/webhooks/default/secret 获取,建议本地缓存 同上
终态 webhook 收到 4xx/5xx 或连不上会重试;中间状态不重试 官方 接收 webhook
同一 webhook 可能发送多次,少数情况下会乱序;建议终态之后的 webhook 一律忽略 同上
webhook_events_filter 可选 start、output、logs、completed 同上

文件结构

85-replicate-B-src/
├── replicate_webhook.py
├── send_test_webhook.py
├── samples/
│   ├── processing.json
│   └── succeeded.json
└── test_replicate_webhook.py

第 1 步:接收服务

"""接收 Replicate 预测的 webhook:验签(HMAC-SHA256)、防重放、按 webhook-id 去重、
忽略终态之后迟到的事件,并在成功时记录输出文件(API 生成的文件 1 小时后删除)。

签名内容:{webhook-id}.{webhook-timestamp}.{原始 body}
密钥:GET /v1/webhooks/default/secret 返回的 key,去掉 whsec_ 前缀后 Base64 解码。"""
import base64, hashlib, hmac, json, os, time
from http.server import BaseHTTPRequestHandler, HTTPServer

TOLERANCE = 300      # 时间容差由你决定,官方只要求“在你的容差范围内”
TERMINAL = {"succeeded", "failed", "canceled", "aborted"}   # aborted 见“预测生命周期”页


def verify(secret, headers, body, now=None):
    wid, ts, sigs = headers.get("webhook-id"), headers.get("webhook-timestamp"), headers.get("webhook-signature")
    if not (wid and ts and sigs):
        return False, "缺少 webhook-* 请求头"
    now = int(time.time()) if now is None else now
    try:
        if abs(now - int(ts)) > TOLERANCE:
            return False, "时间戳超出容差"
    except ValueError:
        return False, "时间戳格式错误"
    key = base64.b64decode(secret.split("_", 1)[1] if secret.startswith("whsec_") else secret)
    expected = base64.b64encode(hmac.new(key, f"{wid}.{ts}.".encode() + body, hashlib.sha256).digest()).decode()
    for item in sigs.split():                        # 可能有多个,空格分隔,形如 v1,xxxx
        _, _, sig = item.partition(",")
        if hmac.compare_digest(sig, expected):      # 常量时间比较
            return True, "ok"
    return False, "签名不匹配"


class State:
    def __init__(self, path):
        self.path, self.seen, self.final = path, set(), {}
        if os.path.exists(path):
            for line in open(path, encoding="utf-8"):
                e = json.loads(line)
                self.seen.add(e["webhook_id"])
                if e["status"] in TERMINAL:
                    self.final[e["prediction_id"]] = e["status"]

    def handle(self, wid, pred):
        pid, st = pred["id"], pred["status"]
        if wid in self.seen:
            return f"重复投递 {wid},跳过"
        self.seen.add(wid)
        if pid in self.final:
            return f"{pid} 已是终态 {self.final[pid]},忽略迟到的 {st}"
        rec = {"webhook_id": wid, "prediction_id": pid, "status": st, "output": pred.get("output"), "error": pred.get("error")}
        with open(self.path, "a", encoding="utf-8") as f:
            f.write(json.dumps(rec, ensure_ascii=False) + "\n")
        if st in TERMINAL:
            self.final[pid] = st
        if st == "succeeded":
            return f"{pid} 成功,输出 {pred.get('output')}(1 小时内下载保存)"
        if st == "failed":
            return f"{pid} 失败:{pred.get('error')}"
        return f"{pid} 状态 {st}"


def make_handler(secret, state, log=print):
    class H(BaseHTTPRequestHandler):
        def do_POST(self):
            body = self.rfile.read(int(self.headers.get("Content-Length", 0)))
            ok, why = verify(secret, {k.lower(): v for k, v in self.headers.items()}, body)
            if not ok:
                log(f"拒绝:{why}"); return self._send(401)
            log(state.handle(self.headers["webhook-id"], json.loads(body)))
            self._send(200)      # 尽快回 2xx,否则终态 webhook 会被重试

        def _send(self, code):
            self.send_response(code); self.end_headers()

        def log_message(self, *a):
            pass
    return H


if __name__ == "__main__":
    secret = os.environ["REPLICATE_WEBHOOK_SECRET"]
    port = int(os.environ.get("PORT", "8090"))
    print(f"监听 127.0.0.1:{port}", flush=True)
    HTTPServer(("127.0.0.1", port), make_handler(secret, State("predictions.jsonl"), log=lambda s: print(s, flush=True))).serve_forever()

第 2 步:模拟投递

"""本地模拟 Replicate 投递(用我自己生成的测试密钥签名,不是 Replicate 的)。
用法:python send_test_webhook.py <URL> <payload.json> [webhook-id]"""
import base64, hashlib, hmac, os, sys, time, urllib.request, urllib.error

url, payload = sys.argv[1], sys.argv[2]
wid = sys.argv[3] if len(sys.argv) > 3 else f"msg_{int(time.time() * 1000)}"
secret = os.environ["REPLICATE_WEBHOOK_SECRET"]
key = base64.b64decode(secret.removeprefix("whsec_"))
body, ts = open(payload, "rb").read(), str(int(time.time()))
sig = base64.b64encode(hmac.new(key, f"{wid}.{ts}.".encode() + body, hashlib.sha256).digest()).decode()
req = urllib.request.Request(url, data=body, method="POST", headers={"Content-Type": "application/json",
      "webhook-id": wid, "webhook-timestamp": ts, "webhook-signature": f"v1,{sig}"})
try:
    print(urllib.request.urlopen(req).status)
except urllib.error.HTTPError as e:
    print(e.code)

第 3 步:示例事件

{"id": "ufawqhfynnddngldkgtslldrkq", "model": "black-forest-labs/flux-schnell", "version": "v", "status": "processing", "input": {"prompt": "水彩橘猫"}, "output": null, "error": null, "logs": "", "metrics": {}}
{"id": "ufawqhfynnddngldkgtslldrkq", "model": "black-forest-labs/flux-schnell", "version": "v", "status": "succeeded", "input": {"prompt": "水彩橘猫"}, "output": ["https://replicate.delivery/example/out-0.webp"], "error": null, "logs": "", "metrics": {"predict_time": 1.1}}

第 4 步:测试

import base64, hashlib, hmac, json
import replicate_webhook as w

# Standard Webhooks 官方测试数据(msg id / payload / secret 来自其 Python 库测试),
# 签名 v1,g0hM9SsE… 也出现在 Replicate 文档的 webhook-signature 示例里
VEC = {"webhook-id": "msg_p5jXN8AQM9LWM0D4loKWxJek", "webhook-timestamp": "1614265330",
       "webhook-signature": "v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE="}
BODY = b'{"test": 2432232314}'
SECRET = "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw"


def test_reference_vector():
    assert w.verify(SECRET, VEC, BODY, now=1614265330) == (True, "ok")


def test_old_timestamp_rejected_without_now():
    assert w.verify(SECRET, VEC, BODY) == (False, "时间戳超出容差")


def test_multiple_signatures_one_valid():
    h = dict(VEC, **{"webhook-signature": "v1,bm9ldHUjKzFob2VudXRob2VodWUzMjRvdWVvdW9ldQo= " + VEC["webhook-signature"]})
    assert w.verify(SECRET, h, BODY, now=1614265330)[0]


def test_tampered_body_and_missing_headers():
    assert w.verify(SECRET, VEC, BODY + b" ", now=1614265330) == (False, "签名不匹配")
    assert w.verify(SECRET, {}, BODY)[1] == "缺少 webhook-* 请求头"


def test_state_dedupe_and_ordering(tmp_path):
    s = w.State(str(tmp_path / "p.jsonl"))
    p = lambda st: {"id": "p1", "status": st, "output": ["https://replicate.delivery/x.webp"] if st == "succeeded" else None}
    assert "状态 processing" in s.handle("m1", p("processing"))
    assert "成功" in s.handle("m2", p("succeeded"))
    assert "重复投递" in s.handle("m2", p("succeeded"))
    assert "忽略迟到" in s.handle("m3", p("processing"))
    s2 = w.State(str(tmp_path / "p.jsonl"))           # 重启后仍记得
    assert "重复投递" in s2.handle("m1", p("processing")) and "忽略迟到" in s2.handle("m9", p("processing"))

第 5 步:运行

$ export REPLICATE_WEBHOOK_SECRET="whsec_$(openssl rand -base64 24)" PORT=8090   # 本地测试密钥
$ python replicate_webhook.py &
21:37 UTC+8
$ python send_test_webhook.py http://127.0.0.1:8090/webhooks/replicate samples/processing.json msg_001
200
$ python send_test_webhook.py http://127.0.0.1:8090/webhooks/replicate samples/succeeded.json msg_002
200
$ python send_test_webhook.py http://127.0.0.1:8090/webhooks/replicate samples/succeeded.json msg_002
200
$ python send_test_webhook.py http://127.0.0.1:8090/webhooks/replicate samples/processing.json msg_003
200
$ REPLICATE_WEBHOOK_SECRET=whsec_<另一个密钥> python send_test_webhook.py http://127.0.0.1:8090/webhooks/replicate samples/succeeded.json msg_004
401
# 服务端输出
监听 127.0.0.1:8090
ufawqhfynnddngldkgtslldrkq 状态 processing
ufawqhfynnddngldkgtslldrkq 成功,输出 ['https://replicate.delivery/example/out-0.webp'](1 小时内下载保存)
重复投递 msg_002,跳过
ufawqhfynnddngldkgtslldrkq 已是终态 succeeded,忽略迟到的 processing
拒绝:签名不匹配
$ wc -l predictions.jsonl
2 predictions.jsonl

# 真实密钥接口(无效 Token,21:38 UTC+8)
$ curl -s https://api.replicate.com/v1/webhooks/default/secret -H 'Authorization: Bearer r8_invalid_demo'
{"title":"Unauthenticated","detail":"You did not pass a valid authentication token","status":401}   ← HTTP 401
$ python -m pytest -q
5 passed

怎么检查结果?

  • 第 3 次投递(同一个 msg_002)只打印“重复投递”,jsonl 不会多一行。
  • 第 4 次是迟到的 processing,被忽略,不会把已成功的预测改回处理中。
  • 换一个密钥签名返回 401。
  • 成功事件里的输出链接 1 小时后失效:在处理函数里立即下载(可以复用 A 篇的 save_outputs),不要只存链接。

调试示例

1. 用官方测试数据验签,返回“时间戳超出容差”。 我不想只靠自己签、自己验(两边写错同一处也能通过),于是用了 Standard Webhooks 官方 Python 库测试里的数据:msg id msg_p5jXN8AQM9LWM0D4loKWxJek、payload {"test": 2432232314}、secret MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw,签名 v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=(Replicate 文档的示例 header 里第一个签名就是它),时间戳 1614265330。第一次调用:

>>> verify('whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw', h, b'{"test": 2432232314}')
(False, '时间戳超出容差')
>>> verify(..., now=1614265330)
(True, 'ok')

时间戳是 2021 年的,防重放检查生效了。这正说明两项检查是分开的:给 verify() 加上可注入的 now 参数后,签名部分通过,证明 HMAC 的拼接方式和 Base64 处理是对的。

2. 乱序和重复要分别处理。 官方说同一 webhook 可能重发(webhook-id 相同),也可能乱序到达。重发按 webhook-id 去重;乱序按预测 id 记住终态,终态之后来的任何事件都忽略。两者都要落盘,否则服务重启后会忘掉,测试 test_state_dedupe_and_ordering 覆盖了重启的情况。

常见问题

现象 原因 处理
全部 401 密钥不对,或 body 被框架改过 用原始字节验签;重新获取密钥
时间戳超出容差 服务器时钟不准 开启 NTP
同一事件处理两次 重试投递 按 webhook-id 去重
状态倒退 乱序到达 终态之后的事件忽略

模板规则

  • 用原始 body 验签。
  • 常量时间比较签名。
  • 按 webhook-id 去重,按预测 id 记终态。
  • 尽快返回 2xx,重活放到后台。

项目资源

用途 地址
Replicate 验证 webhook(官方) https://replicate.com/docs/topics/webhooks/verify-webhook
Replicate 接收 webhook(官方) https://replicate.com/docs/topics/webhooks/receive-webhook
Standard Webhooks https://github.com/standard-webhooks/standard-webhooks
Replicate HTTP API 参考(官方) https://replicate.com/docs/reference/http

验签先用官方测试数据过一遍,比自己签自己验更可靠。