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

Backend2026-10-18TryQuickToolBox

你已经构建了一个 API,现在需要在事件发生时通知其他服务。你设置了一个 webhook 端点,但很快发现任何人都可以发送伪造的负载,而且当你的服务器宕机时,事件会永远丢失。这是一个常见的陷阱:webhook 概念简单,但为了安全和可靠性需要精心设计。

在本指南中,我们将介绍 webhook 的基本要素:如何对负载进行签名以防止篡改,如何在接收端验证签名,以及如何实现带指数退避的重试逻辑以处理失败。我们还会涵盖幂等性以避免重复处理。最终,你将拥有一个可以信赖的健壮 webhook 系统。

什么是 Webhook?

Webhook 是用户定义的 HTTP 回调。当源系统中发生事件(例如,支付处理完成)时,它会向你指定的 URL 发送一个 HTTP POST 请求,其中包含事件详情的负载。这允许服务之间进行实时通信,而无需轮询。

然而,webhook 引入了两个挑战:

使用签名负载保护 Webhook

为了防止伪造请求,发送方应使用共享密钥对负载进行签名。接收方随后可以验证签名以认证发送方。最常见的方法是使用 SHA-256 的 HMAC(基于哈希的消息认证码)。

HMAC 签名的工作原理

  1. 发送方和接收方共享一个密钥(理想情况下是一个长随机字符串)。
  2. 对于每个 webhook,发送方使用密钥计算请求体的 HMAC,并将其包含在头部(例如 X-Signature)中。
  3. 接收方对收到的请求体计算相同的 HMAC,并与头部值进行比较。如果匹配,则请求是真实的。

关键是要对原始请求体进行签名,而不是解析后的版本,以避免因 JSON 序列化差异而导致的不一致。

示例:在 Node.js 中签名负载

const crypto = require('crypto');

function signPayload(payload, secret) {
  return crypto.createHmac('sha256', secret).update(payload).digest('hex');
}

// 发送方:
const payload = JSON.stringify({ event: 'payment.success', amount: 100 });
const signature = signPayload(payload, 'your-secret-key');
// 将签名包含在头部:X-Signature: signature

在接收方验证签名

在接收端,你必须在处理负载之前验证签名。使用恒定时间比较以防止时序攻击。

const crypto = require('crypto');

function verifySignature(payload, signature, secret) {
  const expected = crypto.createHmac('sha256', secret).update(payload).digest('hex');
  return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}

// 在你的 webhook 端点中:
app.post('/webhook', (req, res) => {
  const signature = req.headers['x-signature'];
  const payload = req.rawBody; // 原始请求体作为字符串
  if (!verifySignature(payload, signature, 'your-secret-key')) {
    return res.status(401).send('Invalid signature');
  }
  // 处理事件
  res.status(200).send('OK');
});

注意:始终使用 HTTPS 以防止中间人攻击。此外,考虑在签名负载中包含时间戳以防止重放攻击。

使用指数退避实现重试

即使接收方可靠,网络问题或临时中断也可能导致 webhook 传递失败。健壮的发送方应重试失败的传递。然而,简单的重试可能会使接收方不堪重负或导致重复处理。解决方案:带抖动的指数退避和最大重试限制。

重试策略

示例:Node.js 中的重试逻辑

async function deliverWebhook(url, payload, secret, maxRetries = 5) {
  const signature = signPayload(payload, secret);
  let attempt = 0;
  while (attempt < maxRetries) {
    try {
      const response = await fetch(url, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', 'X-Signature': signature },
        body: payload,
      });
      if (response.ok) return;
      throw new Error(`HTTP ${response.status}`);
    } catch (error) {
      attempt++;
      if (attempt >= maxRetries) throw error;
      const delay = Math.min(1000 * 2 ** attempt + Math.random() * 1000, 30000);
      await new Promise(resolve => setTimeout(resolve, delay));
    }
  }
}

此函数最多重试 5 次,采用指数退避和抖动,将延迟上限设为 30 秒。根据你的需求调整参数。

确保幂等性

重试可能导致重复传递,因此你的 webhook 处理程序必须是幂等的。在负载中包含唯一的事件 ID,并让接收方跟踪已处理的 ID 以避免重复处理。

// 在接收方:
const processedEvents = new Set();

app.post('/webhook', (req, res) => {
  const { id } = req.body;
  if (processedEvents.has(id)) {
    return res.status(200).send('Already processed');
  }
  // 处理事件
  processedEvents.add(id);
  res.status(200).send('OK');
});

在生产环境中,使用持久化存储(例如 Redis 或数据库)来跟踪重启后的已处理 ID。

比较:签名与未签名的 Webhook

方面 未签名 签名(HMAC)
安全性 任何人都可以伪造请求 只有密钥持有者才能创建有效签名
完整性 负载可能被篡改 篡改会使签名无效
复杂性 低 中等(密钥管理、验证)
使用场景 内部、可信网络 任何公共 webhook

常见问题

什么是 webhook 签名?

Webhook 签名是使用共享密钥创建的负载的加密哈希(通常是 HMAC)。它允许接收方验证请求来自预期的发送方,并且负载未被更改。

我应该重试失败的 webhook 多少次?

没有固定的次数,但常见做法是使用指数退避重试 3 到 5 次。之后,将传递标记为失败并提醒你的团队。考虑事件的关键性:支付通知可能比非关键更新需要更多重试。

我可以不使用 HTTPS 使用 webhook 吗?

在生产环境中,你绝对不应该通过纯 HTTP 使用 webhook。HTTPS 在传输过程中加密负载,防止窃听和中间人攻击。即使有签名,HTTP 也会暴露敏感数据。

准备好测试你的 webhook 负载了吗?使用我们的 JSON 格式化工具 在发送或调试之前美化并验证 JSON 负载。