用 Claude Code 实现生产级 Webhook:签名验证、幂等与重试
用 Claude Code 构建生产级 Webhook:raw body、签名验证、幂等、重试、测试与运维手册。
Webhook 是外部服务在事件发生时,通过 HTTP 主动通知你的应用的一种机制。支付完成、GitHub push、表单提交、订阅变更、CRM 更新、SaaS 状态变化,都经常通过 Webhook 串起来。
真正上线时,Webhook 不是“建一个 POST 接口再读取 JSON”这么简单。你需要保留 raw body,也就是收到的原始请求字节;需要按供应商规则验证签名;需要幂等性,保证同一个事件被重试多次时业务结果只发生一次;还需要 event store、retry queue、测试、重放工具和运维 runbook。
如果只对 Claude Code 说“帮我加一个 Webhook”,它可能生成能跑的 demo,却在签名前先解析 JSON,或者在供应商重试时重复发邮件、重复开通权限。更好的做法是把失败条件也写进提示词。API 结构可以配合阅读 Claude Code API 开发、Secrets 管理、安全最佳实践 和 队列系统。
先写清供应商契约
| 项目 | GitHub 示例 | Stripe 示例 | 实现关注点 |
|---|---|---|---|
| Endpoint | POST /webhooks/github | POST /webhooks/stripe | 路由隔离 |
| 事件 ID | X-GitHub-Delivery | event.id | 幂等 key |
| 事件类型 | X-GitHub-Event | event.type | handler 分发 |
| 签名 header | X-Hub-Signature-256 | Stripe-Signature | 来源验证 |
| 验证输入 | raw body | raw body | body parser 顺序 |
| 成功响应 | 快速返回 2xx | 快速返回 2xx | 先入队再处理 |
实现前请看官方文档:GitHub Webhooks、GitHub 验证投递、Stripe Webhooks、Stripe Webhook signatures、Express 的 express.raw,以及 Anthropic 的 Claude Code best practices。
flowchart LR
A["Provider<br/>GitHub / Stripe"] --> B["Webhook endpoint<br/>raw body"]
B --> C["Signature verification"]
C --> D["Event store"]
D --> E["Idempotency check"]
E --> F["Retry queue"]
F --> G["Domain handler"]
D --> H["Replay tool"]
给 Claude Code 的提示词
请用 Express + TypeScript 实现 GitHub Webhook 接收端。
要求:
- 增加 POST /webhooks/github
- Webhook 路由必须用 express.raw({ type: "*/*" }) 保留 raw body
- JSON parse 必须发生在签名验证之后
- 用 HMAC SHA-256 验证 X-Hub-Signature-256
- 用 X-GitHub-Delivery 作为幂等 key
- 接受的事件先写入 event store,再进入处理流程
- 同一个 delivery id 不能被处理两次
- 接收时快速返回 202,重任务放入 retry queue
- 用 node:test 覆盖签名成功、签名失败、重复投递
- 增加保存投递的 replay script
- 从 WEBHOOK_SECRET 环境变量读取密钥
可复制运行的接收端
npm init -y
npm install express
npm install -D typescript tsx @types/node @types/express
创建 src/server.ts:
import crypto from "node:crypto";
import express from "express";
type EventStatus = "queued" | "processing" | "processed" | "failed";
type WebhookEvent = {
id: string;
provider: "github";
type: string;
headers: Record<string, string>;
rawBody: Buffer;
payload: unknown;
receivedAt: string;
status: EventStatus;
attempts: number;
lastError?: string;
};
export const app = express();
export const eventStore = new Map<string, WebhookEvent>();
export const processedEvents = new Set<string>();
export const retryQueue: string[] = [];
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET ?? "dev-secret-change-me";
app.use("/webhooks", express.raw({ type: "*/*", limit: "1mb" }));
app.use(express.json());
function firstHeader(value: string | string[] | undefined): string | undefined {
return Array.isArray(value) ? value[0] : value;
}
function safeCompare(leftValue: string, rightValue: string): boolean {
const left = Buffer.from(leftValue);
const right = Buffer.from(rightValue);
return left.length === right.length && crypto.timingSafeEqual(left, right);
}
export function signGitHubBody(
rawBody: Buffer | string,
secret = WEBHOOK_SECRET
): string {
return (
"sha256=" +
crypto.createHmac("sha256", secret).update(rawBody).digest("hex")
);
}
export function verifyGitHubSignature(
rawBody: Buffer,
signatureHeader: string | undefined,
secret = WEBHOOK_SECRET
): boolean {
if (!signatureHeader?.startsWith("sha256=")) return false;
return safeCompare(signGitHubBody(rawBody, secret), signatureHeader);
}
function headersForStorage(req: express.Request): Record<string, string> {
const result: Record<string, string> = {};
for (const [key, value] of Object.entries(req.headers)) {
if (typeof value === "string") result[key] = value;
}
return result;
}
app.post("/webhooks/github", (req, res) => {
const rawBody = Buffer.isBuffer(req.body) ? req.body : Buffer.from("");
const signature = firstHeader(req.headers["x-hub-signature-256"]);
const deliveryId = firstHeader(req.headers["x-github-delivery"]);
const eventType = firstHeader(req.headers["x-github-event"]) ?? "unknown";
if (!verifyGitHubSignature(rawBody, signature)) {
return res.status(401).json({ error: "invalid_signature" });
}
if (!deliveryId) {
return res.status(400).json({ error: "missing_delivery_id" });
}
const id = `github:${deliveryId}`;
if (processedEvents.has(id) || eventStore.has(id)) {
return res.status(202).json({ id, status: "duplicate" });
}
let payload: unknown;
try {
payload = JSON.parse(rawBody.toString("utf8"));
} catch {
return res.status(400).json({ error: "invalid_json" });
}
eventStore.set(id, {
id,
provider: "github",
type: eventType,
headers: headersForStorage(req),
rawBody,
payload,
receivedAt: new Date().toISOString(),
status: "queued",
attempts: 0,
});
retryQueue.push(id);
void processNextEvent();
return res.status(202).json({ id, status: "queued" });
});
export async function processNextEvent(): Promise<void> {
const id = retryQueue.shift();
if (!id) return;
const event = eventStore.get(id);
if (!event || event.status === "processed") return;
event.status = "processing";
event.attempts += 1;
try {
await handleWebhookEvent(event);
event.status = "processed";
processedEvents.add(id);
} catch (error) {
event.status = "failed";
event.lastError = error instanceof Error ? error.message : String(error);
if (event.attempts < 5) {
const delayMs = Math.min(30_000, 1_000 * 2 ** event.attempts);
setTimeout(() => {
event.status = "queued";
retryQueue.push(id);
void processNextEvent();
}, delayMs);
}
}
}
async function handleWebhookEvent(event: WebhookEvent): Promise<void> {
if (event.type === "push") console.log("GitHub push received", event.id);
}
if (process.env.NODE_ENV !== "test") {
const port = Number(process.env.PORT ?? 3000);
app.listen(port, () => {
console.log(`Webhook server listening on http://127.0.0.1:${port}`);
});
}
启动:
WEBHOOK_SECRET=dev-secret-change-me npx tsx src/server.ts
本地发送与测试
创建 scripts/send-local-webhook.ts:
import crypto from "node:crypto";
const secret = process.env.WEBHOOK_SECRET ?? "dev-secret-change-me";
const url =
process.env.WEBHOOK_URL ?? "http://127.0.0.1:3000/webhooks/github";
const body = JSON.stringify({ ref: "refs/heads/main", after: "local-test" });
const signature =
"sha256=" + crypto.createHmac("sha256", secret).update(body).digest("hex");
const response = await fetch(url, {
method: "POST",
headers: {
"content-type": "application/json",
"x-github-event": "push",
"x-github-delivery": `local-${Date.now()}`,
"x-hub-signature-256": signature,
},
body,
});
console.log(response.status, await response.text());
WEBHOOK_SECRET=dev-secret-change-me npx tsx scripts/send-local-webhook.ts
关键路径要写测试:签名成功、签名失败、重复投递。测试文件可以直接使用 signGitHubBody 和 verifyGitHubSignature,命令为:
NODE_ENV=test npx tsx --test test/webhook.test.ts
保存失败事件时,至少保留 raw body、headers、status、attempts、lastError。重放工具可以读取 JSON 文件,再用原 body 和原 headers 发回本地或 staging;不要重新格式化 body。
具体用例
支付场景中,Stripe 的 checkout.session.completed 可以确认订单,invoice.payment_failed 可以暂停权限或发送提醒。开发流程中,GitHub 的 push 和 pull_request 可以触发预览环境、文档生成和内部通知。表单与 CRM 场景中,外部 ID 可以防止供应商重试时重复创建工单。自家 SaaS 对外发送 Webhook 时,也要准备签名、投递日志、超时、重试和手动重发。
常见坑
第一,先用 JSON parser 再验签。签名通常基于原始字节计算,空格、换行和编码变化都会导致 HMAC 不一致。第二,在返回 2xx 之前做重任务,导致供应商超时后重试。第三,每次请求生成新的幂等 key,而不是使用供应商的 delivery ID。第四,只记录日志,不保存原始投递,事故时无法可靠重放。第五,把密钥写进代码或完整 payload 打进日志,给安全排查制造新问题。
上线检查
- Webhook 路由保留 raw body,普通 JSON API 不受影响。
- 签名失败返回
401,JSON 错误返回400,接受的任务返回202。 - 供应商 delivery ID 被用作幂等 key。
- event store 保存 raw body、headers、状态、尝试次数和最后错误。
- retry queue 有上限、退避和最终失败通知。
- replay script 可以对保存的 delivery 重新发送。
- 密钥来自环境变量或 secret manager,并有轮换记录。
总结
生产级 Webhook 的难点不在路由,而在安全、重复投递、失败恢复和运维可见性。让 Claude Code 一开始就围绕 provider 契约、raw body、签名验证、幂等、队列、测试和 runbook 生成代码,质量会稳定很多。
ClaudeCodeLab 的 Products 提供可复用模板;如果团队需要把 Claude Code 引入支付、CRM 或 GitHub 集成,可以从 Training 预约评审与培训。实际测试后,先写 raw body 与幂等测试,再让 Claude Code 实现,能显著减少后续返工。
免费 PDF: Claude Code 速查表
输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。
我们会妥善保护你的信息,不发送垃圾邮件。
把 Claude Code 变成真正能带来结果的工作流
先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。
关于作者
Masa
专注 Claude Code 实务流程、团队导入和内容转化的工程师。
相关文章
Claude Code Permission Receipt Pattern:记录权限、证据和回滚方式
Claude Code 权限 receipt:记录允许动作、需要批准的边界、验证命令、回滚说明,以及 Gumroad 和咨询 CTA 检查。
Claude Code/Codex 安全 Agent Harness 实战:权限、验证与回滚
用权限策略、执行计划、验证脚本和回滚日志,为 Claude Code 与 Codex 搭建更安全的 AI Agent 工作流。
Claude Code 子代理实战指南:安全委派并行文章与代码工作
用 Claude Code 子代理安全拆分文章和代码工作:委派规则、提示词模板、失败模式与检查清单。