Webhook 订阅正确性:端点验证、密钥轮换与投递证明
一个 2xx 只能证明端点对 HTTP 请求给出成功响应,不能证明订阅者完成业务处理。本文用订阅代际、签名密钥状态机、不可变投递证据和可选处理回执控制重试与轮换。
本文要点
- 主题聚焦:TypeScript、DevOps
- 适合人群:独立开发者、全栈工程师、AI 应用开发者
- 核心价值:快速理解背景、实践路径、常见误区和工程建议
Webhook 控制面经常把三个不同事实压成一个绿色勾:某 URL 曾通过验证、一次 HTTP 请求返回 2xx、订阅者已经应用事件。它们没有等价关系。端点所有权会变化,DNS 会重绑定,密钥会轮换;反向代理返回 204 时,内部队列甚至可能尚未接收消息。事件驱动架构解释生产者与消费者解耦,本篇只解决跨组织 HTTP 订阅的身份、签名与证据边界,不承诺网络上的 exactly-once。
订阅是有代际的资源
注册请求先生成不可复用的 subscriptionId 和单调 generation,绑定租户、规范化 HTTPS endpoint、topic/filter schema、事件版本集合、交付区域、签名算法、重试策略及到期时间。状态为 PENDING_CHALLENGE -> ACTIVE -> ROTATING -> SUSPENDED -> REVOKED | EXPIRED。修改 URL、topic、接收事件主版本或所有者都创建新 generation,旧 worker 不能重读当前配置后把旧投递送到新端点。
激活前向 endpoint 发送短期一次性 challenge,回包必须绑定 subscription、generation、endpoint 摘要和期限;仅能证明当时控制该响应路径,不能成为永久授权。challenge 与内容投递默认都不跟随 3xx,Location 视为失败;endpoint 改变必须创建新 subscription generation 并重新挑战。每次直连仍要解析并钉住允许的公网地址,限制端口、禁止 HTTPS 降级和私网目标,防止 SSRF。证书、DNS 或组织归属改变时暂停,而不是默默跟随。
订阅状态与 delivery outbox 在一个事务边界内读取:只有 ACTIVE/ROTATING 且 generation 匹配才能创建 attempt。撤销 CAS 提高 generation 并关闭新 attempt;已登记的旧代投递按政策排空到旧 endpoint 或隔离,绝不能转送新 endpoint。后台任务队列处理调度与退避,订阅记录才是投递资格事实源。
轮换必须覆盖发送和验证两端
每个签名凭证都有随机 keyId、keyGeneration、NOT_BEFORE/VERIFY_ONLY/ACTIVE/REVOKED 状态和最大消息寿命。正常轮换先发布新公钥或安全交付新共享秘密,让订阅者用 challenge 证明可验证新代;随后发送端切新 key,旧 key 只保留 VERIFY_ONLY 到“最大队列延迟 + 最大重试窗口 + 时钟偏差”结束。泄漏处置则立即撤销旧 key、暂停未迁移订阅,并通过独立认证通道恢复,不能为了送达率无限双签。
采用 RFC 9421 HTTP Message Signatures 时,版本化 signature profile 明确覆盖方法、目标 URI、Content-Digest、事件 ID、subscription generation、签发时间和到期时间。序列化后的原始 body 字节不可在签名后被代理改写;签名不覆盖 endpoint 或 generation 会允许跨订阅重放。验签日志只保存 key ID、摘要和 reason code,不记录共享秘密。
把“发送”拆成可证明的层级
业务事实使用稳定 eventId 与不可变 payload digest;每个订阅得到稳定 deliveryId,网络重试只递增 attempt。账本状态可写成:
PLANNED -> DISPATCHING -> ENDPOINT_ACCEPTED
\-> RETRYABLE | UNKNOWN_TRANSPORT | DEAD_LETTER
ENDPOINT_ACCEPTED -> APPLICATION_CONFIRMED (仅在有回执协议时)
发送前保存 canonical bytes、目标 generation、key generation 和 attempt;响应丢失时是 UNKNOWN_TRANSPORT,查询不到远端状态就用相同 delivery 身份重试。HTTP 2xx 只能进入 ENDPOINT_ACCEPTED。若产品需要证明业务应用,订阅者必须另发签名 applicationReceipt.v1,绑定 event ID、payload digest、subscriber operation ID、应用结果和回执 key generation;重复回执按 (subscriptionId,eventId) 去重,不同摘要进入冲突隔离。没有这种双方合同就把该层标为 UNOBSERVABLE,不能从 2xx 推断成功。
事件可能重复、乱序和并发到达,消费者仍要按 event ID 幂等,并按聚合版本决定能否应用。分布式追踪可关联 attempt 与消费 span,但 trace 采样缺失时不能否定投递证据;发布说明分发归因进一步区分触达与采用,本篇不做营销归因。
破坏轮换窗口再验收
故障测试覆盖 challenge 重放、DNS 在验证后指向私网、307 改目标、旧 worker 延迟投递、轮换中一半实例仍用旧 key、签名后代理重编码 JSON、响应已发但订阅者入队失败、2xx 响应丢失、同 event 不同 payload、回执伪造和撤销竞争。断言 3xx 投递为失败、旧 generation 不会送往新 endpoint、被撤 key 接受数为 0;仅对启用 application receipt 的订阅断言重试不产生第二个已应用效果,其他订阅保持 UNOBSERVABLE。
上线门要求挑战误激活为 0、100 万次签名变异漏检为 0、轮换窗口 endpoint acceptance 不低于基线 99.95%、UNKNOWN_TRANSPORT 在 15 分钟内对账率不低于 99%,死信都有 owner。subscriber 去重率 100% 只适用于通过 application-receipt conformance 的订阅;其余明确报告 UNOBSERVABLE,只衡量 delivery 重试与 endpoint acceptance。先 shadow,再让内部订阅双验签并分桶切换。异常时只退回仍安全的 VERIFY_ONLY 代;旧 key 泄漏时宁可暂停。
延伸阅读
Related