Stripe Webhooks 生产实战:4 个模式让你告别凌晨 3 点告警

Stripe webhooks 在 72 小时内最多重试 5 次;以下 4 个模式经过了 P0 级生产验证。包含 TypeScript handler 实现、签名验证机制、以及 TanStack Ship 自研计费模块的完整交付方案,是应对重复投递和幂等性问题的实战指南。

Huifer
Huifer
May 16, 202611 min read
其他语言:Deutsch · English

本文由 Huifer 撰写,TanStack Ship 的独立开发者兼维护者。 自 2024 年以来,我已在四款 SaaS 产品上实现了 Stripe 集成的计费模块——最近的一个是 TanStack Ship 自带的计费模块。2025 年 12 月,一次重复的 webhook 投递导致 18 个客户账户被重复积分,产生了 1247 美元的冲正费用,以及六周的支持工单处理成本。以下四个模式是我现在的默认交付标准;每一个都直接对应 Stripe Webhooks 官方文档,并在 Cloudflare Workers + D1 SQLite 上端到端运行。

已验证来源: Stripe Webhooks 文档 · Stripe Webhooks 最佳实践 · Stripe 签名验证 · Stripe 重试行为 · Stripe 幂等请求 · Stripe CLI · Stripe Events API · Drizzle ORM · Cloudflare D1 · Cloudflare Workers · 最后更新:2026-05-16 · 更新日志


TL;DR: Stripe webhooks 是"至少一次"投递——每个 handler 都必须具备幂等性。以下四个模式经受了我在四款 SaaS 计费模块中遇到的生产级故障考验:(1) 对原始请求体进行签名验证,(2) 以 event.id 为主键的幂等性表,(3) 事务性状态变更,(4) pending/failed 监控与告警。完整 handler 仅需约 60 行 TypeScript 代码,schema 只需一个 Drizzle 迁移,测试只需三条 Stripe CLI 命令。TanStack Ship 默认包含全部四个模式。


"生产环境"对 Stripe webhooks 到底意味着什么

"生产环境"不是指某个部署环境——而是一种故障保障。一旦你的 Stripe handler 可以通过公网固定 URL 访问,四种故障模式立即激活,而这正是区分"凌晨 3 点收到计费告警"和"安稳睡到天亮"的关键。

四种故障模式

  1. 至少一次投递。 Stripe 的 webhooks 文档 明确指出:"事件至少投递一次。"每次重试都不是例外情况——这是合同契约。返回 2xx 的 handler 即为成功;其他状态码都会触发重试。
  2. 长重试窗口。 根据 Stripe 重试行为文档,Stripe 以指数退避策略重试失败的投递,最长持续 72 小时。重试序列为:立即、1 秒、5 秒、30 秒、2 分钟、10 分钟、30 分钟、2 小时、6 小时、12 小时。一个 handler 若失败一次,将在第 1 分钟、第 5 分钟和第 30 分钟再次执行。
  3. 并发投递。 同一个 event.id 可能在几毫秒内从两个不同的 Stripe 区域端点到达你的 handler。应用层去重存在竞态问题;数据库层去重则不会。
  4. 业务逻辑内部的竞态条件。 即使有了幂等性,handler 内部的本地状态变更也可能在慢路径上与自身产生竞态。我 2025 年 12 月事故中涉及的降级标签模式正是这种故障——handler 执行快、数据库写入慢,更新结果丢失。

以上四种我都是在没有防护的情况下踩坑学到的。下面的模式逐一封堵了每一种故障。

经受 P0 验证的 4 个模式

每个模式针对上述四种故障模式之一。顺序很重要:模式 1 防止伪造请求;模式 2 防止重复处理;模式 3 防止竞态条件;模式 4 防止静默失败。

模式 1:对原始请求体进行签名验证

签名验证不是可选项。Stripe 签名验证指南 描述了一种基于 HMAC-SHA256 的方案,以每个端点独立的签名密钥为 key。跳过验证的 handler 会接受任何能猜到你端点 URL 的攻击者发来的伪造事件——而这个 URL 在 Stripe Dashboard 上是公开可发现的。

陷阱在于框架。Cloudflare Workers、TanStack Start 和 Next.js 都会在 handler 看到请求体之前自动将 body 解析为 JSON;签名是基于原始字节计算的,所以在解析之后验证就会失败。解决方案是显式处理原始请求体:

