Stripe 每天处理超过 10 亿次 Webhook 投递,GitHub 的 Webhook 系统支撑着全球数百万仓库的 CI/CD 流水线——Webhook(网络钩子)是现代分布式系统中最基础也最容易出错的集成模式。一个设计不良的 Webhook 系统会导致事件丢失、重复处理、安全漏洞,甚至拖垮你的整个后端服务。本文将从发送方和接收方两个视角,深入讲解 Webhook 的签名验证、可靠投递、幂等处理和错误恢复,并提供完整的 Node.js 生产级实现。
🔐 一、Webhook 安全:签名验证与防重放攻击
1.1 为什么裸 HTTP 回调是危险的
最简单的 Webhook 实现就是「事件发生时向注册的 URL 发一个 POST 请求」。但这种裸回调存在三个致命安全问题:
- ❌ 伪造请求:攻击者可以向你的回调 URL 发送伪造的事件数据,触发错误的业务逻辑
- ❌ 中间人攻击:没有签名验证,HTTP 请求在传输过程中可以被篡改
- ❌ 重放攻击:攻击者截获合法的 Webhook 请求后重新发送,导致重复处理
⚠️ **警告:**永远不要在没有签名验证的情况下处理 Webhook。即使你的回调 URL 是 HTTPS,也只能防止中间人攻击,无法防止伪造和重放。
1.2 HMAC-SHA256 签名验证实现
工业标准的做法是使用 HMAC-SHA256 对请求体进行签名。发送方和接收方共享一个密钥(Webhook Secret),发送方用密钥对请求体签名并放在 HTTP Header 中,接收方用同样的密钥验证签名。
发送方签名实现:
// webhook-sender.js — Webhook 发送方签名实现
import crypto from 'node:crypto'
// 发送 Webhook 事件
async function sendWebhook(targetUrl, payload, secret) {
const body = JSON.stringify(payload)
const timestamp = Math.floor(Date.now() / 1000).toString()
// 签名内容 = 时间戳 + "." + 请求体
// 加入时间戳是为了防重放攻击
const signaturePayload = `${timestamp}.${body}`
const signature = crypto
.createHmac('sha256', secret)
.update(signaturePayload)
.digest('hex')
const response = await fetch(targetUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
// Stripe 风格的签名头格式
'X-Webhook-Signature': `v1=${signature}`,
'X-Webhook-Timestamp': timestamp,
'X-Webhook-ID': crypto.randomUUID(),
},
body,
signal: AbortSignal.timeout(10000), // 10 秒超时
})
return { status: response.status, ok: response.ok }
}
// 测试
const payload = { event: 'order.created', data: { id: 'ord_123', amount: 9900 } }
const secret = 'whsec_your_webhook_secret_key_here'
await sendWebhook('https://api.example.com/webhooks/orders', payload, secret)
接收方验证实现:
// webhook-receiver.js — Webhook 接收方签名验证
import crypto from 'node:crypto'
function verifyWebhookSignature(req, secret) {
const signatureHeader = req.headers['x-webhook-signature']
const timestamp = req.headers['x-webhook-timestamp']
const webhookId = req.headers['x-webhook-id']
// 1. 检查必要的 Header 是否存在
if (!signatureHeader || !timestamp || !webhookId) {
return { valid: false, error: 'Missing required webhook headers' }
}
// 2. 防重放攻击:检查时间戳是否在允许的窗口内(5 分钟)
const currentTime = Math.floor(Date.now() / 1000)
const timestampAge = Math.abs(currentTime - parseInt(timestamp, 10))
if (timestampAge > 300) {
return { valid: false, error: `Timestamp too old: ${timestampAge}s (max 300s)` }
}
// 3. 计算期望的签名
const body = typeof req.body === 'string' ? req.body : JSON.stringify(req.body)
const signaturePayload = `${timestamp}.${body}`
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(signaturePayload)
.digest('hex')
// 4. 提取签名版本和值
const [version, providedSignature] = signatureHeader.split('=')
if (version !== 'v1' || !providedSignature) {
return { valid: false, error: 'Invalid signature format' }
}
// 5. 使用 timingSafeEqual 防止时序攻击
const expectedBuffer = Buffer.from(expectedSignature, 'hex')
const providedBuffer = Buffer.from(providedSignature, 'hex')
if (expectedBuffer.length !== providedBuffer.length ||
!crypto.timingSafeEqual(expectedBuffer, providedBuffer)) {
return { valid: false, error: 'Signature mismatch' }
}
return { valid: true, webhookId }
}
// Express 中间件集成示例
import express from 'express'
const app = express()
// ⚠️ 注意:需要先用 raw body 中间件保存原始请求体
app.post('/webhooks/orders', express.raw({ type: 'application/json' }), (req, res) => {
const result = verifyWebhookSignature(req, process.env.WEBHOOK_SECRET)
if (!result.valid) {
console.error('Webhook verification failed:', result.error)
return res.status(401).json({ error: result.error })
}
// 签名验证通过,处理事件
const event = JSON.parse(req.body)
handleOrderEvent(event)
res.status(200).json({ received: true })
})
📌 记住:
crypto.timingSafeEqual是防御时序攻击(Timing Attack)的关键。普通的字符串比较(===)在发现第一个不匹配字符时就会返回,攻击者可以通过测量响应时间逐字节推断出正确的签名。
1.3 签名方案对比
| 方案 | 安全性 | 实现复杂度 | 代表产品 | 推荐度 |
|---|---|---|---|---|
| HMAC-SHA256 + 时间戳 | ✅ 高 | 低 | Stripe, GitHub, Shopify | ✅ 推荐 |
| RSA 非对称签名 | ✅ 最高 | 中 | 银行 API, 政务系统 | ⚠️ 复杂场景 |
| 简单 API Key 对比 | ❌ 低 | 极低 | 早期 SaaS | ❌ 不推荐 |
| mTLS 双向证书 | ✅ 最高 | 高 | 企业内网 | ⚠️ 内部系统 |
💡 **提示:**大多数公开 API(Stripe、GitHub、Shopify)都使用 HMAC-SHA256 + 时间戳方案。只有在双方无法安全共享密钥的场景(如跨组织集成)才需要考虑 RSA 非对称签名。
🚀 二、可靠投递:重试机制与死信队列
2.1 Webhook 投递失败的常见原因
生产环境中,Webhook 投递失败率通常在 2%-5%。常见原因包括:
- 🔴 接收方服务器宕机或超时
- 🔴 网络抖动导致连接中断
- 🔴 接收方返回 5xx 错误
- 🔴 接收方 DNS 解析失败
- 🔴 TLS 证书过期
一个健壮的 Webhook 系统必须实现自动重试 + 指数退避 + 死信队列。
2.2 指数退避重试引擎
// webhook-retry-engine.js — 指数退避重试引擎
import { EventEmitter } from 'node:events'
class WebhookDeliveryEngine extends EventEmitter {
constructor(options = {}) {
super()
this.maxRetries = options.maxRetries ?? 5
this.baseDelay = options.baseDelay ?? 1000 // 初始延迟 1 秒
this.maxDelay = options.maxDelay ?? 60000 // 最大延迟 60 秒
this.jitterFactor = options.jitterFactor ?? 0.3 // 抖动因子 30%
this.deadLetterStore = new Map() // 死信存储
this.deliveryAttempts = new Map() // 投递记录
}
// 计算带抖动的指数退避延迟
calculateDelay(attempt) {
// 指数退避:1s, 2s, 4s, 8s, 16s, ...
const exponentialDelay = this.baseDelay * Math.pow(2, attempt)
// 加入随机抖动,防止「惊群效应」
const jitter = exponentialDelay * this.jitterFactor * Math.random()
// 不超过最大延迟
return Math.min(exponentialDelay + jitter, this.maxDelay)
}
// 投递 Webhook(带自动重试)
async deliver(webhookId, targetUrl, payload, options = {}) {
const attempt = this.deliveryAttempts.get(webhookId)?.count ?? 0
if (attempt >= this.maxRetries) {
// 超过最大重试次数,进入死信队列
this.moveToDeadLetter(webhookId, targetUrl, payload, attempt)
return { success: false, reason: 'max_retries_exceeded', deadLettered: true }
}
try {
const response = await fetch(targetUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Webhook-ID': webhookId,
'X-Delivery-Attempt': String(attempt + 1),
...options.headers,
},
body: JSON.stringify(payload),
signal: AbortSignal.timeout(options.timeout ?? 10000),
})
if (response.ok) {
this.deliveryAttempts.delete(webhookId)
this.emit('delivered', { webhookId, attempt: attempt + 1 })
return { success: true, attempt: attempt + 1 }
}
// 4xx 错误(除 429)不重试——接收方明确拒绝
if (response.status >= 400 && response.status < 500 && response.status !== 429) {
this.moveToDeadLetter(webhookId, targetUrl, payload, attempt + 1, {
reason: `client_error_${response.status}`,
statusCode: response.status,
})
return { success: false, reason: 'client_error', deadLettered: true }
}
// 5xx 或 429(限流)→ 重试
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
} catch (error) {
// 记录失败尝试
this.deliveryAttempts.set(webhookId, {
count: attempt + 1,
lastError: error.message,
lastAttempt: Date.now(),
})
const delay = this.calculateDelay(attempt)
this.emit('retry_scheduled', { webhookId, attempt: attempt + 1, delay })
// 延迟后重试
await new Promise(resolve => setTimeout(resolve, delay))
return this.deliver(webhookId, targetUrl, payload, options)
}
}
// 移入死信队列
moveToDeadLetter(webhookId, targetUrl, payload, attempts, error = {}) {
this.deadLetterStore.set(webhookId, {
webhookId,
targetUrl,
payload,
attempts,
error,
deadLetteredAt: Date.now(),
})
this.deliveryAttempts.delete(webhookId)
this.emit('dead_lettered', { webhookId, attempts, error })
}
// 手动重试死信
async retryDeadLetter(webhookId) {
const entry = this.deadLetterStore.get(webhookId)
if (!entry) return null
this.deadLetterStore.delete(webhookId)
// 重置重试计数
this.deliveryAttempts.delete(webhookId)
return this.deliver(entry.webhookId, entry.targetUrl, entry.payload)
}
getDeadLetters() {
return Array.from(this.deadLetterStore.values())
}
}
// 使用示例
const engine = new WebhookDeliveryEngine({ maxRetries: 5, baseDelay: 1000 })
engine.on('delivered', ({ webhookId, attempt }) => {
console.log(`✅ Webhook ${webhookId} delivered after ${attempt} attempt(s)`)
})
engine.on('retry_scheduled', ({ webhookId, attempt, delay }) => {
console.log(`🔄 Webhook ${webhookId} retry #${attempt} in ${delay}ms`)
})
engine.on('dead_lettered', ({ webhookId, attempts }) => {
console.error(`💀 Webhook ${webhookId} dead-lettered after ${attempts} attempts`)
})
await engine.deliver('wh_001', 'https://api.example.com/webhook', {
event: 'payment.completed',
data: { amount: 4999 },
})
💡 **提示:**抖动(Jitter)是重试策略中容易被忽略但极其重要的细节。没有抖动的指数退避会导致「惊群效应」——大量失败的 Webhook 在同一时刻同时重试,瞬间压垮刚刚恢复的服务。
2.3 429 状态码的特殊处理
当接收方返回 429(Too Many Requests)时,应该优先使用服务端通过 Retry-After Header 告知的等待时间:
// 处理 429 限流响应
async function handleRateLimit(response) {
const retryAfter = response.headers.get('Retry-After')
if (retryAfter) {
// Retry-After 可以是秒数或 HTTP 日期
const delay = retryAfter.includes('-')
? new Date(retryAfter).getTime() - Date.now()
: parseInt(retryAfter, 10) * 1000
return Math.max(0, delay)
}
// 没有 Retry-After,使用默认的指数退避
return null
}
💡 三、幂等处理:确保事件不重复消费
3.1 为什么幂等性是必须的
Webhook 的「至少一次投递」(At-Least-Once Delivery)语义意味着你的接收方一定会收到重复事件。原因包括:
- ✅ 发送方在收到你的 200 响应前连接断开,会重发
- ✅ 网络超时导致发送方不确定你是否收到,会重发
- ✅ 重试机制在你处理成功但响应丢失时会重发
如果你的 Webhook 处理器不是幂等的,重复事件会导致:
- ❌ 订单被重复创建
- ❌ 支付被重复入账
- ❌ 通知被重复发送
- ❌ 库存被重复扣减
3.2 幂等键 + Redis 去重实现
// webhook-idempotent-handler.js — 幂等 Webhook 处理器
import Redis from 'ioredis'
const redis = new Redis({ host: 'localhost', port: 6379 })
// 幂等处理 Webhook 事件
async function handleWebhookIdempotent(webhookId, event) {
// 1. 使用 SET NX 实现原子性的去重检查
// webhookId 作为幂等键,EX 86400 表示 24 小时后自动过期
const lockKey = `webhook:processed:${webhookId}`
const isNew = await redis.set(lockKey, Date.now(), 'EX', 86400, 'NX')
if (!isNew) {
// 重复事件,检查之前的处理结果
const existingResult = await redis.get(`webhook:result:${webhookId}`)
console.log(`⏭️ Duplicate webhook ${webhookId}, skipping`)
return {
status: 'duplicate',
originalResult: existingResult ? JSON.parse(existingResult) : null,
}
}
try {
// 2. 首次处理 — 执行业务逻辑
const result = await processEvent(event)
// 3. 存储处理结果(用于重复请求的快速返回)
await redis.set(
`webhook:result:${webhookId}`,
JSON.stringify(result),
'EX',
86400
)
console.log(`✅ Webhook ${webhookId} processed successfully`)
return { status: 'processed', result }
} catch (error) {
// 4. 处理失败 — 释放幂等键,允许重试
await redis.del(lockKey)
console.error(`❌ Webhook ${webhookId} processing failed:`, error.message)
throw error
}
}
// 业务处理函数
async function processEvent(event) {
switch (event.type) {
case 'order.created':
// 幂等的订单创建:使用 orderId 作为数据库唯一约束
return await createOrder(event.data)
case 'payment.completed':
// 幂等的支付确认:检查是否已确认
return await confirmPayment(event.data)
default:
console.warn(`Unknown event type: ${event.type}`)
return { handled: false }
}
}
// Express 集成
import express from 'express'
const app = express()
app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf } }))
app.post('/webhooks/events', async (req, res) => {
const webhookId = req.headers['x-webhook-id']
if (!webhookId) {
return res.status(400).json({ error: 'Missing X-Webhook-ID header' })
}
try {
const result = await handleWebhookIdempotent(webhookId, req.body)
res.status(200).json(result)
} catch (error) {
// 返回 5xx 触发发送方重试
res.status(500).json({ error: 'Internal processing error' })
}
})
⚠️ **警告:**幂等键的过期时间要根据业务场景设置。支付类事件建议 7 天,普通事件 24 小时即可。过期后如果收到重复事件,应该将其视为新事件处理——但业务层(如数据库唯一约束)仍然能提供最后一道防线。
3.3 幂等处理的三层防线
| 层级 | 机制 | 作用 | 实现方式 |
|---|---|---|---|
| 第一层 | Webhook ID 去重 | 快速拒绝重复请求 | Redis SET NX |
| 第二层 | 业务唯一约束 | 防止数据重复 | 数据库唯一索引 |
| 第三层 | 状态机检查 | 防止非法状态转换 | 业务逻辑校验 |
📌 **记住:**幂等性不能只依赖 Redis 去重。Redis 可能宕机或丢失数据,真正的安全保障是数据库层面的唯一约束。三层防线缺一不可。
📊 四、发送方最佳实践
4.1 Webhook 事件设计规范
一个好的 Webhook 事件结构应该包含完整的元数据:
// ✅ 推荐的 Webhook 事件结构
const webhookEvent = {
// 唯一标识符,用于幂等处理
id: 'evt_1a2b3c4d5e',
// 事件类型,使用 domain.action 格式
type: 'order.created',
// 事件发生时间(ISO 8601)
created_at: '2026-06-13T10:30:00Z',
// API 版本号
api_version: '2026-06-01',
// 事件数据
data: {
object: {
id: 'ord_98765',
amount: 9900,
currency: 'cny',
status: 'created',
},
// 变更前的字段(用于 UPDATE 事件)
previous_attributes: null,
},
}
// ❌ 避免的事件结构 — 缺少元数据
const badEvent = {
orderId: '98765', // 没有 idempotency key
amount: 9900, // 没有类型区分
// 没有时间戳,没有版本号
}
4.2 超时与并发控制
| 参数 | 推荐值 | 说明 |
|---|---|---|
| 连接超时 | 5 秒 | 建立 TCP 连接的超时 |
| 读取超时 | 10-30 秒 | 等待响应的超时 |
| 并发限制 | 按接收方限流 | 避免压垮接收方 |
| 重试次数 | 3-5 次 | 超过后进入死信队列 |
| 首次重试延迟 | 1 秒 | 指数退避的基准 |
4.3 接收方的被动重试 vs 发送方的主动重试
| 策略 | 优点 | 缺点 | 代表产品 |
|---|---|---|---|
| 发送方主动重试 | 可靠、可控 | 发送方负担重 | Stripe, GitHub |
| 接收方主动拉取 | 简单、解耦 | 实时性差 | AWS SQS |
| 混合模式 | 兼顾可靠性和实时性 | 复杂度高 | Shopify |
⚡ **关键结论:**大多数场景应该选择「发送方主动重试」模式。它对接收方最友好——接收方只需要验证签名、处理事件、返回 200,所有重试逻辑都由发送方负责。
✅ 五、Webhook 系统工程清单
发送方必须做到:
- ✅ 对所有 Webhook 请求进行 HMAC-SHA256 签名
- ✅ 实现指数退避 + 随机抖动的重试机制
- ✅ 为每个事件生成全局唯一的事件 ID
- ✅ 提供 Webhook 管理界面(注册、测试、查看日志)
- ✅ 实现死信队列,支持手动重试
- ❌ 不要在 4xx 错误(除 429)时重试
- ❌ 不要把重试延迟设为固定值(避免惊群效应)
接收方必须做到:
- ✅ 验证每一条 Webhook 的签名
- ✅ 检查时间戳防重放攻击(±5 分钟窗口)
- ✅ 使用
crypto.timingSafeEqual防时序攻击 - ✅ 实现幂等处理(Webhook ID + Redis 去重 + 数据库约束)
- ✅ 先返回 200 再处理业务逻辑(或使用消息队列异步处理)
- ❌ 不要在 Webhook 回调中做耗时操作(>10 秒会被发送方判定为超时)
- ❌ 不要只依赖 Redis 做幂等(加数据库唯一约束兜底)
⚡ **关键结论:**Webhook 系统的核心设计原则是「信任但验证」——信任发送方发来的事件,但通过签名验证防伪造、通过时间戳防重放、通过幂等键防重复。三层防线缺一不可。