Webhook 消费者规格:签名、重试与顺序
我评审过的绝大多数 webhook 消费端规格,覆盖的内容只有一个端点、几乎别无其他:URL、JSON 结构,再加一句含糊其辞的“校验签名”。那不是规格。消费端规格是一份接收方的契约,规定发送方在失败场景下可能做出什么行为,以及当网络出错时你的处理器必须保证什么。下面是我写这类规格的方式。
假设发送方会作恶、会抽风、偶尔还会重复自己
我默认的心智模型是:发送方是一个状态不佳的正常系统。它会重试一个我已经处理过的请求。它会因为两个 worker 乱序出队,把事件 42 送到事件 41 前面。而在公网的某个角落,一个无聊的攻击者正打算重放昨天那条 payment.succeeded 事件。
如果规格不强迫团队对每种情况作出决策,团队就会在某次财务对账与账本对不上之后、在一个慌乱的 hotfix 里意外地做出决策。我评审时只要规格没回答以下问题就会打回:谁是签名权威、签名有效期多长、收到重复事件 ID 怎么处理、事件乱序到达怎么处理。
签名校验是第一道闸门,不是一个打勾项
我会把签名校验写成一段明确的算法。对于 Stripe 风格的 webhook,规格是这样的:发送方将 timestamp + "." + raw_body 拼接,用当前签名密钥计算 HMAC-SHA256,放在 Stripe-Signature 请求头里。接收方必须在任何 JSON 解析之前读取原始 body,用每一个当前有效的密钥重新计算 HMAC,并使用常量时间比较。所有候选都不匹配就返回 400。
初级规格通常会跳过三件事:原始 body 要求(JSON 重新序列化会破坏签名)、常量时间比较(普通的 == 会泄露密钥比特)、密钥轮换。我的规则是:始终保持两个有效密钥,30 天轮换一次,重叠期间接收方 MUST 同时接受任一密钥。不这么做,轮换就等于一次计划内的故障。
五分钟法则:重放窗口要写进规格
光有一个有效签名并不代表这个请求是新鲜的。规格必须定义重放窗口。我的默认值是 5 分钟:如果 abs(now - signed_timestamp) > 300 seconds,即使 HMAC 匹配也要返回 400。窗口更短,发送方的时钟偏移就会让它崩;窗口更长,重放面就会扩大。如果业务要求在整个窗口内严格一次,就加一张按签名或 event_id 为键的 nonce 表,在保留期内对重复项进行拒绝。我通常会在 5 分钟时间戳窗口之上,再加一个 24 小时的 nonce 保留期。
幂等是处理器的属性,不是一种美好愿望
我写的每一份规格都包含一条不可妥协的话:处理器 MUST 对发送方的 event_id 幂等。发送方一定会重试;规格的任务就是让第二次投递变成空操作。
具体设计:一张 webhook_events 表,以 (source, event_id) 作为主键。处理器开启一个事务,插入事件行(冲突即已见过),执行业务副作用,然后提交。如果插入发生冲突,返回 200,副作用不再执行。
对于无法通过数据库回滚的副作用(扣款、发邮件),要加一层下游状态检查。比如在收到 charge.dispute.created 后要创建 Stripe 退款时,处理器会用基于 event_id 推导的幂等键去查询退款 API。查不到就创建;查到就记录日志并返回 200。
重试语义属于发送方,容忍成本属于你
规格必须把发送方的重试计划写下来。GitHub 以指数退避重试约 8 小时。Stripe 最多重试 3 天。对于内部总线,我的规格是:立即、30s、2m、10m、1h、6h、24h、死信。接收方必须容忍的内容从这个计划自然推导出来:如果重试跨度是 24 小时,去重表的行至少保留 30 天,且处理器要足够轻量,一次重复投递的成本不应高于原始投递。
状态码是与发送方重试循环之间的契约
这部分没人写下来,引发的故障却比签名 bug 多得多。我的规格里总有这张表:
- 200 / 204 — 已处理。不要重试。
- 200 且返回
{"ignored": true}— 已收到但未处理(未知事件类型、被过滤的客户、过期版本)。不要重试。 - 400 — 签名无效、时间戳超出窗口,或 body 格式错误。永久失败。不要重试。
- 422 — 语义层面拒绝(引用了一个已删除的资源)。永久失败。不要重试。
- 500 / 502 / 503 — 处理器炸了或下游不可用。按发送方的重试计划重试。
- 429 — 过载。退避后重试,若有
Retry-After则遵循。
我见过被滥用得最多的一条:签名失败时返回 500。这会把发送方钉在一个永远不可能成功的请求上反复重试。签名失败应该是 400。
顺序:默认无序,带上版本字段
除非发送方给出硬性保证,否则我会把每一个消费者都规定为与顺序无关——而几乎没有发送方会给出这种保证。缓解方式是在底层资源上加一个单调递增的版本号。当 subscription.updated 到达时,处理器把事件的 data.version(或 updated_at)与已持久化的版本相比。更旧就返回 200 并忽略;更新就应用;相等即重复。
没有这层检查,像“把套餐设为 pro”后“把套餐设为 free”这样的一对事件乱序到达,就会让一个付费客户被默默降级。版本门从结构上让这件事不可能发生。
用 Given/When/Then 写验收标准
- Given a POST with a valid HMAC-SHA256 signature and a timestamp within 300 seconds
When the event_id has not been seen before
Then the handler inserts (source, event_id) into webhook_events and returns 200
- Given a POST with a valid signature and a known event_id
When the handler runs
Then it performs no downstream writes and returns 200 within 50 ms
- Given a POST whose signed timestamp is more than 300 seconds old
When the handler runs
Then it returns 400 with body {"error": "timestamp_outside_window"}
- Given a subscription.updated event whose data.version is less than persisted
When the handler runs
Then it returns 200 with body {"ignored": "stale_version"} and makes no state change
- Given the downstream database is unreachable
When the handler runs
Then it returns 503 and the sender retries per its schedule
死信、日志与我唯一真正盯着的那个指标
接收方需要自己的死信队列,独立于发送方的死信。当处理器对同一个 event_id 连续返回三次 5xx,我就会把它搬到 webhook_dlq 表里,保存原始 body、请求头和最后一次错误。发送方最终会停止重试,而我不想在它停止时丢掉载荷。
每一行日志都带着 event_id、source 和 event_type。我放上仪表盘的唯一指标是从已签名事件时间戳开始测量的处理器完成延迟,不是从 HTTP 接收开始。这个指标一行就覆盖了发送方队列延迟、网络传输、重试延迟和我自己的处理时间。如果它在 p99 越过几分钟,说明上游在起火,我要在客户告诉我之前知道。
评审时看什么
这篇文章适合用在Webhook 消费者规格评审时。别从“原则”聊起,直接拿一条真实改动来对照,看看规格里还缺什么。
- 签名校验失败是否停止处理。
- 重复投递是否幂等。
- 事件乱序时状态如何收敛。
- 重试耗尽后进入哪里:死信、告警还是人工队列。
Webhook 规格要按最坏情况写。提供方不会保证按你的 happy path 投递。
Webhook 消费方先验签,再谈业务
Webhook spec 里要把处理顺序写死:读取原始 body、验签、检查时间戳、做 replay 保护、落入幂等表,然后才执行业务逻辑。顺序错了,安全问题会伪装成普通 bug。
Webhook processing order: 1. read raw body bytes 2. verify signature with active and previous secret 3. reject timestamp older than 5 minutes 4. insert event_id into idempotency table 5. enqueue business handler 6. return 2xx only after durable enqueue 7. record provider retry count and handler result
边界:不要在 HTTP 请求里做长业务处理。验签和持久化应该快,真正的业务逻辑交给队列,才能正确处理重试和超时。
密钥轮换也属于 webhook 规格
验签逻辑要支持 active secret 和 previous secret,并写清重叠窗口、轮换 owner、失败报警和回滚。很多 webhook 事故不是业务 handler 坏了,而是密钥换了但消费者只认旧值。
可复制产物:契约评审包
当工作涉及 API 行为、schema、事件、重试或消费者预期时使用。它会把兼容性和发布证据提前摊开。
API 契约评审包:Webhook 消费者规格:签名、重试与顺序 本次要做的决策: - 确认契约变化是否兼容,消费者需要什么迁移动作,发布后如何观察风险。 责任人检查: - 产品责任人: - 工程责任人: - QA 或运维评审: 范围边界: - 本次包含: - 本次不包含: - 仍需确认的假设: 验收证据: - 测试或 fixture: - 日志、指标或截图: - 人工复核步骤: 契约边界:没有兼容性分类、消费者影响、重试行为和回滚说明,不进入发布。 评审追问: - 没参加需求会的人还会误解哪里? - 哪个证据能证明这次改动足够安全,可以发布?
编辑复核记录
复核日期:2026-04-28。本次补充了可复用产物,按相关主题 Hub 检查了文章定位,并收紧下一步链接,让页面更像可操作参考,而不是孤立长文。
专题阅读路径
这篇文章归入 API 契约 主题。先读 Hub,再结合下面的清单、模板或工具落到具体项目里。
继续阅读
编辑说明与免责声明
最近复核:2026-04-28。编辑部检查了示例、内链和可复制评审片段,确保内容更适合真实项目使用。
本文用于软件工程教学与实践参考,不构成法律、税务或投资建议。示例场景用于解释规格方法,不对应真实客户数据。
- 作者信息:Spec Coding 编辑部
- 编辑政策:编辑与事实核查政策
- 联系方式:联系页面