typescript
// billing/verify-webhook.ts
import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: "2025-09-30.clover",
  typescript: true,
});

export async function verifyWebhookRequest(
  request: Request,
  signingSecret: string,
): Promise<Stripe.Event> {
  const signature = request.headers.get("stripe-signature");
  if (!signature) throw new Error("Missing stripe-signature header");

  const payload = await request.text();
  // constructEventAsync 在签名错误时抛出异常
  return stripe.webhooks.constructEventAsync(
    payload,
    signature,
    signingSecret,
    undefined,
    Stripe.createSubtleCryptoProvider(),
  );
}

该函数在任何解析之前以文本形式读取 body。根据 Cloudflare 的请求体文档,request.text() API 返回原始字节——这正是 Stripe 重新计算 HMAC 所需的。

模式 2:以 event.id 为主键的幂等性表

幂等性是数据库约束,不是代码模式。Stripe webhooks 最佳实践指南 明确指出:以 event.id 作为去重 key,在任何业务逻辑之前将其持久化,并依赖数据库强制执行唯一性。

以下 schema 是随 TanStack Ship 计费模块交付的 Drizzle 迁移:

typescript
// schema/webhook-events.ts (Drizzle + D1 SQLite)
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";

export const webhookEvents = sqliteTable("webhook_events", {
  eventId: text("event_id").primaryKey(),
  eventType: text("event_type").notNull(),
  receivedAt: integer("received_at").notNull(),
  processedAt: integer("processed_at"),
  status: text("status", { enum: ["pending", "completed", "failed"] })
    .notNull()
    .default("pending"),
});
sql
-- Migration: 0001_webhook_events.sql
CREATE TABLE webhook_events (
  event_id TEXT PRIMARY KEY,
  event_type TEXT NOT NULL,
  received_at INTEGER NOT NULL,
  processed_at INTEGER,
  status TEXT NOT NULL DEFAULT 'pending'
    CHECK (status IN ('pending', 'completed', 'failed'))
);

CREATE INDEX idx_webhook_events_status_time
  ON webhook_events(status, received_at);

status 列是一个免费的状态监控钩子——超过 5 分钟的 pending 事件计数就是你的"重试风暴检测器",无需额外代码。

模式 3:事务性状态变更

在行插入上的幂等性是不够的。handler 内部的业务逻辑本身就是一种竞态条件——尤其是当同一个 handler 涉及多个表时。解决方案是将幂等性声明和业务逻辑包装在同一个事务中:

typescript
// billing/handle-invoice-paid.ts
import { db } from "../db";
import { webhookEvents, credits } from "../schema";
import { eq } from "drizzle-orm";

export async function handleInvoicePaid(event: Stripe.Event) {
  const invoice = event.data.object as Stripe.Invoice;
  const customerId = invoice.customer as string;

  await db.transaction(async (tx) => {
    // 1. 将事件标记为已完成(幂等性断言)
    await tx
      .update(webhookEvents)
      .set({ status: "completed", processedAt: Date.now() })
      .where(eq(webhookEvents.eventId, event.id));

    // 2. 原子性地执行业务逻辑
    await tx
      .insert(credits)
      .values({
        customerId,
        amountCents: invoice.amount_paid,
        stripeEventId: event.id, // 第二层幂等性保护
        createdAt: Date.now(),
      })
      .onConflictDoNothing({ target: credits.stripeEventId });
  });
}

两层幂等性:webhook_events 的主键和 credits.stripeEventId 外键。两者都以 ON CONFLICT DO NOTHING 插入,所以即使未来某个 bug 跳过了幂等性声明,积分插入也不会重复。根据 Stripe 幂等请求文档,在出站 API 调用上传递 Idempotency-Key 可以在 Stripe 端获得同样的保护。

模式 4:pending/failed 监控与告警

静默失败的 handler 是代价最高的一种。根据 Stripe webhook 文档,Stripe 重试 72 小时后会丢弃事件。如果你的 handler 静默返回 500 持续三天,你就会面临一个以客户投诉形式浮出水面的计费对账问题,而不是仪表盘告警。

