はじめに
サブスク 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 の idempotencyKey は 24 時間で 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 idempotencyKey | server からの API 二重実行 | ${operation}-${naturalKey}-${粒度} key |
| 第 3 段: UI 連打防止 | ユーザーの連打操作 | changingTo !== null で button disabled |
3 段を組み合わせることで、ネットワーク失敗 / Stripe リトライ / ユーザー連打のいずれのシナリオでも二重課金を防ぎやすくなります。
サブスク SaaS を開発される方は、ぜひこのパターンを参考にしてください。1 段だけでも防げそうに思えますが、現場では想定外の経路で抜けが発生しがちです。3 段揃えるのが安全だと考えています。