Webhook 集成与可靠性:签名校验、重试与幂等
Webhook 让你"订阅"系统中发生的事件,事件发生时平台自动把数据推送到你的服务器,而不是反复轮询 API。webhooks.fyi 调查了 100 多家 Webhook 提供商后指出:Webhook 在概念上通用,但大多是"非标准化"的接口契约,安全控制和运维体验参差不齐。本文结合 webhooks.fyi 的最佳实践、Stripe 与 GitHub 的官方文档,梳理一套可靠的 Webhook 集成方案。签名校验的详细代码可参考 Webhook 签名校验实现。
举个真实的教训:某团队接入支付回调时只校验了事件类型、没有校验签名,结果攻击者伪造了一串 payment_intent.succeeded 事件,系统把未支付的订单全部标记为已支付并触发了发货,损失发生后才发现端点是“裸奔”的。这类事故完全可以靠下面的三步预防:验签、幂等、快速响应。
一、事件模型:先理解推送规则
- Webhook 是事件驱动的:你只需在创建时订阅一次,之后只在事件发生时收到数据(GitHub 用"push 触发 CI、PR 评论通知、部署上线"等场景举例)。
- 每个事件通常包含事件 ID、事件类型(type)和事件对象(data.object),例如 Stripe 的
payment_intent.succeeded。 - 事件不一定按顺序到达:Stripe 官方明确"不保证按生成顺序发送"。比如创建订阅会产生
customer.subscription.created、invoice.created、invoice.paid等多个事件,处理程序不能依赖顺序,必要时用 API 补查缺失的对象。
二、签名校验:安全第一
未做签名校验的 Webhook 端点是重灾区——攻击者可以伪造事件触发"发货、授权、改记录"等操作。Stripe 与 GitHub 都采用 HMAC 签名:
- 平台用端点密钥对
时间戳 + "." + 原始请求体计算 HMAC-SHA256; - 请求头携带签名(如
Stripe-Signature: t=...v1=...); - 你的服务端用同一密钥重算并常量时间比较,防时序攻击;
- 校验时间戳与当前时间的差值(Stripe 默认 5 分钟容差),防重放攻击。
务必用官方库验证签名,且验证时必须使用原始请求体(框架改写 body 会导致验证失败)。
以 Node.js 为例,用原生 crypto 演示验签与时间戳防重放检查:
const crypto = require('crypto');
function verifySignature(payload, signatureHeader, secret) {
const [tsPart, sigPart] = signatureHeader.split(',');
const timestamp = tsPart.split('=')[1];
const expected = sigPart.split('=')[1];
const signed = `${timestamp}.${payload}`;
const actual = crypto
.createHmac('sha256', secret)
.update(signed)
.digest('hex');
// 常量时间比较 + 时间窗检查(防重放)
const ok = crypto.timingSafeEqual(
Buffer.from(actual), Buffer.from(expected)
) && (Date.now() / 1000 - Number(timestamp)) < 300;
return ok;
}
实际项目请优先使用平台官方 SDK(如 stripe.webhooks.constructEvent),上面的代码用于理解原理。
三、自动重试与幂等
- 自动重试:Stripe 会在约 3 天内以递减频率重试失败的投递;GitHub 也提供有限次重试。返回
2xx即视为成功,4xx/5xx、超时、TLS 错误都会触发重试。 - 幂等去重:同一个事件可能被多次投递。官方最佳实践是记录已处理的事件 ID,重复事件直接跳过,不要重复执行业务逻辑(如重复记账)。
- 快速返回 2xx:Stripe 明确要求"在执行可能超时的复杂逻辑之前,先快速返回 2xx",把耗时的业务处理放到后台队列,避免阻塞导致重试风暴。异步处理可参考 后台任务与消息队列指南。
Stripe 的默认重试节奏大致是:首次失败后约 1 分钟重试,之后间隔逐步拉长到几小时,整体窗口约 3 天。一个务实的幂等做法是把“已处理事件 ID”存在数据库唯一索引里:
CREATE TABLE processed_events (
event_id VARCHAR(64) PRIMARY KEY,
processed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
处理事件前先 INSERT ... ON CONFLICT DO NOTHING,插入影响行数为 0 即表示已经处理过,直接返回 2xx 跳过。
还有一类容易被忽略的情况:重试投递的时间点可能在业务状态已经变化之后。例如用户先取消了订单,平台随后重试发送“支付成功”事件,处理程序如果无条件按事件更新状态,就会把已取消的订单又改回已支付。稳妥的做法是把事件 ID 与业务状态一并记录,处理前先比对当前状态,避免旧事件覆盖新状态。
四、安全加固清单
- HTTPS 必配:生产环境端点必须是 HTTPS,Stripe 要求 TLS v1.2+。
- IP 白名单:从平台固定的 IP 段接收,配合防火墙过滤。
- 只订阅需要的事件:不要监听全部事件,减少无效流量和攻击面。
- 定期轮换密钥:怀疑泄露时立即轮换,支持新旧密钥并行过渡。
- CSRF 豁免:Django/Rails 等框架默认校验 CSRF token,需要从校验中豁免 Webhook 路由(因为签名验证本身已提供鉴权)。
五、场景化建议
- 支付回调:务必结合 Stripe 支付集成指南,把
checkout.session.completed等事件与本地订单状态对齐。 - CI/CD 触发:GitHub Webhook 驱动流水线,见 GitHub Actions CI/CD 指南。
- 自建 Webhook 平台:为调用方提供签名、重试、投递日志,参考 webhooks.fyi 的 provider 最佳实践。投递日志尤其重要——它记录每次请求的 URL、请求体、响应码与耗时,出问题时能让双方快速定位是“没发出去”还是“对方处理失败”。
16IDC 观察
Webhook 是"反向 API":你的服务器从消费者变成被调用方,可靠性责任也随之转移。签名校验、幂等去重、快速响应这三件事做好了,支付、CI、通知等集成就稳了大半。更多后端工程实践见 后端对接 分类。
原文来源:https://webhooks.fyi/
参考:webhooks.fyi 最佳实践 https://webhooks.fyi/guide/best-practices/;Stripe Webhook 文档 https://docs.stripe.com/webhooks;GitHub Webhook 事件 https://docs.github.com/en/webhooks