三条 Cloudflare Workers 日志加一条 D1 查询就足够:

  • webhook_events.status='pending' 计数超过 5 分钟——大于 5 条时告警。
  • Stripe Dashboard webhook 投递成功率——24 小时内低于 99% 时告警。
  • D1 查询:SELECT event_type, COUNT(*) FROM webhook_events WHERE status='failed' AND received_at > strftime('%s','now','-7 day') GROUP BY event_type——每周审查一次。
  • stripe events resend 日志条目中的重放尝试次数——任何非零值都需要人工调查。

我对每个涉及计费模块的 PR 都运行这套监控。成本约为每月 0.40 美元的 Workers Analytics 费用;而没有这套监控的代价是 2025 年 12 月的 1247 美元。

约 60 行代码的生产级 handler

完整的 handler 将四个模式组合成一个文件。它运行约 60 行代码,位于 src/server/billing/webhook-handler.ts:

typescript
// src/server/billing/webhook-handler.ts
import Stripe from "stripe";
import { db } from "../db";
import { webhookEvents } from "../schema/webhook-events";
import { handleInvoicePaid } from "./handle-invoice-paid";
import { handleSubscriptionUpdated } from "./handle-subscription-updated";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: "2025-09-30.clover",
});

export async function POST(request: Request) {
  const event = await verifyWebhookRequest(
    request,
    process.env.STRIPE_WEBHOOK_SECRET!,
  );

  // 模式 2:幂等性声明。主键强制执行唯一性。
  try {
    await db.insert(webhookEvents).values({
      eventId: event.id,
      eventType: event.type,
      receivedAt: Date.now(),
      status: "pending",
    });
  } catch (err: any) {
    if (err?.code === "SQLITE_CONSTRAINT_PRIMARYKEY") {
      // 已接收——快速短路返回 200
      return new Response("OK (duplicate)", { status: 200 });
    }
    throw err;
  }

  // 模式 3:按事件类型分发,每个 handler 都是事务性的
  try {
    switch (event.type) {
      case "invoice.paid":
        await handleInvoicePaid(event);
        break;
      case "customer.subscription.updated":
      case "customer.subscription.deleted":
        await handleSubscriptionUpdated(event);
        break;
      default:
        // 未处理的事件类型——标记为已完成,避免重试
        await db
          .update(webhookEvents)
          .set({ status: "completed", processedAt: Date.now() })
          .where(eq(webhookEvents.eventId, event.id));
    }
  } catch (err) {
    // 模式 4:监控——标记为失败,以便重试可重新尝试
    await db
      .update(webhookEvents)
      .set({ status: "failed", processedAt: Date.now() })
      .where(eq(webhookEvents.eventId, event.id));
    return new Response("Handler error", { status: 500 });
  }

  return new Response("OK", { status: 200 });
}

它封堵的三种故障模式:

  • 同一 event.id 的重放 → 被主键捕获,在 <5ms 内返回 200。
  • 同一 event.id 的并发投递 → 被主键捕获。
  • 业务逻辑内部的竞态条件 → 被 db.transaction() 块捕获。

复现和测试这个模式

Stripe CLI 是你唯一需要的工具。测试计划只需三条命令,覆盖所有重试路径:

bash
# 1. 将生产 webhook 转发到 localhost
stripe listen --forward-to localhost:8787/webhooks/stripe

# 2. 触发一次性的 invoice.paid 事件
stripe trigger invoice.paid

# 3. 将同一 event_id 重放 5 次——只有第一次应该执行业务逻辑
stripe events resend evt_1ABC...  # 重复 5 次

使用修复前的 handler:5 次处理运行,5 组业务逻辑被执行。使用修复后的 handler:1 次处理运行,4 次在 50ms 内短路返回 200 OK。Stripe CLI 文档 涵盖了 stripe listen 和 stripe trigger;Stripe events API 文档 涵盖了 events resend。

这 4 个模式不覆盖的场景

以 event.id 为 key 的幂等性可以防止同一事件的重复业务逻辑执行。但它无法防护以下情况:

1. 长时重试

Stripe 72 小时的重试窗口意味着一个 pending 事件可能停留数小时。添加一个定期回收器,将超过 24 小时的 pending 行标记为 failed 以便人工审查。TanStack Ship 计费模块将此回收器作为 Cloudflare Workers Cron Trigger 交付,每 15 分钟运行一次。

2. 多币种对账

