Webhook 详解:签名负载与可靠重试
你上线了一个支付集成,服务商触发 webhook,你的端点返回 200——但三小时后客服报告订单丢失。或者更糟:同一个事件到达四次,客户被重复扣款。Webhook 看起来简单(向你的 URL 发送一个 HTTP POST),但难点在于信任和投递保证。
本指南涵盖两方面:如何使用签名负载验证负载确实来自发送方,以及如何构建一个能经受停机、超时和重复投递的重试模型。
Webhook 到底是什么
Webhook 是一种反向 API 调用。与其让你的服务器每分钟轮询 GET /events,不如让服务商在事件发生时向你注册的 URL 推送一个 HTTP 请求。负载通常是 JSON,方法几乎总是 POST,而你返回的响应是一个信号:2xx 表示“已接收”,其他任何状态码都表示“稍后重试”。
最后一句话就是整个契约。其他一切——签名、重试、顺序——都是为了在不可靠的网络上让这个契约安全可靠。
为什么未签名的 Webhook 很危险
你的 webhook 端点是一个公开的 URL。任何发现它的人都可以 POST 一个伪造的负载,声称订单已支付。如果你不经验证就信任请求体,你就等于为计费逻辑敞开了一扇门。
IP 白名单有一点帮助,但在 CDN、无服务器出口和提供商 IP 变更的情况下会失效。稳健的解决方案是共享密钥加上对原始请求体的 HMAC 签名。
签名负载的工作原理
服务商和你共享一个密钥。每次投递时,服务商对原始请求体(有时是时间戳加上请求体)计算一个 HMAC(通常是 SHA-256),并将十六进制摘要放在诸如 X-Signature 的头部中发送。你用自己那份密钥重新计算相同的摘要并进行比较。
有两个细节比人们预想的更重要:
- 对原始字节签名。 如果你的框架解析 JSON 并重新序列化,键的顺序和空白会改变,摘要将不匹配。首先读取原始请求体。
- 使用恒定时间比较。 简单的
==会泄露时间信息。使用语言加密库中的恒定时间比较函数。
一个最小的验证示例(Node.js)
const crypto = require('crypto');
function verifySignature(rawBody, headerSig, secret) {
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody) // 原始 Buffer 或字符串,不是解析后的 JSON
.digest('hex');
const a = Buffer.from(expected, 'utf8');
const b = Buffer.from(headerSig || '', 'utf8');
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}
同样的模式在 Python 中对应 hmac.compare_digest,在 Go 中对应 hmac.Equal,在 Java 中对应 MessageDigest.isEqual。原则处处相同:对原始字节做 HMAC,恒定时间比较。
使用时间戳防止重放
有效的签名证明负载来自持有密钥的人——但捕获的请求可以被无限重放。关心这一点的服务商会在签名字符串中包含时间戳,例如 t=1712345678,v1=<hex>。在你这边:
- 从头部解析时间戳。
- 如果请求的时间戳早于容忍窗口(通常是 5 分钟),则拒绝该请求。
- 对
timestamp + "." + rawBody重新计算 HMAC。
这将泄露请求的损害限制在几分钟内,而不是永久。
设计真正能送达的重试
网络会故障,你的服务会重启,部署会滚动更新 Pod。一个认真的服务商会按计划重试失败的投递。一个认真的接收方必须假设每个事件都可能到达多次。
发送方重试规则
- 将任何非 2xx 响应、连接错误或超时视为失败。
- 使用指数退避和抖动进行重试——例如 1 秒、5 秒、30 秒、5 分钟、30 分钟、2 小时——这样当服务商恢复时不会产生惊群效应。
- 限制尝试次数(通常是 5–10 次),并记录最终失败,以便用户或运维人员看到。
- 设置较短的客户端超时(5–10 秒)。Webhook 处理程序应快速确认并异步执行工作。
接收方规则
- 先验证,后解析。 在接触数据库之前,用 401 拒绝无效签名。
- 持久化事件,然后返回 200。 将原始负载和事件 ID 写入队列或表,立即响应,并在工作线程中处理。如果处理失败,在内部重试。
- 使处理程序幂等。 使用服务商的事件 ID 作为唯一键。如果已存在,则返回 200 而不重新执行副作用。
- 如实返回错误。 如果你的数据库宕机,返回 500 以便服务商重试——不要返回 200 并静默丢弃事件。
| 响应 | 对发送方的含义 | 接下来会发生什么 |
|---|---|---|
| 200 / 204 | 已投递 | 不重试 |
| 400 | 格式错误或被拒绝 | 通常不重试;记录以便调试 |
| 401 | 签名无效 | 不重试;调查密钥 |
| 429 | 限流 | 退避后重试 |
| 5xx / 超时 | 临时故障 | 退避后重试 |
实践中的幂等性
在这里,幂等性不是流行词——它是重试和重复扣款之间的唯一屏障。模式如下:
INSERT INTO webhook_events (event_id, payload, received_at)
VALUES ($1, $2, now())
ON CONFLICT (event_id) DO NOTHING;
如果插入影响零行,说明你已经见过此事件;确认并继续。这在并发投递下也能工作,因为唯一约束会在数据库层面将它们串行化。
对于必须恰好执行一次的副作用(发送邮件、扣款),向下游传递你自己的幂等键——大多数支付和邮件 API 都接受一个。
顺序、调试和可观测性
不要假设事件按顺序到达。重试可能打乱投递顺序,并行工作线程也会如此。如果顺序很重要,在负载中包含序列号或时间戳,并协调状态,而不是盲目应用增量。
记录每次投递尝试的事件 ID、签名结果、响应码和处理时间。当客户说“我的订单从未到达”时,你希望能在几秒钟内回答:webhook 是否收到,签名是否有效,处理是否成功?每个事件一行结构化日志就足够了。
对于接收大量流量的端点,注意部署后的重试风暴。发现它们的一个快速方法是查看访问日志中的请求模式——来自一个 IP 的相同路径伴随 5xx 响应的激增是经典的重试循环。像 Nginx 日志分析器 这样的工具可以让这种模式变得显而易见,而无需手动编写查询。
要避免的常见错误
- 在验证签名之前解析 JSON。
- 跨环境使用相同的密钥,或者从不轮换密钥。
- 在请求处理程序中执行繁重的工作(生成 PDF、调用第三方)。
- 在处理错误时返回 200,导致服务商从不重试。
- 在重要时,不重新从服务商 API 获取资源就信任负载中的金额或状态。
常见问题
如果请求体是 JSON,如何验证 webhook 签名?
在任何 JSON 解析之前,将原始请求体作为字节读取,对这些字节计算 HMAC,并使用恒定时间函数与头部进行比较。重新序列化的 JSON 将产生不同的摘要。
Webhook 应该重试多少次?
没有通用数字,但在几个小时内使用指数退避和抖动进行 5–10 次尝试是常见的。重要的是接收方是幂等的,因此即使重试频繁也是安全的。
我的 webhook 端点应返回什么状态码?
只有在持久存储事件后才返回 2xx。对于无效签名返回 401,对于格式错误的负载返回 400,当你希望发送方重试时返回 5xx 或 429。
如果你正在测试 webhook 负载或记录收到的 JSON,JSON 格式化器 是一种快速美化打印和验证捕获事件的方法,然后再围绕它构建解析逻辑。