Advanced (更新: 2026/6/3)

用 Claude Code 实现生产级 Webhook:签名验证、幂等与重试

用 Claude Code 构建生产级 Webhook:raw body、签名验证、幂等、重试、测试与运维手册。

用 Claude Code 实现生产级 Webhook:签名验证、幂等与重试

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 示例实现关注点
EndpointPOST /webhooks/githubPOST /webhooks/stripe路由隔离
事件 IDX-GitHub-Deliveryevent.id幂等 key
事件类型X-GitHub-Eventevent.typehandler 分发
签名 headerX-Hub-Signature-256Stripe-Signature来源验证
验证输入raw bodyraw bodybody parser 顺序
成功响应快速返回 2xx快速返回 2xx先入队再处理

实现前请看官方文档:GitHub WebhooksGitHub 验证投递Stripe WebhooksStripe 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

关键路径要写测试:签名成功、签名失败、重复投递。测试文件可以直接使用 signGitHubBodyverifyGitHubSignature,命令为:

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 的 pushpull_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 实现,能显著减少后续返工。

#Claude Code #Webhook #API 设计 #安全 #异步处理
免费

免费 PDF: Claude Code 速查表

输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。

我们会妥善保护你的信息,不发送垃圾邮件。

把 Claude Code 变成真正能带来结果的工作流

先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。

Masa

关于作者

Masa

专注 Claude Code 实务流程、团队导入和内容转化的工程师。