Webhook 详解:签名负载与重试机制
你已经集成了支付网关或 CI/CD 服务,现在需要接收实时事件。Webhook 是标准解决方案,但它们带来两大挑战:验证请求确实来自预期的发送方,以及处理端点宕机或响应缓慢时的失败。本文通过实际代码示例,解释如何实现签名 Webhook 负载和健壮的重试逻辑。
什么是 Webhook?
Webhook 是一种用户定义的 HTTP 回调。当事件发生时,提供方会向您指定的 URL 发送 HTTP POST 请求,而不是由您的应用程序轮询 API 获取更新。这使得集成高效且近乎实时。
然而,Webhook 是基于推送的,这意味着您的端点必须可公开访问、能够处理意外负载,并应对重复或恶意请求。这就是签名和重试的用武之地。
为什么要对 Webhook 负载签名?
如果没有验证,任何知道您 Webhook URL 的人都可以发送虚假事件。使用共享密钥对负载签名,可以验证发送方并确保负载未被篡改。
最常见的方法是使用 SHA-256 的 HMAC(基于哈希的消息认证码)。提供方使用密钥对原始请求体计算签名,并将其包含在头部(例如 X-Signature)中。您的服务器重新计算签名并进行比较。
实现 HMAC 验证
以下是使用 Express 在 Node.js 中的简单示例:
const crypto = require('crypto');
const express = require('express');
const app = express();
// Use raw body for signature verification
app.use(express.raw({ type: 'application/json' }));
app.post('/webhook', (req, res) => {
const signature = req.headers['x-signature'];
const secret = process.env.WEBHOOK_SECRET;
const payload = req.body; // Buffer
const expected = crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(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, () => console.log('Webhook server running'));
关键点:
- 使用原始请求体: 将请求体解析为原始字节,而不是 JSON,因为任何转换都会改变签名。
- 时间安全比较: 使用
crypto.timingSafeEqual防止时序攻击。 - 密钥管理: 安全地存储密钥(环境变量、密钥管理器)。
处理重试:让 Webhook 可靠
网络会故障,服务器会崩溃,部署会导致停机。可靠的 Webhook 系统必须重试失败的传递。但简单的重试可能导致重复处理并使系统过载。
重试策略
常见的重试模式包括:
- 指数退避: 每次重试之间等待更长时间(例如 1 秒、2 秒、4 秒、8 秒...)。
- 最大重试次数: 在 N 次尝试后停止(例如 5 次),并将传递标记为失败。
- 抖动: 添加随机性以避免惊群问题。
大多数提供方实现类似:在 24 小时内以指数退避重试最多 5 次。
幂等性:安全重试的关键
由于重试可能多次传递同一事件,您的端点必须是幂等的。在负载中包含唯一事件 ID,并存储已处理的 ID 以跳过重复。
使用简单内存集合的示例(生产环境中使用数据库):
const processedEvents = new Set();
app.post('/webhook', (req, res) => {
// ... signature verification ...
const event = JSON.parse(payload);
if (processedEvents.has(event.id)) {
return res.status(200).send('Already processed');
}
// Process event
processedEvents.add(event.id);
res.status(200).send('OK');
});
对于生产环境,使用在事件 ID 上具有唯一约束的数据库。
Webhook 消费者的最佳实践
- 快速响应: 在进行繁重处理之前,用 2xx 状态确认接收。将事件排队以进行异步处理。
- 验证时间戳: 如果提供方包含时间戳,拒绝超过几分钟的请求以防止重放攻击。
- 使用 HTTPS: 始终通过 HTTPS 暴露您的 Webhook 端点,以保护传输中的负载。
- 记录所有内容: 记录传入请求、签名和处理结果以进行调试。
- 监控失败: 为重复的传递失败设置警报。
重试策略比较
| 策略 | 优点 | 缺点 |
|---|---|---|
| 固定间隔 | 实现简单 | 可能使恢复中的系统过载 |
| 指数退避 | 在中断期间减少负载 | 如果发生瞬时故障,会延迟传递 |
| 带抖动的指数退避 | 防止同步重试 | 稍微复杂一些 |
常见问题
如何验证 Webhook 签名?
使用共享密钥计算原始请求体的 HMAC,并使用时间安全函数将其与签名头部进行比较。切勿使用简单的相等比较。
如果我的 Webhook 端点宕机了怎么办?
提供方应使用指数退避进行重试。确保您的端点快速返回 2xx 并异步处理事件,以最小化停机影响。
我可以不使用签名吗?
不建议。没有签名,您无法验证发送方,使您的端点容易受到虚假事件的影响。始终在可用时使用签名。
在测试 Webhook 负载时,您可能需要检查 JSON 数据。使用 JSON Formatter 快速美化打印和验证负载。