ブログ一覧に戻る
Stripe決済冪等性SaaS

Stripe で二重課金を絶対に起こさない 3-段防御パターン

はじめに

サブスク SaaS において、二重課金は最もユーザー信頼を失うバグ です。Stripe は冪等性のための仕組みを多数提供していますが、それらを「3 段防御」として体系的に組み合わせないと、想定外のケースで二重請求が発生します。

本記事では、私たちの実装経験から得た 3-段防御パターン を共有します。

┌────────────────────────────────────────────────────────┐
│ 第 1 段: Webhook 冪等性 (受信側)                       │
│   Stripe からの同じ event を二重処理しない            │
└────────────────────────────────────────────────────────┘
┌────────────────────────────────────────────────────────┐
│ 第 2 段: API idempotencyKey (送信側)                  │
│   Stripe API への同じ POST を二重実行しない           │
└────────────────────────────────────────────────────────┘
┌────────────────────────────────────────────────────────┐
│ 第 3 段: UI 連打防止 (ユーザー操作起点)               │
│   ユーザーがボタンを連打しても 1 回だけ送信           │
└────────────────────────────────────────────────────────┘

3 段それぞれが独立して二重課金を防ぎます。1 段だけだと別経路で抜け穴があります。

第 1 段: Webhook 冪等性 (受信側)

Stripe は 同じ event を複数回送ってくる ことがあります (ネットワーク不安定、Stripe 側のリトライ、Webhook endpoint 5xx 等)。これに対し、受信側で dedup table を持って既処理 event を弾きます。

スキーマ

// packages/database/src/schema-central/stripe-webhook-event.ts
export const stripeWebhookEvent = pgTable("stripe_webhook_event", {
  id: text("id").primaryKey(), // Stripe event id (evt_xxx)
  type: text("type").notNull(), // event type
  status: text("status").notNull(), // RECEIVED | PROCESSED | FAILED
  payload: jsonb("payload"), // 元 event (audit 用)
  errorMessage: text("error_message"), // FAILED 時の理由
  createdAt: timestamp("created_at").defaultNow(),
  processedAt: timestamp("processed_at"),
});

ハンドラ

@Post("webhook")
async handleWebhook(@Body() rawBody: Buffer, @Headers("stripe-signature") sig: string) {
  const event = this.stripe.webhooks.constructEvent(rawBody, sig, secret);

  // STEP 1: dedup INSERT (既存なら INSERT スキップ)
  const inserted = await this.db
    .insert(stripeWebhookEvent)
    .values({ id: event.id, type: event.type, status: "RECEIVED", payload: event })
    .onConflictDoNothing()
    .returning();
  if (inserted.length === 0) {
    // 既処理 event → 何もしない
    return { received: true, deduped: true };
  }

  // STEP 2: 実処理
  try {
    await this.processEvent(event);
    await this.db.update(stripeWebhookEvent)
      .set({ status: "PROCESSED", processedAt: new Date() })
      .where(eq(stripeWebhookEvent.id, event.id));
  } catch (err) {
    await this.db.update(stripeWebhookEvent)
      .set({ status: "FAILED", errorMessage: String(err) })
      .where(eq(stripeWebhookEvent.id, event.id));
    throw err; // Stripe にリトライさせる
  }
  return { received: true };
}

ポイント:

  • ON CONFLICT DO NOTHING で event id 単位で dedup
  • 既処理なら inserted.length === 0 で early return
  • 実処理失敗時は status=FAILED にしつつ 500 を返して Stripe にリトライさせる
  • リトライ時は RECEIVED 状態の row が残るので、再処理は INSERT 失敗で skip → 別途手動復旧

第 2 段: API idempotencyKey (送信側)

Stripe API の idempotencyKey パラメータは ほぼすべての書き込み(POST)リクエストで利用できます。これにより、ネットワーク失敗で再試行しても同じ操作が 2 回実行されることを防げます。

命名規約: {operation}-{naturalKey}-{粒度}

// プラン変更
await stripe.subscriptions.update(
  subscriptionId,
  {
    items: [{ id: itemId, price: newPriceId }],
    proration_behavior: "always_invoice",
  },
  { idempotencyKey: `sub-update-plan-${subscriptionId}-${newPriceId}` },
);

