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

Backend2026-11-05TryQuickToolBox

你的集成在预发布环境中运行正常:支付提供商触发事件,你的端点接收它,订单状态变为 paid。然后在生产环境中,客户支付了两次,一次部署期间投递无声消失,还有人重放捕获的请求将订单标记为已退款。所有这三个故障都有相同的根本原因:Webhook 被当作简单的 HTTP POST 处理,而不是安全关键、至少一次投递的通道。

本指南涵盖了正确实现 Webhook 的两个方面——验证载荷的真实性,以及让投递在重试中存活——并附有可适配任何技术栈的代码。

Webhook 究竟是什么

Webhook 是一个系统在事件发生时向另一个系统发送的服务器到服务器 HTTP 请求。它颠倒了通常的轮询模型:不是你的应用每分钟询问“有什么新内容吗?”,而是生产者将事件推送到你注册的 URL。

这种设计带来两个特性,两者都很重要:

下面的一切都是这两个事实的结果。

使用 HMAC 签名载荷

标准方法是共享密钥加上对原始请求体的 HMAC。发送方计算签名并将其放入头部;你重新计算并比较。如果匹配,则载荷来自知道密钥的人,并且在传输过程中未被修改。

三个细节将可用的实现与损坏的实现区分开来:

  1. 对原始字节签名。仅在验证后解析 JSON。重新序列化请求体会改变空白和键顺序,签名将永远不匹配。
  2. 使用恒定时间比较。简单的 == 会泄露时间信息。使用语言内置的安全比较。
  3. 在签名字符串中包含时间戳。签名 timestamp + "." + body 可以让你拒绝旧请求,从而阻止重放攻击。

在 Node.js 中验证签名

const crypto = require('crypto');

function verifyWebhook(rawBody, headers, secret, toleranceSeconds = 300) {
  const signature = headers['x-signature'];   // e.g. "t=1712345678,v1=abc123..."
  const timestamp = headers['x-timestamp'];
  if (!signature || !timestamp) return false;

  // 1. Reject stale requests (replay protection)
  const age = Math.floor(Date.now() / 1000) - Number(timestamp);
  if (!Number.isFinite(age) || age > toleranceSeconds) return false;

  // 2. Recompute over the exact bytes received
  const signedPayload = `${timestamp}.${rawBody}`;
  const expected = crypto
    .createHmac('sha256', secret)
    .update(signedPayload, 'utf8')
    .digest('hex');

  // 3. Constant-time compare
  const a = Buffer.from(expected, 'hex');
  const b = Buffer.from(signature, 'hex');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

在 Express 中,这需要在 webhook 路由上使用 express.raw({ type: 'application/json' }),以便请求体以 Buffer 形式到达。如果全局 express.json() 中间件先运行,原始请求体就没了,验证将失败——这是一个非常常见的 bug。

在 Python 中验证

import hmac, hashlib, time

def verify(raw_body: bytes, timestamp: str, signature: str, secret: bytes, tolerance: int = 300) -> bool:
    if abs(time.time() - int(timestamp)) > tolerance:
        return False
    signed = timestamp.encode() + b"." + raw_body
    expected = hmac.new(secret, signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)

验证失败时始终返回 401 并记录请求 ID——但绝不记录密钥或完整签名。

设计真正有效的重试

只有当你的端点行为可预测时,重试才有用。契约很简单:一旦你持久地接受了事件,就快速返回 2xx,让其他一切都被重试。

你的响应发送方行为应该怎么做
200 / 204标记为已投递正确——在入队后确认
400 / 401通常不重试修复签名或载荷处理
429 / 503退避重试在过载时返回,而不是通用的 500
500 / 超时重试将慢速工作移至队列

两条规则保持健康:

使处理程序幂等

因为投递是至少一次的,同一个事件 ID 可能到达两次——一次是在你的处理程序实际成功后的超时之后。存储已处理的事件 ID 并跳过重复:

INSERT INTO processed_events (event_id, received_at)
VALUES ($1, now())
ON CONFLICT (event_id) DO NOTHING;
-- 如果插入 0 行,则为重复:返回 200 并停止。

尽可能使业务操作本身幂等——将订单状态设置为 paid 而不是递增计数器,并在调用下游服务时使用事件 ID 作为幂等键。

上线前的检查清单

  1. 使用恒定时间比较验证原始字节上的 HMAC。
  2. 拒绝超过几分钟的时间戳以阻止重放。
  3. 持久化事件,然后在一两秒内返回 2xx。
  4. 在做任何工作之前按事件 ID 去重。
  5. 记录事件 ID、类型和结果——绝不记录密钥。
  6. 提供从仪表板或管理工具手动重放事件的方法。
  7. 使用故意损坏的签名、重复投递和慢速处理程序进行测试。

当你在调试失败的集成时,原始请求体是你最需要关注的东西。将其粘贴到 JSON 格式化工具 中可以使嵌套的事件对象可读,而不会改变你已经捕获的字节。

常见问题

为什么载荷看起来相同但签名检查失败?

几乎总是因为请求体在哈希之前被解析并重新序列化。JSON 键顺序、空白和 unicode 转义在解析/字符串化过程中会改变,因此字节不再与发送方签名的内容匹配。捕获原始请求体并对其进行哈希。

我应该在处理事件之前还是之后返回 200?

在持久化存储之后,执行繁重工作之前。如果你先处理,慢速的下游调用会导致超时,发送方重试,你会得到重复。将事件排队,立即响应,并异步处理。

HTTPS 足以保护 webhook 端点吗?

不。TLS 保护传输中的数据,但不证明谁发送了请求——任何人都可以 POST 到公共 URL。使用共享密钥的 HMAC 签名认证发送方,签名时间戳防止捕获请求的重放。

总结

将 Webhook 视为任何不可信输入:验证原始字节上的签名,检查时间戳,按事件 ID 去重,并且仅在事件安全存储后确认。这样做,重试就不再是重复收费的来源,而是成为它们本应成为的东西——一个安全网。