handler 以发票货币为客户增加积分,但你的内部账本可能假设单一货币。在源头处理外汇——永远不要在 webhook handler 内部处理。使用一个从每日 cron 更新的独立 fx_rates 表,并在 handler 事务内部引用它。

3. 退款和争议

这些是独立的事件类型(charge.refunded、charge.dispute.created),各有其幂等性需求。每个都需要自己的主键表列。否则,退款重试可能会将原始支付方式重复积分。

4. Webhook 端点宕机超过 72 小时

如果你的端点在 Stripe 最大重试窗口之后仍不可用,事件会在 Dashboard 中标记为失败且永不重新投递。构建一个每日对账作业,从 Stripe events API 按 created 时间戳拉取遗漏的事件。

对于这些边缘场景,Stripe Billing 客户门户 是正确的下一层防护。

常见问题

Stripe webhooks 的"至少一次"投递意味着什么?

至少一次意味着 Stripe 承诺至少投递每个事件一次——但也可能更多。返回非 2xx 状态的 handler 会触发 Stripe 指数退避策略的重试(立即、1 秒、5 秒、30 秒、2 分钟、10 分钟、30 分钟、2 小时、6 小时、12 小时),持续最长 72 小时。根据 Stripe 重试行为文档,handler 在第二次尝试成功的情况下,第一次和第二次同样都收到了同一个 event.id。以 event.id 为 key 的幂等性是使 handler 业务逻辑安全的唯一方法。

我应该保留 Stripe webhook 事件多久以保证幂等性?

24 小时覆盖绝大多数情况。Stripe 的重试时间线在 12 小时达到峰值,总退避最长 72 小时;24 小时覆盖峰值。对于交易量更大的商户,保留 7 天以处理时钟偏移和来自 Stripe Dashboard 的人工重放。TanStack Ship 默认保留 30 天以获得清晰的对账周期。

出站 API 调用也应该使用 Stripe 的幂等性 key 吗?

应该。根据 Stripe 幂等请求文档,在 POST 请求上传递 Idempotency-Key 可以使 Stripe 端的 API 具备安全的重试能力。当调用源自 webhook 时,使用 event.id 作为幂等性 key——这样 handler 的重试就会映射到同一次 Stripe API 调用。

Webhook handler 应该返回什么 HTTP 状态码?

只有在事件完全处理完毕且数据库行状态为 completed 时才返回 200。返回 500(或任何 4xx/5xx)以触发 Stripe 重试。对于未处理的事件类型返回 200 是一个常见 bug——它告诉 Stripe"成功",但事件实际上未被处理,所以重试永远不会触发。4xx 应该仅用于永久性失败(签名错误、格式错误的 payload);对于需要重试的情况,应返回 5xx。

我可以在调试期间禁用 webhook 重试吗?

可以。Stripe Dashboard webhook 端点编辑器 允许完全禁用端点。禁用期间,Stripe 会将事件排队最多 30 天,之后丢弃。仅用于短期调试窗口——禁用数小时意味着事件堆积。对于多天的调试工作,请将端点指向预发环境服务器。

如果 webhook 到达时数据库宕机了怎么办?

handler 返回 500(数据库不可达),Stripe 以标准退避策略重试,一旦数据库恢复,下次重试就会成功。这是正确的行为——失败关闭比静默丢弃事件要好。模式 4 中的监控会暴露这个事故:超过 5 分钟的 pending 行计数就是你的凌晨 3 点告警触发器。

总结

生产环境中的 Stripe webhooks 需要四个模式协同工作:对原始请求体进行签名验证、以 event.id 为主键的幂等性、事务性状态变更、以及 pending/failed 监控。handler 约 60 行代码;schema 只需一个迁移;测试只需三条 Stripe CLI 命令。

完整计费模块随 TanStack Ship 的定价方案交付——请查看定价页面获取模块分解,以及功能概览了解包含内容。了解驱动这些模式的 2025 年 12 月事故复盘,请参阅 Stripe webhook 重复投递事故复盘。了解更早期的 event.id 优先方法的更广泛重试模式,请参阅 幂等计费重试逻辑构建。了解 Stripe 的全球支付替代方案,请参阅 Stripe vs Paddle 对比。


下一步: 了解 TanStack Ship 计费模块 → · 阅读 2025 年 12 月事故复盘 → · 克隆免费入门版 →