Webhook 详解:签名载荷与可靠重试
你的集成在预发布环境中运行正常:支付提供商触发事件,你的端点接收它,订单状态变为 paid。然后在生产环境中,客户支付了两次,一次部署期间投递无声消失,还有人重放捕获的请求将订单标记为已退款。所有这三个故障都有相同的根本原因:Webhook 被当作简单的 HTTP POST 处理,而不是安全关键、至少一次投递的通道。
本指南涵盖了正确实现 Webhook 的两个方面——验证载荷的真实性,以及让投递在重试中存活——并附有可适配任何技术栈的代码。
Webhook 究竟是什么
Webhook 是一个系统在事件发生时向另一个系统发送的服务器到服务器 HTTP 请求。它颠倒了通常的轮询模型:不是你的应用每分钟询问“有什么新内容吗?”,而是生产者将事件推送到你注册的 URL。
这种设计带来两个特性,两者都很重要:
- 你的端点是公开的。互联网上的任何人都可以 POST 到它,因此每个请求都必须经过认证。
- 投递是至少一次的。网络会故障,服务器会重启,超时是模糊的——发送方无法知道你的处理程序是否运行。所以它会重试,你可能会多次看到同一个事件。
下面的一切都是这两个事实的结果。
使用 HMAC 签名载荷
标准方法是共享密钥加上对原始请求体的 HMAC。发送方计算签名并将其放入头部;你重新计算并比较。如果匹配,则载荷来自知道密钥的人,并且在传输过程中未被修改。
三个细节将可用的实现与损坏的实现区分开来:
- 对原始字节签名。仅在验证后解析 JSON。重新序列化请求体会改变空白和键顺序,签名将永远不匹配。
- 使用恒定时间比较。简单的
==会泄露时间信息。使用语言内置的安全比较。 - 在签名字符串中包含时间戳。签名
timestamp + "." + body可以让你拒绝旧请求,从而阻止重放攻击。
在 Node.js 中验证签名
const crypto = require('crypto');
function verifyWebhook(rawBody, headers, secret, toleranceSeconds = 300) {
const signature = headers['x-signature']; // e.g. "t=1712345678,v1=abc123..."
const timestamp = headers['x-timestamp'];
if (!signature || !timestamp) return false;
// 1. Reject stale requests (replay protection)
const age = Math.floor(Date.now() / 1000) - Number(timestamp);
if (!Number.isFinite(age) || age > toleranceSeconds) return false;
// 2. Recompute over the exact bytes received
const signedPayload = `${timestamp}.${rawBody}`;
const expected = crypto
.createHmac('sha256', secret)
.update(signedPayload, 'utf8')
.digest('hex');
// 3. Constant-time compare
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(signature, 'hex');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
在 Express 中,这需要在 webhook 路由上使用 express.raw({ type: 'application/json' }),以便请求体以 Buffer 形式到达。如果全局 express.json() 中间件先运行,原始请求体就没了,验证将失败——这是一个非常常见的 bug。
在 Python 中验证
import hmac, hashlib, time
def verify(raw_body: bytes, timestamp: str, signature: str, secret: bytes, tolerance: int = 300) -> bool:
if abs(time.time() - int(timestamp)) > tolerance:
return False
signed = timestamp.encode() + b"." + raw_body
expected = hmac.new(secret, signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
验证失败时始终返回 401 并记录请求 ID——但绝不记录密钥或完整签名。
设计真正有效的重试
只有当你的端点行为可预测时,重试才有用。契约很简单:一旦你持久地接受了事件,就快速返回 2xx,让其他一切都被重试。
| 你的响应 | 发送方行为 | 应该怎么做 |
|---|---|---|
200 / 204 | 标记为已投递 | 正确——在入队后确认 |
400 / 401 | 通常不重试 | 修复签名或载荷处理 |
429 / 503 | 退避重试 | 在过载时返回,而不是通用的 500 |
500 / 超时 | 重试 | 将慢速工作移至队列 |
两条规则保持健康:
- 快速确认。验证、将事件持久化到队列或表,响应
200。异步执行实际工作。在响应前调用三个 API 的处理程序将超时并触发重复投递。 - 使用带抖动的指数退避。如果发送方立即重试,短暂的中断会变成惊群。像 10秒、30秒、2分钟、10分钟、1小时这样带有随机抖动的调度可以分散负载。
使处理程序幂等
因为投递是至少一次的,同一个事件 ID 可能到达两次——一次是在你的处理程序实际成功后的超时之后。存储已处理的事件 ID 并跳过重复:
INSERT INTO processed_events (event_id, received_at)
VALUES ($1, now())
ON CONFLICT (event_id) DO NOTHING;
-- 如果插入 0 行,则为重复:返回 200 并停止。
尽可能使业务操作本身幂等——将订单状态设置为 paid 而不是递增计数器,并在调用下游服务时使用事件 ID 作为幂等键。
上线前的检查清单
- 使用恒定时间比较验证原始字节上的 HMAC。
- 拒绝超过几分钟的时间戳以阻止重放。
- 持久化事件,然后在一两秒内返回
2xx。 - 在做任何工作之前按事件 ID 去重。
- 记录事件 ID、类型和结果——绝不记录密钥。
- 提供从仪表板或管理工具手动重放事件的方法。
- 使用故意损坏的签名、重复投递和慢速处理程序进行测试。
当你在调试失败的集成时,原始请求体是你最需要关注的东西。将其粘贴到 JSON 格式化工具 中可以使嵌套的事件对象可读,而不会改变你已经捕获的字节。
常见问题
为什么载荷看起来相同但签名检查失败?
几乎总是因为请求体在哈希之前被解析并重新序列化。JSON 键顺序、空白和 unicode 转义在解析/字符串化过程中会改变,因此字节不再与发送方签名的内容匹配。捕获原始请求体并对其进行哈希。
我应该在处理事件之前还是之后返回 200?
在持久化存储之后,执行繁重工作之前。如果你先处理,慢速的下游调用会导致超时,发送方重试,你会得到重复。将事件排队,立即响应,并异步处理。
HTTPS 足以保护 webhook 端点吗?
不。TLS 保护传输中的数据,但不证明谁发送了请求——任何人都可以 POST 到公共 URL。使用共享密钥的 HMAC 签名认证发送方,签名时间戳防止捕获请求的重放。
总结
将 Webhook 视为任何不可信输入:验证原始字节上的签名,检查时间戳,按事件 ID 去重,并且仅在事件安全存储后确认。这样做,重试就不再是重复收费的来源,而是成为它们本应成为的东西——一个安全网。