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.createdinvoice.createdinvoice.paid 等多个事件,处理程序不能依赖顺序,必要时用 API 补查缺失的对象。

二、签名校验:安全第一

未做签名校验的 Webhook 端点是重灾区——攻击者可以伪造事件触发"发货、授权、改记录"等操作。Stripe 与 GitHub 都采用 HMAC 签名

  1. 平台用端点密钥对 时间戳 + "." + 原始请求体 计算 HMAC-SHA256;
  2. 请求头携带签名(如 Stripe-Signature: t=...v1=...);
  3. 你的服务端用同一密钥重算并常量时间比较,防时序攻击;
  4. 校验时间戳与当前时间的差值(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