Webhook 详解:签名载荷与重试机制
你刚刚集成了一个支付网关。文档说:“当支付成功时,我们会向你的端点发送一个 Webhook。”你设置了一个简单的路由,在本地测试后部署上线。然而在生产环境中,你发现了重复事件、丢失的通知以及神秘的 500 错误。Webhook 看似简单——直到它不再简单。
本指南将解释 Webhook 真正的工作原理、为什么签名载荷很重要,以及如何构建重试逻辑,即使在出错时也能保持系统同步。
什么是 Webhook?
Webhook 是一种 HTTP 回调:当特定事件发生时,一个系统会向你提供的 URL 发送 HTTP POST 请求。与轮询(你反复询问“有事情发生吗?”)不同,Webhook 会近乎实时地将事件推送给你。
常见用途包括:
- 支付处理商通知你扣款成功。
- Git 托管服务在推送时触发 CI/CD 流水线。
- 通信平台提醒你有新消息。
发送方(例如 Stripe、GitHub)是Webhook 提供者;你的服务器是Webhook 消费者。
为什么签名载荷至关重要
你的 Webhook 端点可公开访问。任何知道 URL 的人都可以发送伪造事件。如果没有验证,攻击者可以伪造“支付成功”事件,而你可能会免费发货。
签名载荷通过在请求头中包含一个加密签名来解决此问题,该签名使用只有你和提供者知道的共享密钥生成。当你收到 Webhook 时,重新计算签名并进行比较。如果匹配,则载荷是真实的。
HMAC 签名的工作原理
大多数提供者使用 HMAC-SHA256。提供者计算:
signature = HMAC-SHA256(secret, payload)并将其放在类似 X-Signature 的请求头中发送。你的服务器执行相同的计算并检查是否相等。由于密钥是保密的,只有提供者才能生成有效的签名。
实际验证签名
以下是使用 Express 的极简 Node.js 示例:
const crypto = require('crypto');
const express = require('express');
const app = express();
app.post('/webhook', express.raw({type: 'application/json'}), (req, res) => {
const signature = req.headers['x-signature'];
const secret = process.env.WEBHOOK_SECRET;
const payload = req.body; // raw Buffer
const expected = crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
if (signature !== expected) {
return res.status(401).send('Invalid signature');
}
// Process the event
const event = JSON.parse(payload);
console.log('Received event:', event.type);
res.status(200).send('OK');
});
app.listen(3000);重要提示: 始终使用原始请求体进行签名验证。如果你先解析 JSON,序列化后的字符串可能会不同(空白、键顺序),从而导致签名检查失败。
常见陷阱
- 使用错误的密钥: 一些提供者为测试模式和生产模式使用不同的密钥。
- 时序攻击: 使用恒定时间比较函数(例如
crypto.timingSafeEqual)以避免泄露信息。 - 忽略时间戳标头: 许多提供者包含时间戳以防止重放攻击。拒绝超过几分钟的请求。
重试:优雅地处理故障
网络会故障,服务器会重启,部署会发生。Webhook 提供者预期你的端点可能暂时不可用,因此它们会重试失败的投递。但重试会带来新的挑战:重复事件、乱序投递,以及如果不小心可能出现的无限循环。
提供者如何重试
大多数提供者使用指数退避:在第一次失败后等待一小段时间,然后逐渐延长间隔。例如,Stripe 最多重试 3 天,延迟递增。GitHub 最多重试 8 次。具体时间表各不相同,但模式是一致的。
你的端点应快速返回 2xx 状态码以确认接收。任何其他状态(4xx、5xx)都表示失败并触发重试。
构建幂等处理器
由于重试可能多次投递同一事件,你的处理器必须是幂等的:处理同一事件两次应与处理一次具有相同的效果。
常见策略:
- 事件 ID 跟踪: 将唯一事件 ID(在载荷或标头中提供)存储在数据库中。在处理之前,检查是否已处理过。
- Upserts: 使用天然幂等的数据库操作(例如
INSERT ... ON CONFLICT DO NOTHING)。 - 状态机: 设计你的业务逻辑以容忍重复转换(例如,仅在未支付时将订单标记为已支付)。
使用简单内存集合的示例(仅用于说明;生产环境请使用持久化存储):
const processedEvents = new Set();
app.post('/webhook', (req, res) => {
const event = req.body;
if (processedEvents.has(event.id)) {
return res.status(200).send('Already processed');
}
// Process event...
processedEvents.add(event.id);
res.status(200).send('OK');
});快速响应
提供者通常有超时限制(例如 5 秒)。如果你的处理器执行繁重处理,请立即确认并异步处理。例如,将事件推送到队列并返回 200。
可靠 Webhook 的最佳实践
- 始终验证签名,使用原始请求体和恒定时间比较。
- 使用 HTTPS 保护你的端点,防止窃听和篡改。
- 实现幂等性 以处理重复投递。
- 记录一切 —— 传入载荷、签名和你的响应 —— 以便调试。
- 监控故障 并为重复错误设置警报。
- 使用真实事件测试,通过提供者仪表板或 CLI 工具。
- 处理乱序事件,在逻辑中包含时间戳或序列号。
比较:轮询 vs. Webhook
| 方面 | 轮询 | Webhook |
|---|---|---|
| 延迟 | 取决于间隔 | 近乎实时 |
| 服务器负载 | 高(持续请求) | 低(仅事件发生时) |
| 复杂性 | 实现简单 | 需要端点、安全、重试 |
| 可靠性 | 你控制重试 | 提供者控制重试 |
常见问题
什么是 Webhook 签名?
Webhook 签名是载荷的加密哈希(通常是 HMAC-SHA256),使用共享密钥生成。它允许接收方验证请求来自预期的发送方且未被篡改。
如何处理重复的 Webhook 事件?
通过跟踪事件 ID 或使用天然幂等的数据库操作使处理器幂等。在应用更改之前检查事件是否已处理。
我的 Webhook 端点应返回什么?
在接收并验证事件后,尽快返回 2xx 状态码(例如 200 OK)。如果你需要进行繁重处理,请立即确认并异步处理工作。
准备好测试你的 Webhook 载荷了吗?使用我们的 JSON 格式化工具 来检查和验证传入事件的 JSON 结构。