Webhook 详解:签名负载与重试逻辑
你刚刚集成了一个支付网关。文档说:“支付成功时我们会发送一个 webhook。”很好。但接着你会想:我怎么知道这个请求真的来自他们?如果我的服务器在他们发送时宕机了怎么办?如果他们重复发送同一个事件怎么办?
Webhook 功能强大,但也伴随着陷阱。本指南将带你了解正确处理它们所需的机制、安全性和可靠性模式。
什么是 Webhook?
Webhook 是一个系统在特定事件发生时向另一个系统发送的 HTTP POST 请求。无需你的应用每隔几秒轮询一次 API(“有新订单吗?有新订单吗?”),提供商会在事情发生时立即将数据推送给你。
典型流程:
- 你在提供商处注册一个 URL(例如
https://yourapp.com/webhooks/payments)。 - 事件发生(支付成功、用户注册、文件上传)。
- 提供商向你的 URL 发送一个 POST 请求,请求体为描述该事件的 JSON。
- 你的服务器处理该事件并返回
2xx以确认接收。
如果你没有快速响应(通常在 5–30 秒内),提供商会认为投递失败并可能重试。
为什么必须验证签名负载
你的 webhook 端点可公开访问。任何发现该 URL 的人都可以发送伪造事件。如果不进行验证,攻击者可以伪造一个“支付成功”事件来免费获取商品。
解决方案:签名负载。提供商使用共享密钥(只有你和他们知道)对请求体计算加密签名,并将其包含在请求头中。你在自己这边重新计算签名并进行比较。
HMAC 签名的工作原理
大多数提供商使用 HMAC-SHA256。步骤如下:
- 提供商将原始请求体与时间戳拼接(以防止重放攻击)。
- 提供商计算
HMAC-SHA256(secret, timestamp + '.' + body)。 - 提供商在请求头中发送签名,例如
X-Signature: t=1234567890,v1=abc123...。 - 你提取时间戳和签名,使用你持有的密钥副本重新计算 HMAC,并使用恒定时间比较函数进行比较。
以下是一个最简 Node.js 示例:
const crypto = require('crypto');
function verifySignature(req, secret) {
const signatureHeader = req.headers['x-signature'];
const [tPart, vPart] = signatureHeader.split(',');
const timestamp = tPart.split('=')[1];
const receivedSig = vPart.split('=')[1];
const payload = timestamp + '.' + JSON.stringify(req.body);
const expectedSig = crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
// 恒定时间比较以防止时序攻击
return crypto.timingSafeEqual(
Buffer.from(receivedSig, 'hex'),
Buffer.from(expectedSig, 'hex')
);
}
重要提示: 始终使用原始请求体进行签名验证。如果你的框架解析 JSON 并重新序列化它,字节序列可能会不同,导致验证失败。在解析之前捕获原始请求体。
时间戳验证
检查时间戳是最近的(例如,在 5 分钟内)。这可以防止攻击者捕获有效请求并稍后重发的重放攻击。
重试逻辑:优雅地处理失败
你的服务器可能宕机、过载或遇到网络故障。提供商通过重试失败的投递来处理这种情况。但你必须设计端点以安全地处理重试。
带抖动的指数退避
提供商通常以递增的延迟进行重试:1 分钟、5 分钟、30 分钟、2 小时等。这称为指数退避。有些会添加随机抖动以避免惊群问题。
从你的角度来看,你无法控制提供商的重试计划,但你应该:
- 在安全存储事件后立即返回
2xx(即使处理是异步的)。 - 对于永久性失败(例如格式错误的负载)返回
4xx,以便提供商停止重试。 - 对于暂时性失败(例如数据库宕机)返回
5xx以触发重试。
幂等性:安全重试的关键
重试意味着你可能多次收到同一个事件。你的处理必须是幂等的——处理同一个事件两次应该与处理一次具有相同的效果。
常见方法:在负载中包含唯一的事件 ID。在处理之前,检查你是否已经处理过该 ID。如果是,则返回 200 并跳过。
async function handleWebhook(event) {
const existing = await db.events.findOne({ id: event.id });
if (existing) {
return { status: 200, body: 'Already processed' };
}
await db.events.insert({ id: event.id, processedAt: new Date() });
await processEvent(event);
return { status: 200, body: 'OK' };
}
这种模式确保即使提供商发送事件三次,你也只执行一次操作。
比较:轮询 vs Webhook
| 方面 | 轮询 | Webhook |
|---|---|---|
| 延迟 | 取决于间隔(例如 30 秒) | 近乎实时 |
| 服务器负载 | 恒定,即使没有事件 | 仅在事件发生时 |
| 复杂性 | 实现简单 | 需要端点、验证、重试 |
| 可靠性 | 你控制重试 | 取决于提供商的重试策略 |
Webhook 在实时更新方面胜出,但如果你担心遗漏投递,轮询可以作为关键事件的回退方案。
最佳实践清单
- 始终验证签名。 永远不要信任未经验证的 webhook。
- 使用 HTTPS。 Webhook 负载通常包含敏感数据。
- 快速响应。 快速确认接收,然后根据需要异步处理。
- 实现幂等性。 存储事件 ID 以避免重复处理。
- 记录一切。 保留收到事件的记录以用于调试和审计。
- 监控失败。 为重复的 5xx 响应或签名验证失败设置警报。
- 使用真实负载测试。 使用提供商的测试模式或向你的端点发送示例事件。
调试 Webhook 问题
当 webhook 失败时,第一步是检查原始请求。常见问题:
- 签名不匹配: 你使用了已解析的请求体而不是原始请求体。通过捕获原始请求体来修复。
- 超时: 你的端点响应时间过长。将繁重的处理移至后台任务。
- 事件丢失: 检查提供商仪表板中的投递日志和重试历史。
如果你正在调试 HTTP 流量,像 Nginx 日志分析器 这样的工具可以帮助你发现请求失败和响应代码的模式。
常见问题
什么是 webhook 密钥,我该如何存储它?
密钥是用于签名负载的共享密钥。将其存储在环境变量或密钥管理器中——永远不要放在代码或版本控制中。如果提供商支持,请定期轮换它。
提供商会对失败的 webhook 重试多少次?
因提供商而异。有些使用指数退避重试 24 小时,有些则长达 3 天。请查看提供商的文档以了解确切的时间表和最大尝试次数。
我可以将 webhook 与无服务器函数一起使用吗?
可以,但要注意执行时间限制。快速确认 webhook(返回 200)并将繁重的处理转移到队列或后台函数以避免超时。
准备好构建健壮的 webhook 集成了吗?从验证签名和实现幂等性开始——这两个步骤将为你节省数小时的调试时间并防止代价高昂的安全事件。