每个 SaaS 产品都需要 Webhook——Stripe 有 3000 万次/天的 Webhook 调用,GitHub 每天投递数亿条事件通知。但绝大多数开发者第一次实现 Webhook 时,都会掉进同一个坑:以为 fetch() 一下就完事了。直到用户投诉「为什么我没收到通知?」,才发现丢失的事件根本无从追溯。
Webhook 系统的核心挑战不是发送 HTTP 请求,而是在不可靠的网络中实现可靠的事件投递。本文将从零构建一个生产级 Webhook 系统,覆盖签名验证、指数退避重试、幂等性保障、死信队列和监控告警——每个模块都有完整可运行的 TypeScript 代码。
🔐 一、Webhook 安全基础:签名验证与防重放
Webhook 的第一个安全问题是:接收方如何验证请求确实来自你,而不是攻击者伪造的? 答案是 HMAC 签名。Stripe、GitHub、Shopify 等所有主流平台都采用这个方案。
HMAC-SHA256 签名实现
HMAC(Hash-based Message Authentication Code)用共享密钥对请求体做哈希,接收方用同样的密钥验证。即使攻击者截获了请求,没有密钥也无法伪造。
// webhook-signer.ts - Webhook 签名生成与验证
import { createHmac, timingSafeEqual } from 'crypto'
interface WebhookPayload {
id: string
event: string
data: Record<string, unknown>
timestamp: number
}
/**
* 生成 Webhook 签名
* 使用 HMAC-SHA256 对 payload 进行签名
*/
export function signPayload(payload: WebhookPayload, secret: string): string {
const body = JSON.stringify(payload)
const signature = createHmac('sha256', secret)
.update(body, 'utf8')
.digest('hex')
return `sha256=${signature}`
}
/**
* 验证 Webhook 签名
* 使用 timingSafeEqual 防止时序攻击
*/
export function verifySignature(
payload: string,
signature: string,
secret: string
): boolean {
const expected = createHmac('sha256', secret)
.update(payload, 'utf8')
.digest('hex')
const expectedSig = `sha256=${expected}`
// ⚠️ 必须使用 timingSafeEqual,否则存在时序攻击风险
if (signature.length !== expectedSig.length) {
return false
}
return timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSig)
)
}
⚠️ **警告:**永远不要用
===比较签名!JavaScript 的字符串比较是短路的,攻击者可以通过测量响应时间逐字节猜出签名。必须用timingSafeEqual。
防重放攻击:时间戳 + Nonce
光有签名还不够。攻击者可以截获一个合法的 Webhook 请求,然后反复重放。解决方案是在 payload 中加入时间戳和唯一 ID,接收方检查时间窗口和是否已处理过。
// anti-replay.ts - 防重放攻击中间件
import { signPayload, verifySignature } from './webhook-signer'
const TIMESTAMP_TOLERANCE_MS = 5 * 60 * 1000 // 5 分钟时间窗口
/**
* 生成带防重放保护的 Webhook 请求
*/
export function createWebhookRequest(
event: string,
data: Record<string, unknown>,
secret: string
) {
const payload = {
id: `evt_${crypto.randomUUID()}`, // 唯一事件 ID
event,
data,
timestamp: Date.now(),
}
const signature = signPayload(payload, secret)
return {
headers: {
'Content-Type': 'application/json',
'X-Webhook-Signature': signature,
'X-Webhook-ID': payload.id,
'X-Webhook-Timestamp': String(payload.timestamp),
},
body: JSON.stringify(payload),
}
}
/**
* 验证 Webhook 请求(签名 + 时间戳 + 去重)
*/
export async function validateWebhookRequest(
req: { headers: Record<string, string>; body: string },
secret: string,
processedIds: Set<string>
): Promise<{ valid: boolean; reason?: string }> {
const signature = req.headers['x-webhook-signature']
const webhookId = req.headers['x-webhook-id']
const timestamp = req.headers['x-webhook-timestamp']
// 1. 检查必需的 header
if (!signature || !webhookId || !timestamp) {
return { valid: false, reason: '缺少必需的 header' }
}
// 2. 验证签名
if (!verifySignature(req.body, signature, secret)) {
return { valid: false, reason: '签名验证失败' }
}
// 3. 检查时间窗口(防重放)
const age = Date.now() - Number(timestamp)
if (age > TIMESTAMP_TOLERANCE_MS || age < -TIMESTAMP_TOLERANCE_MS) {
return { valid: false, reason: `请求已过期 (${age}ms)` }
}
// 4. 检查是否已处理过(幂等性)
if (processedIds.has(webhookId)) {
return { valid: false, reason: '重复事件' }
}
processedIds.add(webhookId)
return { valid: true }
}
💡 **提示:**生产环境中,
processedIds应该存储在 Redis 中并设置 TTL,而不是内存 Set。Redis 的SET key NX EX命令天然支持幂等性检查。
🚀 二、可靠投递:指数退避重试与死信队列
网络是不可靠的。接收方服务器可能宕机、网络可能超时、DNS 可能解析失败。一个生产级 Webhook 系统必须有完善的重试机制。
重试策略对比
| 策略 | 间隔模式 | 优点 | 缺点 | 推荐场景 |
|---|---|---|---|---|
| 固定间隔 | 10s, 10s, 10s… | 实现简单 | 容易造成雪崩 | ❌ 不推荐 |
| 线性递增 | 10s, 20s, 30s… | 略好于固定间隔 | 仍可能集中重试 | ❌ 不推荐 |
| 指数退避 | 10s, 20s, 40s, 80s… | 分散压力 | 可能等太久 | ✅ 推荐 |
| 指数退避 + 抖动 | 10s, 25s, 55s, 90s… | 最佳分散效果 | 实现稍复杂 | ✅✅ 最佳 |
⚡ **关键结论:**永远使用「指数退避 + 随机抖动(Jitter)」策略。AWS 的官方博客曾分析,纯指数退避在大规模场景下仍会产生「惊群效应」,加上随机抖动后可将重试峰值降低 70% 以上。
完整的重试引擎实现
// webhook-retry-engine.ts - 带指数退避和死信队列的重试引擎
import { signPayload } from './webhook-signer'
interface WebhookDelivery {
id: string
url: string
payload: WebhookPayload
secret: string
attempt: number
maxAttempts: number
nextRetryAt: number
status: 'pending' | 'delivered' | 'failed' | 'dead_letter'
lastError?: string
createdAt: number
}
interface WebhookPayload {
id: string
event: string
data: Record<string, unknown>
timestamp: number
}
// 重试间隔配置(毫秒)
const RETRY_INTERVALS = [
10_000, // 第 1 次重试:10 秒
30_000, // 第 2 次重试:30 秒
2 * 60_000, // 第 3 次重试:2 分钟
10 * 60_000, // 第 4 次重试:10 分钟
30 * 60_000, // 第 5 次重试:30 分钟
2 * 3600_000, // 第 6 次重试:2 小时
]
const MAX_ATTEMPTS = RETRY_INTERVALS.length
/**
* 计算下次重试时间(指数退避 + 随机抖动)
*/
function calculateNextRetry(attempt: number): number {
const baseDelay = RETRY_INTERVALS[Math.min(attempt, RETRY_INTERVALS.length - 1)]
// 添加 ±25% 的随机抖动
const jitter = baseDelay * (0.75 + Math.random() * 0.5)
return Date.now() + Math.round(jitter)
}
/**
* 投递单个 Webhook
*/
async function deliverWebhook(delivery: WebhookDelivery): Promise<boolean> {
const signature = signPayload(delivery.payload, delivery.secret)
try {
const response = await fetch(delivery.url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Webhook-Signature': signature,
'X-Webhook-ID': delivery.payload.id,
'X-Webhook-Timestamp': String(delivery.payload.timestamp),
'X-Webhook-Delivery-ID': delivery.id,
'X-Webhook-Attempt': String(delivery.attempt + 1),
},
body: JSON.stringify(delivery.payload),
signal: AbortSignal.timeout(30_000), // 30 秒超时
})
// 2xx 视为成功
if (response.status >= 200 && response.status < 300) {
return true
}
// 4xx(除 429)不重试 —— 客户端错误
if (response.status >= 400 && response.status < 500 && response.status !== 429) {
delivery.lastError = `客户端错误: HTTP ${response.status}`
return false
}
// 5xx 或 429 → 可重试
delivery.lastError = `服务端错误: HTTP ${response.status}`
return false
} catch (error) {
delivery.lastError = `网络错误: ${(error as Error).message}`
return false
}
}
/**
* Webhook 投递调度器
*/
export class WebhookDispatcher {
private queue: WebhookDelivery[] = []
private deadLetter: WebhookDelivery[] = []
private processing = false
/**
* 入队一个新的 Webhook 投递任务
*/
enqueue(url: string, payload: WebhookPayload, secret: string): string {
const delivery: WebhookDelivery = {
id: `dlv_${crypto.randomUUID()}`,
url,
payload,
secret,
attempt: 0,
maxAttempts: MAX_ATTEMPTS,
nextRetryAt: Date.now(),
status: 'pending',
createdAt: Date.now(),
}
this.queue.push(delivery)
this.scheduleProcessing()
return delivery.id
}
/**
* 处理投递队列
*/
private async processQueue(): Promise<void> {
const now = Date.now()
const pending = this.queue.filter(
(d) => d.status === 'pending' && d.nextRetryAt <= now
)
// 并发投递,最多 10 个
const BATCH_SIZE = 10
for (let i = 0; i < pending.length; i += BATCH_SIZE) {
const batch = pending.slice(i, i + BATCH_SIZE)
await Promise.allSettled(batch.map((d) => this.attemptDelivery(d)))
}
// 继续处理还有待重试的
if (this.queue.some((d) => d.status === 'pending')) {
setTimeout(() => this.processQueue(), 1000)
} else {
this.processing = false
}
}
/**
* 尝试单次投递
*/
private async attemptDelivery(delivery: WebhookDelivery): Promise<void> {
const success = await deliverWebhook(delivery)
if (success) {
delivery.status = 'delivered'
console.log(`✅ Webhook ${delivery.id} 投递成功 (第 ${delivery.attempt + 1} 次)`)
return
}
delivery.attempt++
if (delivery.attempt >= delivery.maxAttempts) {
// 超过最大重试次数 → 进入死信队列
delivery.status = 'dead_letter'
this.deadLetter.push(delivery)
console.error(`💀 Webhook ${delivery.id} 进入死信队列 (${delivery.attempt} 次尝试)`)
return
}
// 计算下次重试时间
delivery.nextRetryAt = calculateNextRetry(delivery.attempt)
console.warn(
`⚠️ Webhook ${delivery.id} 投递失败,第 ${delivery.attempt} 次重试于 ${new Date(delivery.nextRetryAt).toISOString()}`
)
}
private scheduleProcessing(): void {
if (!this.processing) {
this.processing = true
this.processQueue()
}
}
/**
* 获取死信队列(用于手动重试或告警)
*/
getDeadLetterQueue(): WebhookDelivery[] {
return [...this.deadLetter]
}
/**
* 手动重试死信队列中的投递
*/
retryDeadLetter(deliveryId: string): boolean {
const idx = this.deadLetter.findIndex((d) => d.id === deliveryId)
if (idx === -1) return false
const delivery = this.deadLetter.splice(idx, 1)[0]
delivery.attempt = 0
delivery.status = 'pending'
delivery.nextRetryAt = Date.now()
this.queue.push(delivery)
this.scheduleProcessing()
return true
}
}
接收方的最佳实践
发送方做好了重试,接收方也要配合。以下是接收方的关键规范:
// webhook-receiver.ts - 接收方最佳实践
import { verifySignature } from './webhook-signer'
const processedEvents = new Map<string, number>()
/**
* 接收 Webhook 的标准处理流程
*/
export async function handleIncomingWebhook(
req: { headers: Record<string, string>; body: string },
secret: string
): Promise<{ status: number; body: string }> {
// 1. 快速返回 200 —— 不要在 Webhook handler 中做耗时操作
// 异步处理业务逻辑,先确认收到
// 2. 验证签名
const signature = req.headers['x-webhook-signature']
if (!signature || !verifySignature(req.body, signature, secret)) {
return { status: 401, body: 'Invalid signature' }
}
// 3. 幂等性检查
const webhookId = req.headers['x-webhook-id']
if (processedEvents.has(webhookId)) {
return { status: 200, body: 'Already processed' }
}
processedEvents.set(webhookId, Date.now())
// 4. 先返回 200,再异步处理
// 这样即使处理失败,发送方也不会重试
processEventAsync(req.body).catch(console.error)
return { status: 200, body: 'OK' }
}
async function processEventAsync(body: string): Promise<void> {
const event = JSON.parse(body)
// 业务逻辑放在这里,失败不影响 HTTP 响应
console.log(`处理事件: ${event.event}`, event.data)
}
// 定期清理过期的事件 ID(保留 24 小时)
setInterval(() => {
const cutoff = Date.now() - 24 * 60 * 60 * 1000
for (const [id, timestamp] of processedEvents) {
if (timestamp < cutoff) processedEvents.delete(id)
}
}, 60_000)
📌 记住:接收方应该尽快返回 HTTP 200,把业务处理放到异步队列中。如果你在 handler 中做了耗时操作(比如写数据库、调第三方 API),发送方会因为超时而重试,导致重复处理。
💡 三、生产级特性:事件注册、批量投递与监控
一个真正的 Webhook 平台(像 Stripe 或 GitHub)远不止发送请求。你需要事件订阅管理、批量投递优化、投递日志和告警机制。
事件订阅管理
// webhook-subscription.ts - 事件订阅管理
interface WebhookSubscription {
id: string
url: string
events: string[] // 订阅的事件类型,['*'] 表示全部
secret: string
active: boolean
createdAt: number
failureCount: number // 连续失败次数
}
export class SubscriptionManager {
private subscriptions = new Map<string, WebhookSubscription>()
/**
* 创建新的 Webhook 订阅
*/
create(url: string, events: string[]): WebhookSubscription {
const sub: WebhookSubscription = {
id: `sub_${crypto.randomUUID()}`,
url,
events,
secret: this.generateSecret(),
active: true,
createdAt: Date.now(),
failureCount: 0,
}
this.subscriptions.set(sub.id, sub)
return sub
}
/**
* 根据事件类型查找匹配的订阅
*/
findSubscribers(event: string): WebhookSubscription[] {
return Array.from(this.subscriptions.values()).filter(
(sub) => sub.active && (sub.events.includes(event) || sub.events.includes('*'))
)
}
/**
* 连续失败自动禁用(5 次连续失败后暂停)
*/
recordFailure(subId: string): void {
const sub = this.subscriptions.get(subId)
if (!sub) return
sub.failureCount++
if (sub.failureCount >= 5) {
sub.active = false
console.error(`🚫 订阅 ${subId} 因连续 ${sub.failureCount} 次失败已被自动禁用`)
}
}
recordSuccess(subId: string): void {
const sub = this.subscriptions.get(subId)
if (sub) sub.failureCount = 0
}
private generateSecret(): string {
return `whsec_${crypto.randomUUID().replace(/-/g, '')}`
}
}
投递日志与监控指标
生产环境中,你需要记录每次投递的详细信息,并暴露关键指标:
| 指标名称 | 含义 | 告警阈值 |
|---|---|---|
| 投递成功率 | 成功数 / 总投递数 | < 95% 告警 |
| 平均延迟 | 从事件发生到投递成功的平均时间 | > 30s 告警 |
| 死信队列深度 | 等待手动处理的失败投递数 | > 10 告警 |
| 重试率 | 需要重试的投递占比 | > 20% 告警 |
| 订阅禁用数 | 被自动禁用的订阅数 | > 0 告警 |
⚡ 关键结论:Webhook 系统最核心的监控指标是投递成功率。如果低于 95%,说明接收方大面积不可用,需要排查是你的发送频率过高,还是对方服务有问题。
完整的事件发送流程
// webhook-service.ts - 整合所有模块的完整服务
import { signPayload } from './webhook-signer'
import { WebhookDispatcher } from './webhook-retry-engine'
import { SubscriptionManager } from './webhook-subscription'
export class WebhookService {
private dispatcher = new WebhookDispatcher()
private subscriptions = new SubscriptionManager()
/**
* 触发一个事件,通知所有订阅者
*/
async emit(event: string, data: Record<string, unknown>): Promise<string[]> {
const subscribers = this.subscriptions.findSubscribers(event)
if (subscribers.length === 0) {
console.log(`📭 事件 ${event} 没有订阅者`)
return []
}
const payload = {
id: `evt_${crypto.randomUUID()}`,
event,
data,
timestamp: Date.now(),
}
const deliveryIds: string[] = []
for (const sub of subscribers) {
const deliveryId = this.dispatcher.enqueue(sub.url, payload, sub.secret)
deliveryIds.push(deliveryId)
console.log(`📤 事件 ${event} → ${sub.url} (${deliveryId})`)
}
return deliveryIds
}
/**
* 获取 Webhook 平台的统计信息
*/
getStats() {
const deadLetters = this.dispatcher.getDeadLetterQueue()
return {
deadLetterCount: deadLetters.length,
deadLetters: deadLetters.map((d) => ({
id: d.id,
url: d.url,
event: d.payload.event,
attempts: d.attempt,
lastError: d.lastError,
})),
}
}
}
// 使用示例
const webhookService = new WebhookService()
// 用户创建 Webhook 订阅
const subscription = webhookService.subscriptions.create(
'https://api.example.com/webhooks',
['order.created', 'order.paid', 'user.registered']
)
console.log(`Webhook Secret: ${subscription.secret}`)
// 系统触发事件
await webhookService.emit('order.created', {
orderId: 'ORD-20260607-001',
amount: 9900,
currency: 'CNY',
})
⚠️ 四、常见踩坑与避坑指南
构建 Webhook 系统的过程中,我总结了以下最常见的坑:
❌ 错误做法 1:在 Webhook 中发送敏感数据
Webhook 的 payload 会经过多个网络节点。不要在 payload 中发送密码、Token 原文或完整的信用卡号。正确做法是发送一个 ID,让接收方通过 API 回查获取完整数据。
❌ 错误做法 2:不做超时控制
默认的 HTTP 客户端可能等待 60 秒甚至更久。如果接收方卡住,你的投递线程会被阻塞。必须设置 5-30 秒的超时,并用 AbortSignal.timeout() 实现。
❌ 错误做法 3:同步处理 Webhook
在接收端同步处理 Webhook 是最常见的错误。如果你的处理逻辑需要 5 秒,而发送方的超时是 10 秒,那么当你的数据库稍微慢一点,发送方就会超时重试,导致重复处理。
✅ 推荐做法:先确认后处理
// ✅ 正确:先返回 200,异步处理
app.post('/webhooks', (req, res) => {
// 快速验证签名
if (!verifySignature(req.body, req.headers['x-signature'], SECRET)) {
return res.status(401).send()
}
// 立即返回 200
res.status(200).json({ received: true })
// 异步处理业务逻辑
processWebhookAsync(req.body)
})
// ❌ 错误:在 handler 中做耗时操作
app.post('/webhooks', async (req, res) => {
const order = await db.orders.update(...) // 可能很慢
await emailService.send(order.email, ...) // 可能更慢
await analytics.track('order.updated', ...) // 又一次网络调用
res.status(200).json({ received: true }) // 终于返回了
})
💡 **提示:**Stripe 的官方文档明确建议:「收到 Webhook 后立即返回 200,然后处理事件。如果你在返回 200 之前做耗时操作,Stripe 会认为投递失败并重试。」
📊 五、与主流 Webhook 实现的对比
| 特性 | 本文实现 | Stripe | GitHub | SendGrid |
|---|---|---|---|---|
| HMAC-SHA256 签名 | ✅ | ✅ | ✅ | ✅ |
| 指数退避重试 | ✅ (6 次) | ✅ (3 天内) | ✅ (3 次) | ✅ (3 次) |
| 死信队列 | ✅ | ✅ | ✅ | ❌ |
| 事件订阅过滤 | ✅ | ✅ | ✅ | ❌ |
| 自动禁用 | ✅ (5 次失败) | ✅ (3 天失败) | ✅ (连续失败) | ❌ |
| 投递日志 | 需扩展 | ✅ 控制台 | ✅ 设置页 | ✅ 活动日志 |
| 手动重试 | ✅ | ✅ | ✅ | ❌ |
🎯 总结
构建生产级 Webhook 系统的核心要点:
- ✅ 安全第一 — HMAC-SHA256 签名 + 时间戳防重放 + timingSafeEqual 防时序攻击
- ✅ 可靠投递 — 指数退避 + 随机抖动,最多 6 次重试,覆盖 2 天时间窗口
- ✅ 幂等保障 — 每个事件唯一 ID,接收方去重处理
- ✅ 优雅降级 — 连续失败自动禁用订阅,死信队列保留手动重试能力
- ✅ 先确认后处理 — 接收方立即返回 200,异步执行业务逻辑
如果你正在构建 SaaS 平台的 Webhook 功能,推荐参考 Stripe Webhook 文档 作为产品设计的标杆,再用本文的代码作为技术实现的基础。完整代码已适配 TypeScript + Node.js 环境,可直接集成到 Express、Hono 或任何框架中。