Webhook 详解:签名负载与重试机制

Backend2026-10-29TryQuickToolBox

你刚刚集成了一个第三方支付提供商。文档上写着:“支付成功时我们会发送一个 webhook。”听起来很简单,对吧?但随后你意识到:你怎么知道请求真的来自他们?如果他们发送时你的服务器宕机了怎么办?如果他们重复发送同一个事件呢?

Webhook 很强大,但需要谨慎处理。本指南涵盖基本要点:对负载进行签名以验证真实性、实现重试以处理失败,以及确保幂等性以避免重复处理。

什么是 Webhook?

Webhook 是一种 HTTP 回调:当事件发生时,提供商向你指定的 URL 发送 HTTP POST 请求,而不是让你的应用轮询 API 来获取变更。这使得集成高效且近乎实时。

常见的例子包括支付通知(Stripe、PayPal)、CI/CD 触发器(GitHub、GitLab)和消息平台(Slack、Discord)。

为什么用签名保护 Webhook?

你的 webhook 端点可公开访问。任何人都可以向它发送 POST 请求。如果不进行验证,攻击者可以伪造事件——例如,将未支付的订单标记为已支付。

使用共享密钥对负载进行签名,可以让你验证请求确实来自预期的发送方,并且负载未被篡改。

HMAC 签名的工作原理

提供商和你共享一个密钥。对于每个 webhook,提供商使用该密钥计算负载的 HMAC(基于哈希的消息认证码),并将签名包含在请求头中(例如 X-Signature)。你在自己这边重新计算 HMAC 并进行比较。

HMAC 确保完整性和真实性,因为只有拥有密钥的人才能生成有效的签名。

实现签名负载

让我们通过 Node.js(Express)和 Python(Flask)中的典型实现来逐步了解。这些原则适用于任何语言。

步骤 1:获取并存储密钥

注册 webhook 时,提供商会给你一个密钥。请安全存储——使用环境变量或密钥管理器——切勿存储在代码中。

步骤 2:捕获原始请求体

签名验证需要精确的原始请求体字节。如果你的框架在验证之前解析了 JSON,重新序列化可能会改变字节并破坏验证。

在 Express 中,为 webhook 路由使用 express.raw({ type: 'application/json' })。在 Flask 中,在解析之前访问 request.get_data()。

步骤 3:计算并比较签名

使用恒定时间比较函数以防止时序攻击。

// Node.js (Express)
const crypto = require('crypto');

app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = req.headers['x-signature'];
  const secret = process.env.WEBHOOK_SECRET;
  const expected = crypto.createHmac('sha256', secret).update(req.body).digest('hex');
  
  if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
    return res.status(401).send('Invalid signature');
  }
  
  // Process event
  const event = JSON.parse(req.body);
  // ... handle event
  res.status(200).send('OK');
});

在 Python 中,使用 hmac.compare_digest:

# Python (Flask)
import hmac, hashlib, os
from flask import request, abort

@app.route('/webhook', methods=['POST'])
def webhook():
    signature = request.headers.get('X-Signature')
    secret = os.environ['WEBHOOK_SECRET'].encode()
    expected = hmac.new(secret, request.get_data(), hashlib.sha256).hexdigest()
    if not hmac.compare_digest(signature, expected):
        abort(401)
    event = request.get_json()
    # ... handle event
    return 'OK', 200

步骤 4:处理时间戳(可选但推荐)

一些提供商在签名中包含时间戳以防止重放攻击。例如,Stripe 的方案对 timestamp.payload 进行签名。你应该拒绝时间戳过于久远(例如超过 5 分钟)的请求。

实现重试

网络会失败。你的服务器可能宕机,或者请求可能超时。可靠的 webhook 系统会重试失败的传递。

在提供商侧

如果你正在构建 webhook 发送方,请实现带指数退避的重试。常见模式:1 分钟后重试,然后 5 分钟,然后 30 分钟,以此类推,直到达到最大尝试次数(例如 5 次)。在负载中包含唯一的事件 ID,以便接收方可以去重。

在接收方侧

作为接收方,你应该:

幂等性:处理重复事件

重试可能导致重复事件。使用事件 ID(大多数提供商都会包含)来跟踪已处理的事件。在处理之前,检查该 ID 是否已经出现过;如果是,则跳过。

将已处理的 ID 存储在具有 TTL 的数据库中,或在业务逻辑中使用幂等键。

在本地测试 Webhook

在开发过程中,你的本地服务器无法公开访问。ngrok 或 localtunnel 等工具会创建一个公共 URL,将请求隧道传输到你的机器。或者,使用捕获请求并允许你重放的 webhook 测试服务。

常见陷阱和最佳实践

比较:签名与未签名 Webhook

方面签名未签名
安全性高:验证来源和完整性低:任何人都可以伪造
实现工作量中等:计算和比较 HMAC最小
推荐用于所有生产系统仅限内部可信网络

常见问题

如何验证 webhook 签名?

使用共享密钥计算原始请求体的 HMAC,然后使用恒定时间函数将其与签名头进行比较。始终使用原始请求体,而不是解析后的 JSON。

我的 webhook 端点应该返回什么?

快速返回 2xx 状态码以确认收到。如果处理失败,返回 5xx 以触发重试,但确保你的端点具有幂等性以处理重复。

Webhook 会被重试多少次?

这取决于提供商。大多数会在数小时或数天内使用指数退避重试多次。请查看提供商的文档以了解具体信息。

在调试 webhook 传递时,通常需要检查服务器日志。如果你使用的是 Nginx,Nginx 日志分析器可以帮助你解析和分析访问日志,以发现失败的请求或异常模式。