Webhook 详解:签名负载与可靠重试

Backend2026-10-23TryQuickToolBox

你上线了一个支付集成,服务商触发 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 的头部中发送。你用自己那份密钥重新计算相同的摘要并进行比较。

有两个细节比人们预想的更重要:

一个最小的验证示例(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>。在你这边:

  1. 从头部解析时间戳。
  2. 如果请求的时间戳早于容忍窗口(通常是 5 分钟),则拒绝该请求。
  3. 对 timestamp + "." + rawBody 重新计算 HMAC。

这将泄露请求的损害限制在几分钟内,而不是永久。

设计真正能送达的重试

网络会故障,你的服务会重启,部署会滚动更新 Pod。一个认真的服务商会按计划重试失败的投递。一个认真的接收方必须假设每个事件都可能到达多次。

发送方重试规则

接收方规则

  1. 先验证,后解析。 在接触数据库之前,用 401 拒绝无效签名。
  2. 持久化事件,然后返回 200。 将原始负载和事件 ID 写入队列或表,立即响应,并在工作线程中处理。如果处理失败,在内部重试。
  3. 使处理程序幂等。 使用服务商的事件 ID 作为唯一键。如果已存在,则返回 200 而不重新执行副作用。
  4. 如实返回错误。 如果你的数据库宕机,返回 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,如何验证 webhook 签名?

在任何 JSON 解析之前,将原始请求体作为字节读取,对这些字节计算 HMAC,并使用恒定时间函数与头部进行比较。重新序列化的 JSON 将产生不同的摘要。

Webhook 应该重试多少次?

没有通用数字,但在几个小时内使用指数退避和抖动进行 5–10 次尝试是常见的。重要的是接收方是幂等的,因此即使重试频繁也是安全的。

我的 webhook 端点应返回什么状态码?

只有在持久存储事件后才返回 2xx。对于无效签名返回 401,对于格式错误的负载返回 400,当你希望发送方重试时返回 5xx 或 429。

如果你正在测试 webhook 负载或记录收到的 JSON,JSON 格式化器 是一种快速美化打印和验证捕获事件的方法,然后再围绕它构建解析逻辑。