// 数量変更 (seat 増減)
await stripe.subscriptions.update(
  subscriptionId,
  {
    items: [{ id: itemId, quantity: newQuantity }],
    proration_behavior: "always_invoice",
  },
  { idempotencyKey: `sub-update-qty-${subscriptionId}-${newQuantity}` },
);

// 解約
await stripe.subscriptions.update(
  subscriptionId,
  { cancel_at_period_end: true },
  { idempotencyKey: `sub-cancel-${subscriptionId}` },
);

ポイント:

  • key に naturalKey (subscriptionId) と 粒度 (newPriceId / newQuantity) を含める
  • 同じ key で再送 → Stripe は最初の response をそのまま返す (新しい操作はされない)
  • key 衝突 (異なる操作で同じ key) を避けるため、operation を prefix に明示

私たちのケースでは、Customer Portal の起動、Subscription 作成、Invoice 発行など、書き込み系の呼び出しに idempotencyKey を追加しました。grep で .create(\|.update( を抽出して付与していく作業です。

注意点: 24 時間 TTL

Stripe の idempotencyKey24 時間で expire します。それを超えた再送は別操作扱いになるので、24 時間以上待ってからの操作は要注意です (通常の UI フローでは問題なし)。

第 3 段: UI 連打防止 (ユーザー操作起点)

ユーザーがプラン変更ボタンを連打した場合、第 1・2 段の防御があっても 不要な API call が増えるだけ なので、UI 側でも防御します。

function PlanChangeDialog() {
  const [changingTo, setChangingTo] = useState<string | null>(null);

  async function handleChange(targetPlan: string) {
    if (changingTo !== null) return; // 連打防止
    setChangingTo(targetPlan);
    try {
      await api.post("/subscription/change-plan", { plan: targetPlan });
      toast.success("プラン変更しました");
    } catch (err) {
      toast.error("エラーが発生しました");
    } finally {
      setChangingTo(null);
    }
  }

  return (
    <Button disabled={changingTo !== null} onClick={() => handleChange("PRO")}>
      {changingTo === "PRO" ? "変更中..." : "PRO に変更"}
    </Button>
  );
}

ポイント:

  • changingTo が non-null なら button disabled
  • 同じ button だけでなく、他の plan ボタンも全部 disable (同時複数操作を防ぐ)
  • try { } finally { setChangingTo(null) } で例外時もリセット

私たちは Web (Next.js) / Desktop (Electron) / Mobile (React Native) の 3 アプリすべてで同じパターンを適用しています。

なぜ 3 段必要か (1 段では不十分)

第 1 段だけの場合の抜け穴

UI から API call → ネットワーク失敗で client 再送 → server で 2 つの異なる Stripe API call が走る → Stripe webhook が 2 つ別 event で返ってくる → 第 1 段は dedup できない (event id が異なる) → 二重課金が発生します。

第 1 + 2 段でも残る抜け穴

UI から API call → 完了 → 即座にユーザーが「もう一度押す」(レスポンス遅延に苛立った場合) → server で同じ subscriptionId に対して 2 つの異なる targetPlan で update → idempotencyKey が異なるので 2 回実行 → 二重課金が発生します。

第 3 段が必要

UI 側で changingTo !== null の間ボタン disabled なら、ユーザーが連打しても 2 回目のリクエストは飛ばない。3 段揃って初めて完全防御

まとめ

防御対象実装
第 1 段: Webhook 冪等性Stripe からの event 二重配信stripe_webhook_event table + ON CONFLICT
第 2 段: API idempotencyKeyserver からの API 二重実行${operation}-${naturalKey}-${粒度} key
第 3 段: UI 連打防止ユーザーの連打操作changingTo !== null で button disabled

3 段を組み合わせることで、ネットワーク失敗 / Stripe リトライ / ユーザー連打のいずれのシナリオでも二重課金を防ぎやすくなります。

サブスク SaaS を開発される方は、ぜひこのパターンを参考にしてください。1 段だけでも防げそうに思えますが、現場では想定外の経路で抜けが発生しがちです。3 段揃えるのが安全だと考えています。