更新于

OpenAI Webhook 签名校验与重放防护


只要你开始用 OpenAI 的异步能力,比如 Background Mode、Responses 完成回调或其他事件通知,就迟早要接 Webhook。很多团队第一版都能“收到事件”,但上线后很快踩两个坑:要么签名校验总失败,要么明明验签通过了,结果同一个事件被重复处理多次,最终把数据库、消息队列或下游任务搞乱。

这两个问题其实属于不同层次。签名校验解决的是“这条请求是不是 OpenAI 发来的”;重放防护解决的是“即便这条请求是真的,它会不会被重复送达或被恶意重放”。前者 OpenAI 官方 SDK 已经给了现成能力,后者还需要你自己补业务侧幂等和时间窗控制。

一、先把责任边界分清:验签不等于幂等

OpenAI 官方当前文档明确建议对 webhook 做签名校验,并给出了 Python 和 TypeScript SDK 的现成方法。文档同时说明它采用 Standard Webhooks 约定,因此请求头里会带 webhook-idwebhook-timestampwebhook-signature 这类字段。

这意味着你至少要做两件事:

层次解决什么问题由谁负责
签名校验判断请求是否真的来自 OpenAIOpenAI SDK + 你的服务端
重放防护防止同一事件被重复消费你的业务系统

很多人把这两件事混在一起,最后会出现一种危险错觉:SDK 已经“verify”了,所以系统一定安全。实际上 SDK 校验的是真实性,不是消费语义

二、最常见的验签失败原因:你把原始请求体弄丢了

OpenAI 官方示例里一个很容易被忽略的细节是:验签必须使用原始请求体字符串,不能先被框架解析成 JSON 再反序列化回去。因为签名覆盖的是原始字节序列,哪怕只是空格、换行或字段顺序发生变化,验签都可能失败。

官方 Python SDK 的推荐写法是直接 unwrap

from flask import request
from openai import OpenAI

client = OpenAI()

body = request.get_data(as_text=True)
event = client.webhooks.unwrap(
    body,
    request.headers,
)

如果你只想验证签名、自己再做 JSON 解析,也可以用 verify_signature()。TypeScript 侧文档同样强调:校验是异步的,而且要把原始 text() 结果传进去。真正的坑通常出在中间件顺序上:

  • Express 先跑了 express.json()
  • Next.js Route Handler 先 await req.json()
  • Flask / FastAPI 被别的中间件改写了 body

只要原始 body 不再可用,后面再怎么调 SDK 都是白费。

三、正确的处理顺序应该是“先验签,再解析,再入队”

Webhook 入口最稳的处理顺序通常是:

  1. 读取原始请求体
  2. 交给 OpenAI SDK 验签并解包事件
  3. 基于 event.type 做最小分流
  4. 把事件写入幂等存储或任务队列
  5. 尽快返回 2xx

之所以强调“尽快返回”,是因为 OpenAI 官方文档当前说明:如果你的服务没有在几秒内回 2xx,会被视为投递失败,并且会继续重试,最长可达 72 小时的指数退避窗口。也就是说,慢处理本身就会制造重复事件。因此 webhook handler 最好只做轻量校验和排队,不要在 HTTP 请求里直接跑长耗时业务。

四、重放防护的第一层:按事件 ID 做幂等去重

只要 webhook 采用“至少一次投递”,重复就是正常现象,不是异常。网络抖动、你的服务超时、下游队列临时不可用,都会让同一个事件再次送达。

因此最基础的重放防护不是“盼着不重发”,而是把事件消费设计成幂等。最常见做法是用 event.idwebhook-id 建唯一约束:

create table processed_webhooks (
  webhook_id text primary key,
  event_type text not null,
  received_at timestamptz not null default now()
);

处理流程也很简单:

  • 先尝试插入 webhook_id
  • 插入成功,说明是首次收到,继续处理
  • 唯一键冲突,说明已经处理过,直接返回 200

这一步不是“可选优化”,而是 webhook 系统的基本功。尤其是你把 OpenAI webhook 用来更新异步任务状态时,如果没有幂等去重,很容易把一条 response.completed 事件处理两遍,重复发通知或重复写结果。

五、重放防护的第二层:时间窗校验和快速失效

即使签名为真、事件 ID 也没见过,仍然可能存在“旧请求被恶意重放”的风险。OpenAI 既然采用 Standard Webhooks 头字段,webhook-timestamp 本身就给了你做时间窗校验的依据。

比较实用的策略通常是:

控制点建议
时间窗只接受和当前时间相差不大的请求,比如 5 分钟内
时钟同步服务器保持 NTP 同步,否则可能误杀合法请求
秘钥轮换用 OpenAI 提供的签名 secret,轮换时支持双 secret 过渡
失败审计记录验签失败、超时失败、重复事件的数量和来源 IP

这里要注意一个现实问题:时间窗太短会增加误判,太长又削弱重放防护能力。多数场景下,几分钟级窗口是比较稳妥的折中。这个窗口值是工程策略,不是 OpenAI 帮你自动决定的。

六、业务侧真正要防的是“重复副作用”

安全文章很容易把重点全放在签名算法上,但对业务来说,真正昂贵的往往是重复副作用:重复发券、重复落库、重复启动后台任务、重复发送 Slack/邮件通知。换句话说,重放防护最后要落到状态机设计,而不是只停留在 HTTP 层。

如果你的 webhook 最终会驱动一条异步任务,推荐把状态更新写成“只允许单向推进”的形式,例如:

  • queued -> in_progress -> completed
  • 已经 completed 的任务,重复收到 completed 时直接忽略
  • 已经 failed 的任务,除非人工重置,不允许旧事件覆盖

这和 OpenAI Background Mode 异步任务设计 里提到的“webhook 只是状态同步入口,response 对象才是真相源”是一致的。Webhook 系统天然会重复、乱序或延迟,你的业务状态机必须能承受这些特性。

七、结论:先信任 SDK,再补上自己的幂等与状态机

OpenAI 官方已经把 webhook 验签这件事做得相当直接:SDK 提供了 unwrap()verify_signature(),文档也明确要求使用原始请求体,并说明了投递失败后的重试行为。对大多数团队来说,真正难的部分并不是“签名算法怎么写”,而是“如何让重复事件不会变成重复副作用”。

一个稳妥的最小方案通常是:入口层保留 raw body、先验签再解析、快速 ACK;消费层按 webhook_id 去重;业务层用单向状态机避免旧事件覆盖新状态。把这三层补齐后,Webhook 才算真正可用于生产,而不是“本地 demo 能跑通”。

八、相关阅读

如果你希望把 OpenAI 异步任务、回调处理和其他模型接口统一接到同一层做日志、队列和状态管理,YoTradeApi 可以作为统一入口,减少多家 API 协议并存时的接入复杂度。