Webhook 搭建
接收并可靠处理 webhook,带签名验证、幂等和死信队列。
为什么
Webhook 是世界告诉你的应用平台外发生了什么的方式——支付清算、仓库推送、文件上传。它们至少投递一次,意味着你会收到重复,且顺序不受你控。无幂等,重复 webhook 双重处理事件。无签名验证,攻击者可 POST 假 webhook 免费获订阅。无死信队列,瞬态 DB 错误导致 webhook 在提供方重试窗口过期后永久失败。这三个机制把 webhook 从可靠性负担变成可靠事件源。
何时使用
消费任何事件驱动外部服务时。健壮 handler 约 2-3 小时。仅轮询集成可跳过。
如何做
1. 暴露 POST 端点接受原始 body(非 JSON 解析) - 验签需精确字节;验证后再解析 - 成功处理后立即返回 200,非之前 2. 用提供方 secret 和算法验签 - Stripe:原始 body 的 HMAC-SHA256,用 timingSafeEqual 比较 - GitHub:body 的 HMAC-SHA256,在 X-Hub-Signature-256 头 3. 查幂等:在 processed_events 表查事件 ID - 已见返回 200(提供方视 200 为成功,不重处理) - 新则处理后在同一 DB 事务里插事件 ID 4. 处理失败返回 5xx 让提供方重试——但限重试 - 5 次失败后移事件到死信队列(DLQ)人工审查 - 处理错误永不返回 4xx——4xx 意为"不重试" 5. 慢工作异步处理:入队 job,返回 200 - webhook handler 的职责是确认,非做工作 - 后台 worker 取 job;失败则 DLQ 接住
示例
import { createHmac, timingSafeEqual } from "crypto";
export async function POST(req: Request) {
const raw = Buffer.from(await req.arrayBuffer());
const sig = req.headers.get("x-hub-signature-256")!;
const expected = "sha256=" + createHmac("sha256", process.env.WEBHOOK_SECRET!).update(raw).digest("hex");
if (!timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
return new Response("签名无效", { status: 401 });
}
const event = JSON.parse(raw.toString());
if (await isProcessed(event.id)) return new Response("ok", { status: 200 }); // 幂等
await enqueueJob(event); // 异步处理
await markProcessed(event.id);
return new Response("ok", { status: 200 });
}常见问题
- ⚠处理完成前返回 200,处理中崩溃永久丢事件。
- ⚠用 === 而非 timingSafeEqual 比签名,启用对 secret 的时序攻击。
- ⚠webhook handler 里同步处理,慢 DB 调用超时,提供方重试。
- ⚠不处理乱序投递,事件 #2 在 #1 前处理,状态不一致。
最佳实践
- ✓事件 ID + 处理时间戳存专用表带 30 天 TTL——旧重复不再到达。
- ✓用带可见性超时的队列(SQS、BullMQ)同一事件不被两 worker 并发处理。
- ✓加重放端点(仅管理员)从提供方重取最近 N 事件供人工重处理。
- ✓DLQ 有 >0 项时告警——卡住的 webhook 是静默数据丢失 bug。