Stripe Webhook Retry Logic: 构建永不超过扣的幂等计费系统

构建能够承受重试的幂等 Stripe webhook 处理器。使用 event.id 主键、事务性状态变更和 Stripe CLI 测试配方实现生产级 TypeScript 代码。

Huifer
Huifer
May 15, 202610 min read
其他语言:Deutsch · English

本文由 Huifer 撰写,独立开发者兼 TanStack Ship 维护者。 2025 年 9 月,我在 TanStack Ship 上线了一个集成 Stripe 的计费模块,九十天之内就遭遇了一次重复投递事故,导致 18 名客户被重复积分。根本原因是 event.id 上缺少幂等性检查。修复方案是一个 SQL 表加上四行处理器代码。本文完整介绍我现在默认上线的模式——包括用 Drizzle 编写的事务性状态变更,堵住了快代码路径背后的竞态条件。

资料来源:Stripe Webhooks 文档 · Stripe Webhooks 最佳实践 · Stripe 签名验证 · Stripe 重试行为 · Stripe 幂等请求 · Drizzle ORM · Cloudflare D1 · TanStack Ship GitHub · 最后更新:2026-05-15 · 更新日志


TL;DR: Stripe webhook 以至少一次方式投递——每个处理器必须幂等。最小可行修复方案是一个以 event.id 为主键的表加事务性状态变更步骤。以下是我随 TanStack Ship 上线的生产级 TypeScript 处理器、Drizzle 的幂等性表 schema、Stripe CLI 测试配方,以及 2025 年 12 月那次导致 18 名客户重复积分的事故总结的四条经验教训。


"至少一次"投递在实践中到底意味着什么

Stripe 的 webhooks 文档 明确说明"事件至少投递一次"。这句话就是计费系统正常工作与重复扣款的界线。每次重试不是例外情况——它就是合同的一部分。

根据 Stripe 重试行为文档,Stripe 在最多 72 小时内以指数退避策略重试失败的投递。重试序列:立即、1 秒、5 秒、30 秒、2 分钟、10 分钟、30 分钟、2 小时、6 小时、12 小时。处理器返回 2xx 即成功;其他任何响应都会触发下一次重试。

实际上,这意味着处理器需要应对三种情况:

  1. **任何非幂等的业务逻辑会在同一个 event.id 上运行两次。**积分被重复应用两次、邮件被发送两次、订阅被降级两次。
  2. Stripe 的 API 调用在你传入 Idempotency-Key 请求头时是独立幂等的(参见幂等请求文档),但处理器本地的副作用不是幂等的,除非你自己构建幂等性。
  3. 竞态条件在低负载下是隐形的,只在重试风暴下才可见——恰好与你想调试它的时候相反。

我是 2025 年 12 月硬生生学到这一课的。事后分析在这里;生产级模式见下文。

最小可行的幂等性实现

正确的 Stripe webhook 处理器由三个部分组成。每个部分解决一种独特的失败模式。跳过任何一个,就是在交付一个计费 bug。

第一部分:签名验证(强制的,不是可选的)

始终验证签名。Stripe 的签名验证指南有 HMAC-SHA256 方案的完整说明。生产环境示例:

typescript
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();
  // stripe.webhooks.constructEvent throws on bad signature
  return stripe.webhooks.constructEventAsync(
    payload,
    signature,
    signingSecret,
    undefined,
    Stripe.createSubtleCryptoProvider(),
  );
}

处理器必须在签名验证之前将原始请求体读取为文本(而非 JSON)。如果框架在签名验证运行之前自动解析 JSON,签名验证就会失败。Cloudflare Workers、TanStack Start 和 Next.js 都需要显式处理原始请求体——详见 Cloudflare 的请求体文档。

第二部分:以 event.id 为键的幂等性表

处理器做的第一件事——在任何业务逻辑之前——是 INSERT INTO webhook_events。如果因主键约束导致插入失败,说明该事件已经被处理过,立即返回 200。

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(),
});

