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

Backend2026-10-22TryQuickToolBox

你已经构建了一个Webhook端点来接收来自第三方服务的事件。在测试中一切正常,但在生产环境中你注意到了重复事件、遗漏通知以及偶尔对伪造请求的安全担忧。这些是在没有适当保护措施的情况下实现Webhook时的常见陷阱。

Webhook是HTTP回调,允许服务在发生某些事情时通知你的应用程序。与轮询不同,它们提供实时更新。但要可靠地使用它们,你需要解决两个关键方面:真实性(确保请求来自预期的发送方)和交付(处理失败和重试)。本文通过实际代码示例解释这两点。

Webhook如何工作

Webhook是用户定义的HTTP POST端点。当事件发生时,源服务向该URL发送一个请求,其中包含描述事件的负载。例如,支付处理器可能在扣款成功时发送Webhook。

流程很简单:

  1. 你在服务中注册一个Webhook URL。
  2. 当事件触发时,服务向该URL发送HTTP POST请求。
  3. 你的端点处理负载并返回2xx状态以确认收到。

如果没有适当的验证,任何人都可以向你的端点发送请求。如果没有重试处理,暂时的故障可能导致事件丢失。让我们逐一解决。

使用签名负载保护Webhook安全

为了确保Webhook源自预期的发送方,服务使用共享密钥对负载进行签名。签名通常是请求体的HMAC(基于哈希的消息认证码),通过类似X-Signature的头部发送。

HMAC签名的工作原理

发送方使用密钥和原始请求体计算HMAC,然后将十六进制编码的签名包含在头部中。你的端点使用相同的密钥重新计算HMAC,并将其与接收到的签名进行比较。如果匹配,则请求是真实的。

以下是使用Express的Node.js最小示例:

const crypto = require('crypto');
const express = require('express');
const app = express();

// 使用原始请求体进行签名验证
app.use(express.raw({ type: 'application/json' }));

app.post('/webhook', (req, res) => {
  const signature = req.headers['x-signature'];
  const secret = process.env.WEBHOOK_SECRET;
  const payload = req.body;

  const expected = crypto
    .createHmac('sha256', secret)
    .update(payload)
    .digest('hex');

  if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
    return res.status(401).send('Invalid signature');
  }

  // 处理事件
  const event = JSON.parse(payload);
  // ... 处理事件
  res.status(200).send('OK');
});

关键点:

额外的安全措施

实现可靠的重试

Webhook交付不是保证的。网络故障、你的服务器可能宕机,或者你可能返回非2xx状态。为了处理这种情况,发送方通常使用指数退避进行重试。作为接收方,你必须设计端点具有幂等性并快速响应。

在发送方(如果你发送Webhook)

如果你正在构建发送Webhook的服务,请实现带退避的重试。常见策略:

Python中的重试逻辑示例:

import time
import requests
from requests.exceptions import RequestException

def send_webhook(url, payload, max_retries=5):
    for attempt in range(max_retries):
        try:
            response = requests.post(url, json=payload, timeout=5)
            if 200 <= response.status_code < 300:
                return True
            elif 400 <= response.status_code < 500 and response.status_code != 429:
                # 客户端错误,不重试
                return False
        except RequestException:
            pass
        # 带抖动的指数退避
        delay = (2 ** attempt) + random.uniform(0, 1)
        time.sleep(delay)
    return False

在接收方

你的端点应该:

使用数据库唯一约束的幂等示例:

CREATE TABLE processed_events (
  event_id VARCHAR(255) PRIMARY KEY,
  processed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

在处理之前,尝试插入事件ID。如果违反主键约束,则跳过处理。

本地测试Webhook

在开发过程中,你需要一个公共URL以便服务能够访问你的本地服务器。ngrok或localtunnel等工具可以暴露你的localhost。始终在开发中使用测试密钥并验证签名,以便尽早发现问题。

比较:轮询 vs Webhook

方面 轮询 Webhook
延迟 取决于间隔 近实时
服务器负载 高(频繁请求) 低(仅事件发生时)
复杂性 实现简单 需要签名验证和重试处理
可靠性 你控制重试 取决于发送方的重试策略

常见问题

什么是签名的Webhook负载?

签名负载包含使用共享密钥生成的加密签名(通常是HMAC)。它允许接收方验证请求来自预期的发送方且未被篡改。

如何处理重复的Webhook交付?

通过使用负载中的唯一事件ID使端点具有幂等性。存储已处理的ID并忽略重复。这确保重试或网络问题不会导致重复处理。

我应该重试失败的Webhook交付吗?

是的,但仅针对暂时性错误(网络问题、5xx)。使用带抖动的指数退避并设置最大重试限制。避免在4xx错误(除429外)时重试,因为它们表示请求本身有问题。

在调试Webhook负载时,你通常需要检查JSON数据。JSON格式化工具可以帮助你快速美化打印和验证JSON负载。