export type WebhookEvent = typeof webhookEvents.$inferSelect;
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 CHECK (status IN ('pending', 'completed', 'failed'))
);

CREATE INDEX idx_webhook_events_status_time
  ON webhook_events(status, received_at);

status 列为你免费提供监控——超过 5 分钟的 pending 事件计数就是你的重试风暴探测器。

第三部分:事务性状态变更

业务逻辑中的任何状态变更,必须与幂等性行更新一起包裹在事务中。两条写操作要么同时成功,要么同时回滚。正是这部分堵住了 2025 年 12 月事故中降级标签所遭遇的竞态条件。

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,  // FK 指向幂等性表
        createdAt: Date.now(),
      })
      .onConflictDoNothing({ target: credits.stripeEventId });
  });
}

credits 表上的 stripeEventId 外键是第二层幂等性保护——即使未来某个 bug 跳过了第二部分,INSERT ... ON CONFLICT DO NOTHING 也能防止重复积分。根据 Stripe webhook 最佳实践指南,event.id 是该模式的标准标识符。

整合:生产级处理器

完整处理器——验证签名、插入幂等性行、按事件类型分发、事务性业务逻辑——约 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";
import { handleCustomerDeleted } from "./handle-customer-deleted";

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!,
  );

  // Step 1: 幂等性声明。PRIMARY KEY 强制唯一性。
  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;
  }

  // Step 2: 按事件类型分发,每个处理器都是事务性的
  try {
    switch (event.type) {
      case "invoice.paid":
        await handleInvoicePaid(event);
        break;
      case "customer.subscription.updated":
      case "customer.subscription.deleted":
        await handleSubscriptionUpdated(event);
        break;
      case "customer.deleted":
        await handleCustomerDeleted(event);
        break;
      default:
        // 未处理的事件类型——标记已完成,以免重试
        await db
          .update(webhookEvents)
          .set({ status: "completed", processedAt: Date.now() })
          .where(eq(webhookEvents.eventId, event.id));
    }
  } catch (err) {
    // 标记失败,以便重试重新尝试
    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 的重放 → 在 Step 1 中被 PRIMARY KEY 捕获,<5ms 返回 200。
  • 同一 event.id 的并发投递 → 在 Step 1 中被 PRIMARY KEY 捕获。
  • 业务逻辑内部的竞态条件 → 在 Step 2 的 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 五次——只有第一次应该执行业务逻辑
stripe events resend evt_1ABC...  # 重复 5 次

用修复前的处理器:5 次处理运行,5 次业务逻辑执行。用修复后的处理器:1 次处理运行,4 次在 <50ms 内短路返回 200 OK。我在每个涉及计费模块的 PR 上都会跑这个精确的测试序列。

Stripe CLI 文档详细介绍了 stripe listen 和 stripe trigger。events resend 命令记录在 Stripe events API 下。

2025 年 12 月事故的四条经验教训

1. 永远假设 webhooks 会重试

Stripe 的"至少一次"保证不是边缘情况的备用方案——它是 API 合同。把每个处理器当作同一个 event.id 的并发处理器来对待。幂等性表不是可选的——它就是最小可行的实现。

2. 幂等性是数据库约束,不是代码模式

应用层检查("我之前见过这个 event_id 吗?")在并发投递下不是幂等的。检查后写入的模式存在竞态。PRIMARY KEY 约束在数据库层强制执行幂等性,那里没有竞态。用它。

3. 竞态条件藏在快速代码路径背后

2025 年 12 月事故中降级标签 bug 只在一个无关 D1 热点导致的慢写入条件下才显现。本地测试中"总是工作"的代码在负载下可能不工作。这个失败模式的修复方案是到处都是原子操作——查找 → 检查 → 写入永远是错的,不管数据库通常有多快。

4. 客户信任是无法退还的成本

2025 年 12 月事故中 1247 美元的重复积分是可以撤销的。接下来的两个月里工单的语气是不可撤销的。最小可行的幂等性实现需要一个 SQL 表和四行处理器代码。不具备它所要付出的信任代价,以月计的支持摩擦和从未开过工单就悄悄取消的客户流失来衡量。

我如何在生产环境中监控这一点

三个 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 日志条目中的重放尝试计数——任何非零计数都需要手动调查。

何时这个模式还不够

以 event.id 为键的幂等性防止同一事件的重复业务逻辑运行。它不能防护以下情况:

  • 长时间运行的重试。 Stripe 72 小时的重试窗口意味着一个 pending 事件可以停留数小时。添加一个定期回收程序,将超过 24 小时的 pending 行标记为 failed 以便人工审查。TanStack Ship 计费模块以 Cloudflare Workers Cron Trigger 形式附送这个回收程序,每 15 分钟运行一次。
  • 多货币对账。 处理器按发票货币给客户积分,但你的内部账本可能假设单一货币。在源头处理外汇——永远不要在 webhook 处理器内部处理。使用一个由每日 cron 更新的独立 fx_rates 表,在处理器事务内部引用它。
  • 退款和争议。 这些是独立的事件类型(charge.refunded、charge.dispute.created),有自己的幂等性需求。每个都需要自己的 PRIMARY KEY 表列。否则,退款重试可能重复积分客户最初的支付方式。
  • Webhook 端点停机超过 72 小时。 如果你的端点在 Stripe 最大重试窗口之后仍停机,事件在 Dashboard 中被标记为失败且永远不会被重新投递。构建一个每日对账任务,从 Stripe events API 中按 created 时间戳拉取遗漏的事件。

对于这些边缘情况,Stripe Billing 客户门户是下一层正确的选择——详见 Billing 客户门户文档 的集成指南。

常见问题

Stripe webhook 中的幂等性是什么?

幂等性意味着即使 Stripe 多次投递同一个 webhook 事件,也精确处理一次。根据 Stripe webhook 文档,每个处理器都必须使用 event.id 作为去重键来实现幂等性。没有它,处理器的一个瞬态 502 就会触发一次重试,业务逻辑运行两次——积分、邮件和订阅状态变更都被重复了。

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

24 小时是覆盖 Stripe 重试窗口的最低要求。重试时间线峰值在 12 小时,总退避时间最长 72 小时;24 小时覆盖绝大多数情况。对于更高交易量的商家,保留 7 天以处理时钟偏差和来自 Stripe Dashboard 的手动重放。

我也应该在出站 API 调用上使用 Stripe 的幂等性密钥吗?

是的。根据幂等请求文档,在 POST 请求上传递 Idempotency-Key 使 Stripe 的 API 端安全地重试。当调用来自 webhook 时,用 event.id 作为幂等性密钥——这样你的处理器重试就映射到同一个 Stripe API 调用。

我的 webhook 处理器应该返回什么 HTTP 状态?

只在事件完全处理且数据库行状态为 completed 时返回 200。返回 500(或任何 4xx/5xx)会触发 Stripe 重试。在未处理的事件类型上返回 200 是一个常见 bug——它告诉 Stripe"成功了",但事件没有被处理,所以重试永远不会触发。4xx 应该只用于永久性失败(签名错误、格式错误的 payload),因为 Stripe 在足够尝试后不会重试这些;如果你想要重试,就返回 5xx。

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

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

总结

Stripe webhook 是至少一次投递。最小可行修复方案是 event.id PRIMARY KEY + 事务性状态变更 + 签名验证。处理器约 60 行代码,schema 一个表,测试三条 Stripe CLI 命令。

如果你在集成 Stripe 时没有幂等性,这篇文章就是你的警告。完整模式随 TanStack Ship 的计费模块一起交付——详见定价页面查看模块细分,或功能概述了解包含内容。Stripe 与 Paddle 的比较见计费基础设施指南,从等待名单到收入的归因文章详细介绍了 Stripe webhook 如何融入 90 天发布计划。


下一步: 查看 TanStack Ship 计费模块 → · 阅读 2025 年 12 月事故的事后分析 → · 克隆免费入门版 →