# StripeのWebhookと冪等性を本番品質で実装する：署名検証・順不同/少なくとも1回配信耐性・サブスク状態機械

> Stripe公式ドキュメントに忠実な、決済の「壊れない」実装ガイド。Idempotency-Keyによる冪等なAPI呼び出し、Webhookの署名検証（raw body必須）と二重配信・順不同への耐性、サブスクのライフサイクルを状態機械として設計する手法、Stripe CLIでの検証までをTypeScriptの動くコードで解説します。

- 公開日: 2026-06-24
- 著者: 友田 陽大
- タグ: Stripe, TypeScript, B2B SaaS, アーキテクチャ設計, Next.js, 決済
- URL: https://tomodahinata.com/blog/stripe-payments-production-guide-webhooks-idempotency-subscriptions

## 要点

- 決済を壊さない不変条件は『二度実行しない・偽リクエストを信じない・状態を巻き戻さない』の3つに集約される
- 冪等性は二層。Stripe への POST は操作から決定的に導出した Idempotency-Key、自分側は event.id の一意制約で重複排除する
- Webhook 署名検証は必ず raw body を constructEvent に渡す。手前のパーサにボディをいじらせないことが落とし穴回避の鍵
- Webhook は順不同・少なくとも1回配信が前提。event.created で巻き戻りを弾き、先に2xx を返して重い処理は非同期化する
- サブスクは状態機械として一箇所に畳み、past_due は督促・unpaid は即剥奪と分けて Union 型＋網羅検査で固める

---

「Stripeはドキュメント通りに繋げば動く」——半分は本当で、半分は罠です。チュートリアル通りのコードは**晴れた日には動きます**。本番で壊れるのは、雨の日——ネットワークが切れ、Webhookが二重に届き、イベントが順不同で到着し、ユーザーがブラウザを閉じた瞬間です。決済は「動く」だけでは不十分で、**重複しても・順序が狂っても・途中で落ちても、お金の状態が壊れない**ことが要件です。

この記事は、Stripeを**再利用可能な本番品質**で実装するための実装ガイドです。すべてのコードと挙動は[Stripe公式ドキュメント](https://docs.stripe.com)に忠実に裏付け、公式より**判断軸（いつ・どう・なぜ）を厚く**しています。私自身、金融リテラシー教育の[サブスク学習プラットフォーム](/case-studies/subscription-learning-platform)で、Stripe Webhookの冪等化・順序保証・PII墨消し・銀行振込サブスクの状態機械を Next.js 16 モノレポに実装し、METI受賞の林業DX SaaSでは Stripe Connect による B2B サブスクを担当しました。その実装の「なぜそう書くか」を、ここに一般化して残します。

> **基準バージョン**：Stripe Node SDK（`stripe` パッケージ）の現行系列。`stripe-node v12` 以降、SDKは**リリース時点のAPIバージョンに自動ピン留め**され、TypeScript型がそのAPIバージョンと整合します（[公式: API versioning](https://docs.stripe.com/api/versioning)）。執筆時点の最新APIバージョンは `2026-05-27.dahlia`。本記事のコードはこの前提です。

> **この記事の射程**：これは「Stripeを正しく実装する一般論」です。特定リポジトリの解剖ではありません。実プロダクトで6系統のマルチチャネル課金・代理店コミッション・型安全規律をどう畳んだかは、姉妹記事[サブスク学習プラットフォームのアーキテクチャ徹底解剖](/blog/subscription-platform-billing-idempotency-type-safety)に書きました。重複は避け、ここでは**原理と再利用できる型**に集中します。

---

## 0. 全体像：決済を壊さない「3つの不変条件」

Stripe実装の難所は、機能の多さではなく**分散システムとしての性質**にあります。あなたのサーバーとStripeは別々のマシンで、その間のネットワークは信頼できません。だから守るべき不変条件は、突き詰めると3つです。

| 不変条件 | 何が脅かすか | 守る手段 | 本記事の章 |
| --- | --- | --- | --- |
| **同じ操作を二度実行しない** | ネットワーク再送・Webhook二重配信 | `Idempotency-Key` ＋ 自前の `event.id` 重複排除 | §1・§3 |
| **偽のリクエストを信じない** | 第三者による偽装Webhook・改ざん金額 | 署名検証（raw body）＋ 金額はサーバーで決定 | §2・§7 |
| **状態を巻き戻さない** | イベントの順不同到着 | 状態機械 ＋ Stripeを真実源に照合 | §4・§5 |

この3つを最初から設計に織り込むと、Stripe実装は驚くほど素直になります。以下、順に見ます。

---

## 1. なぜ決済は冪等でなければならないのか

### 1-1. ネットワークは「少なくとも1回」しか保証しない

あなたのサーバーが `stripe.paymentIntents.create()` を呼び、Stripeが課金を実行し、レスポンスを返す——その**レスポンスがネットワークの途中で消えたら**どうなるか。あなたのコードはタイムアウトを見て「失敗した」と判断し、リトライします。結果、**同じ顧客に二度課金**されます。

これは例外ではなく、分散システムの常態です。HTTPは「リクエストが届いたか」を確実には教えてくれない。だから本番の決済コードは「**1回だけ実行されること（exactly-once）は保証できない。だが、何度実行しても結果は1回分にする（idempotent）**」という発想で書きます。

### 1-2. HOW：StripeへのPOSTには `Idempotency-Key` を付ける

Stripeはこの問題のために[冪等リクエスト](https://docs.stripe.com/api/idempotent_requests)を用意しています。POSTリクエストに一意なキーを添えると、**Stripeが最初のリクエストの結果（ステータスコードとボディ）を保存し、同じキーの再送には保存済みの結果を返す**——課金は1回しか起きません。

Node SDKでは第2引数（リクエストオプション）に `idempotencyKey` を渡します。

```ts
import Stripe from "stripe";
import { randomUUID } from "node:crypto";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

// ❌ 危険：再送で二重課金しうる
await stripe.paymentIntents.create({ amount: 5000, currency: "jpy", customer });

// ✅ 安全：同じキーの再送は1回分として吸収される
await stripe.paymentIntents.create(
  { amount: 5000, currency: "jpy", customer },
  { idempotencyKey: idempotencyKeyForThisOperation }
);
```

公式の挙動で押さえるべき点：

- **POSTにのみ有効**。GET/DELETEは定義上すでに冪等なのでキー不要。
- キーは**最低24時間**保存される。リトライはその窓内で行う。
- 保存された結果は**成功・失敗を問わず返る**（`500` も再現される）。だから「前回500だったから今回は通るはず」と期待してはいけない。失敗を恒久化したくないリトライは、**キーを変える**判断が必要。
- 同じキーで**パラメータが異なると Stripe はエラー**を返す（誤用の検出）。
- キーは **V4 UUID など十分なエントロピーを持つランダム文字列**（最大255文字）。**メールアドレスなど機微情報や推測可能な値を使わない**。

### 1-3. WHY：キーは「操作」に紐づける、リクエストごとに生成しない

ここがチュートリアルが教えない肝です。`idempotencyKey: randomUUID()` を**呼び出しのたびに新規生成したら、冪等性は無意味**になります。再送のたびに別キーになり、別の課金として通ってしまうからです。

キーは「**この業務操作を一意に表す決定的な値**」にします。たとえば「注文 `order_123` への課金」なら、`charge:order_123:v1` のように**操作のアイデンティティから導出**します。こうすると、同じ注文への課金は何度叩いても1回。私のサブスク基盤では、コミッション台帳の冪等キーを `scope:期間:通貨:revision` で決定的に組み立てています（[詳細](/blog/subscription-platform-billing-idempotency-type-safety)）。同じ原理です。

> AWS（DynamoDB条件付き書き込み）で冪等性を実装した事例は別記事[DynamoDBで決済の冪等性とゼロダウンタイムを実現する](/blog/dynamodb-payment-reliability-idempotency-zero-downtime)に書きました。Stripe側の冪等性（このキー）と、**自分のDB側の冪等性**は別レイヤーであり、両方必要です。次章でその「自分側」を扱います。

---

## 2. Webhook署名検証を正しくやる（raw bodyが全て）

### 2-1. なぜ署名検証が必須か

Webhookエンドポイントは**インターネットに公開されたPOST受け口**です。何の検証もなければ、第三者が `checkout.session.completed` を**偽装して投げ込み、無料でプロダクトを有効化**できてしまいます。だからStripeは全Webhookに署名を付け、あなたは**それを検証してから処理**します。

### 2-2. HOW：`stripe.webhooks.constructEvent` に raw body を渡す

[公式の署名検証](https://docs.stripe.com/webhooks/signature)が要求するのは3つの引数です。

1. **raw（未加工）のリクエストボディ** — パース前のバイト列。フレームワークがJSONに変換した値は**使えない**。
2. **`Stripe-Signature` ヘッダ** — `t=タイムスタンプ,v1=署名` の形式。
3. **エンドポイントシークレット** — `whsec_` で始まる値。環境変数で持つ。

最大の落とし穴は**raw body要件**です。多くのフレームワークは受信ボディを自動でJSONパースし、空白や順序を変えます。**パース済みの値で署名を検証すると必ず失敗**します。署名はバイト列に対して計算されているからです。

#### Express の場合

Webhookルートだけ `express.raw()` を使い、**`express.json()` より前に**置きます（[公式 quickstart](https://docs.stripe.com/webhooks/quickstart)）。

```ts
import express from "express";
import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET!;
const app = express();

// このルートだけ raw Buffer で受ける（express.json() より前）
app.post(
  "/webhook",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const signature = req.headers["stripe-signature"];
    let event: Stripe.Event;
    try {
      event = stripe.webhooks.constructEvent(
        req.body, // ← Buffer のまま渡す。JSON.parse してはいけない
        signature as string,
        endpointSecret
      );
    } catch (err) {
      // 署名不一致＝偽装か設定ミス。即 400 を返して処理しない
      console.warn("Webhook signature verification failed.", (err as Error).message);
      return res.sendStatus(400);
    }

    // ここから先は「Stripe が確かに送った」と信頼できる
    res.json({ received: true }); // ★ まず素早く 2xx（理由は §3）
    enqueue(event); // 重い処理は非同期キューへ
  }
);

app.use(express.json()); // 他ルートは通常どおりJSON
```

#### Next.js App Router（Route Handler）の場合

Next.jsでは `await req.text()` で**生テキストを取得**します。`req.json()` を使うとパース済みになり検証に失敗します。

```ts
// app/api/stripe/webhook/route.ts
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET!;

export async function POST(req: NextRequest) {
  const body = await req.text(); // ★ raw 文字列。req.json() ではない
  const signature = req.headers.get("stripe-signature");
  if (signature === null) return NextResponse.json({ error: "no signature" }, { status: 400 });

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(body, signature, endpointSecret);
  } catch (err) {
    console.warn("Webhook signature verification failed.", (err as Error).message);
    return NextResponse.json({ error: "invalid signature" }, { status: 400 });
  }

  await handleStripeEvent(event); // 後述の冪等ハンドラ
  return NextResponse.json({ received: true });
}
```

> **WHY raw body**：`constructEvent` は受け取ったバイト列とシークレットからHMACを再計算し、`Stripe-Signature` の `v1=` と突き合わせます。1バイトでも変われば一致しません。「なぜか本番だけ署名検証が落ちる」の9割は、**手前のミドルウェアがボディをいじっている**ことが原因です。

---

## 3. 黄金律：Webhookは「順不同・少なくとも1回」

ここが本記事で最も重要な章です。Stripe公式が明言する[Webhookの配信セマンティクス](https://docs.stripe.com/webhooks)を、設計の前提として腹に落とす必要があります。

> **At-least-once（少なくとも1回）**：「Webhookエンドポイントは、同じイベントを複数回受信する可能性があります」。本番では指数バックオフで**最大3日間**リトライされます。
>
> **順序保証なし**：「Stripeは、イベントが生成された順序で配信されることを保証しません」。サブスク作成は `customer.subscription.created` → `invoice.created` → `invoice.paid` の順で**生成**されても、**到着は前後しうる**。

つまりあなたのハンドラは、**(a) 同じイベントが二度来ても安全（冪等）** で、**(b) 古いイベントが新しいイベントの後に来ても状態を壊さない（順序非依存）** でなければなりません。

### 3-1. (a) 自前の冪等性：`event.id` で重複排除

§1のStripe側冪等性とは別に、**Webhook処理側でも**重複を弾きます。公式の推奨はシンプルです——「**処理した[イベントID](https://docs.stripe.com/api/events/object#event_object-id)を記録し、すでに記録済みのイベントは処理しない**」。

`event.id`（`evt_...`）に**ユニーク制約**を張り、挿入できたら初回、衝突したらスキップ。「先に記録、後で処理」をアトミックにやるのが要点です。

```ts
// 受信イベントを一意制約で記録 → 初回だけ処理する
async function handleStripeEvent(event: Stripe.Event): Promise<void> {
  // INSERT が衝突したら過去に処理済み＝何もしない（冪等）
  const isFirstTime = await tryRecordEvent(event.id, event.type, event.created);
  if (!isFirstTime) {
    console.info(`duplicate event skipped: ${event.id}`);
    return;
  }
  await dispatch(event);
}

// 例：Prisma。stripeEventId に @unique を張っておく
async function tryRecordEvent(id: string, type: string, created: number): Promise<boolean> {
  try {
    await prisma.stripeWebhookEvent.create({
      data: { stripeEventId: id, eventType: type, eventCreated: created },
    });
    return true;
  } catch (e) {
    if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") {
      return false; // 一意制約違反＝二重配信
    }
    throw e;
  }
}
```

> **WHY DB制約に頼るか**：アプリ層で「SELECTして無ければINSERT」とやると、二重配信が**同時に**届いたとき両方がSELECTで「無い」と判断し、二重処理が起きます（TOCTOU）。**一意制約は競合をDBが原子的に裁く**ので、レースに強い。これは姉妹記事で `StripeWebhookEvents.stripeEventId @unique` として実装した第1層と同じ考え方です。

### 3-2. (b) 順序非依存：`event.created` で巻き戻りを弾く

二重排除だけでは**順序問題**は解けません。`customer.subscription.updated` が2回飛び、新しい方が先に処理された後で**古い方が到着**したら、サブスク状態が古い値に巻き戻ります。

対策は、対象レコードに「**最後に処理したイベントの `created` 時刻**」を持ち、それより古いイベントを捨てること。

```ts
async function applySubscriptionUpdate(sub: Stripe.Subscription, eventCreated: number) {
  const current = await prisma.subscription.findUnique({ where: { stripeId: sub.id } });

  // 既存より古いイベントなら無視（巻き戻り防止）
  if (current && current.lastEventCreated !== null && eventCreated < current.lastEventCreated) {
    console.info(`stale event ignored for ${sub.id} (created=${eventCreated})`);
    return;
  }

  await prisma.subscription.upsert({
    where: { stripeId: sub.id },
    create: { stripeId: sub.id, status: sub.status, lastEventCreated: eventCreated },
    update: { status: sub.status, lastEventCreated: eventCreated },
  });
}
```

### 3-3. 究極の安全網：Stripeを真実源に「照合」する

Webhookは**通知**であって**真実そのものではない**。本当に重要な状態（サブスクが有効か）は、疑わしければ**StripeのAPIから取り直す**のが最も堅い。Webhookは「変化があった」という合図に使い、確定はAPIで照合します。

```ts
// Webhook は「見に行くきっかけ」。確定値は Stripe から取得
async function reconcileSubscription(subscriptionId: string) {
  const fresh = await stripe.subscriptions.retrieve(subscriptionId);
  await applySubscriptionUpdate(fresh, fresh.created);
}
```

> **コスト注意**：照合は追加のAPI呼び出しです。**毎イベントで叩くと無駄**。基本はWebhookのペイロードで処理し、**矛盾を検知したときや、入金確定など金額が動く瞬間だけ**照合する、というメリハリが現実解です（可観測性 ＋ コスト効率）。

### 3-4. なぜ「素早く2xx → 非同期処理」なのか

公式は「**タイムアウトを起こしうる複雑なロジックの前に、素早く2xxを返せ**」と明言します。理由は2つ。

1. **2xxが遅いとStripeはリトライ**する（成功が届かないと失敗扱い）。重い処理を同期でやると、タイムアウト→リトライ→二重配信を**自分で誘発**する。
2. **月初の一斉更新**などWebhookが急増すると、同期処理ではエンドポイントが詰まる。公式推奨は「**受信イベントを非同期キューで処理**」。

設計としては「**署名検証 → `event.id` 記録 → 2xx即返し → 重い処理はキュー/バックグラウンド**」。サーバーレス（Vercel等）なら、軽い処理はその場で、重い処理は専用のジョブ/Queueに逃がします。

---

## 4. サブスクのライフサイクルは「状態機械」として扱う

サブスクの `status` を `if (status === "active")` であちこち散らすと、**分岐の取りこぼし**が必ず事故になります。正しくは、Stripeが定義する[サブスクリプションのステータス](https://docs.stripe.com/billing/subscriptions/overview)を**状態機械**として一箇所に畳み込み、「各状態でアクセス権をどうするか」を表で固定します。

| status | 意味（公式） | プロダクトのアクセス権 | やること |
| --- | --- | --- | --- |
| `trialing` | トライアル期間中。支払い前でも利用可。初回支払いで `active` へ。 | **付与** | フル機能を開放 |
| `active` | 正常。必要な支払いは完了済み。 | **付与** | フル機能を開放 |
| `incomplete` | 初回支払いが23時間以内に必要（3DS等の認証待ち含む）。 | **保留** | まだ開放しない。完了を待つ |
| `incomplete_expired` | 初回支払いが23時間以内に完了せず失効。課金なし。 | **不可** | 新規サブスク作成が必要 |
| `past_due` | 直近invoiceの支払いが失敗/未試行。invoiceは生成され続ける。リトライ継続中。 | **制限/通知** | 督促。Smart Retriesで回収を試みる |
| `unpaid` | 直近invoiceが未払い。**リトライは出し尽くした**。 | **即時剥奪** | アクセスを止める |
| `canceled` | 解約済み。**終端・不可逆**。 | **剥奪** | エンタイトルメント整理 |
| `paused` | トライアル終了時に支払い方法が無く、`pause` 設定の場合。invoice生成なし。 | **保留** | 支払い方法追加→再開を待つ |

公式が特に強調する判断軸：

- **アクセスを付与してよいのは `active` か `trialing` のときだけ**。`incomplete` で先に開放すると、支払い未完了のまま使われる。
- **`past_due` と `unpaid` は別物**。`past_due` はまだリトライ中（猶予を持って通知）。`unpaid` は**リトライ枯渇**なので**即剥奪**。ここを混同すると、踏み倒しか、逆に正当な顧客の早すぎる遮断を招く。
- **`canceled` は不可逆**。再加入には新しいサブスクが必要で、解約済みを再利用はできない。

これを型で固めます。Union型 ＋ 網羅検査で「新ステータス追加時に分岐漏れがコンパイルで落ちる」状態にします（`enum` を避ける理由は[姉妹記事の `NeverError`](/blog/subscription-platform-billing-idempotency-type-safety) を参照）。

```ts
type Access = "grant" | "restrict" | "revoke" | "pending";

// status → アクセス権 の写像を1箇所に集約（状態機械）
function accessForStatus(status: Stripe.Subscription.Status): Access {
  switch (status) {
    case "trialing":
    case "active":
      return "grant";
    case "incomplete":
    case "paused":
      return "pending";
    case "past_due":
      return "restrict";
    case "incomplete_expired":
    case "unpaid":
    case "canceled":
      return "revoke";
    default:
      // status が増えてここを更新し忘れると型エラーになる（網羅保証）
      return assertNever(status);
  }
}

function assertNever(value: never): never {
  throw new Error(`unhandled subscription status: ${String(value)}`);
}
```

### 4-1. どのイベントで何を更新するか

サブスク運用で実際に効くイベントは限られます。

```ts
async function dispatch(event: Stripe.Event): Promise<void> {
  switch (event.type) {
    case "checkout.session.completed":
      // Checkout完了。subscriptionモードなら subscription を取得して反映
      await onCheckoutCompleted(event.data.object as Stripe.Checkout.Session);
      break;
    case "customer.subscription.created":
    case "customer.subscription.updated":
    case "customer.subscription.deleted":
      // 状態機械を回す唯一の入口に集約
      await applySubscriptionUpdate(
        event.data.object as Stripe.Subscription,
        event.created
      );
      break;
    case "invoice.paid":
      // 継続課金の成功。利用期間を延長
      await extendEntitlement(event.data.object as Stripe.Invoice);
      break;
    case "invoice.payment_failed":
      // ★ ここを無視するのが最頻出の事故。督促(dunning)を起動
      await startDunning(event.data.object as Stripe.Invoice);
      break;
    default:
      // 未知イベントも 2xx で受ける（400 を返すとリトライ地獄）
      console.info(`unhandled event type: ${event.type}`);
  }
}
```

> **WHY `invoice.payment_failed` を必ず扱うか**：カードは**普通に期限切れ・残高不足になります**。これを無視すると `past_due` のユーザーが放置され、`unpaid`→`canceled` まで黙って進む。督促メール（dunning）と段階的なアクセス制限を起動するのがこのイベントの役割です。私のサブスク基盤では、銀行振込側の督促を「送信済み集合」で冪等化して二重送信を防いでいます。

---

## 5. CheckoutとCustomer Portal：難所はStripeにホストさせる

決済UIを自前で組むのは、PCI準拠・3DS・各種決済手段対応を**全部自分で背負う**ことを意味します。**Stripe Checkout** と **Customer Portal** は、その「難しい部分」をStripe側にホストさせる仕組みです。まずこれで足りないか検討するのが正解です。

### 5-1. Checkout vs Payment Intents 直接実装

| 観点 | Stripe Checkout（ホスト型） | Payment Intents 直接（Elements等） |
| --- | --- | --- |
| 実装コスト | **低**（リダイレクトするだけ） | 高（UI・状態・エラーを自前） |
| PCI準拠負荷 | **最小（SAQ A）** | 大きい |
| 3DS / 各種決済手段 | **Stripeが自動対応** | 自前で対応 |
| UIの自由度 | 中（テーマ程度） | **高** |
| 向き | 大半のSaaS課金・サブスク | 独自の決済体験が必須なとき |

判断はシンプル。**特別な決済体験が要件でない限り、Checkoutを選ぶ**。自由度のために本質的でない複雑さを抱える理由はありません。

### 5-2. Checkout Session の作成

[公式 quickstart](https://docs.stripe.com/checkout/quickstart) のとおり、`stripe.checkout.sessions.create` で生成し、`session.url` へリダイレクトします。**サブスクなら `mode: "subscription"`**、買い切りなら `mode: "payment"`。`line_items` には**サーバーで管理する Price ID** を渡します（金額を直接渡さない理由は §7）。

```ts
// app/api/checkout/route.ts（サーバー側でのみ実行）
export async function POST(req: NextRequest) {
  const customerId = await requireCustomerId(); // 認証済みユーザー → Stripe Customer
  const session = await stripe.checkout.sessions.create({
    mode: "subscription",
    customer: customerId,
    line_items: [{ price: process.env.STRIPE_PRICE_PRO_MONTHLY!, quantity: 1 }],
    success_url: `${process.env.APP_URL}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: `${process.env.APP_URL}/billing/cancel`,
    // セッションに自分のユーザーIDを紐づけ、Webhookで突合できるようにする
    client_reference_id: await currentUserId(),
  });
  return NextResponse.json({ url: session.url });
}
```

完了は**リダイレクト先のページではなく、必ずWebhookの `checkout.session.completed` で確定**します。理由は、ユーザーが `success_url` に着く前にブラウザを閉じても、課金は成立しているから。**フルフィルメント（プロビジョニング）はWebhookに寄せる**のが公式の作法です。

### 5-3. Customer Portal：解約・支払い方法変更を丸ごと任せる

[Customer Portal](https://docs.stripe.com/customer-management) は、支払い方法の更新・プラン変更・解約・請求書の閲覧を**Stripeホストの画面**で顧客にセルフサービスさせます。自分で作ると地獄の「解約フロー」を、設定だけで用意できます。

```ts
const portal = await stripe.billingPortal.sessions.create({
  customer: customerId,
  return_url: `${process.env.APP_URL}/billing`,
});
// portal.url へリダイレクト。セッションは5分で失効する点に注意
```

> Portalで解約や変更が起きると、結局 `customer.subscription.updated/deleted` がWebhookで飛んできます。**真実源はあくまでWebhook＋API**で、Portalは入口にすぎない——この一貫性が保てると設計が崩れません。

---

## 6. テスト：Stripe CLIでローカルにイベントを流す

決済は「手で本番を叩いて確認」が許されない領域です。[Stripe CLI](https://docs.stripe.com/stripe-cli) で、ローカルにWebhookを転送し、イベントを意図的に発火させて検証します。

```bash
# 1) ログイン（ブラウザで認可）
stripe login

# 2) Stripe から localhost へ Webhook を転送
#    起動時に whsec_ で始まる「署名シークレット」が表示される
stripe listen --forward-to localhost:3000/api/stripe/webhook
# > Ready! Your webhook signing secret is whsec_xxxxxxxx

# 3) 表示された whsec_ を STRIPE_WEBHOOK_SECRET に設定して dev を起動

# 4) 任意のイベントを発火させて、ハンドラを叩く
stripe trigger checkout.session.completed
stripe trigger invoice.payment_failed
stripe trigger customer.subscription.updated
```

`stripe listen` が出す `whsec_` は**ローカル専用のシークレット**で、本番ダッシュボードのものとは別です。これを使うことで、本物の署名検証パスを**ローカルでそのまま検証**できます（検証だけ無効化する、という手抜きを避けられる）。

### テストカード（[公式 testing](https://docs.stripe.com/testing)）

| カード番号 | シミュレートする挙動 |
| --- | --- |
| `4242 4242 4242 4242` | 成功（Visa） |
| `4000 0000 0000 9995` | 残高不足で拒否（declined） |
| `4000 0025 0000 3155` | 3DS認証が必要（要オーセンティケーション） |
| `4000 0000 0000 0341` | カード登録は成功するが、後続の課金が失敗 |

> `4000 0025 0000 3155` で **`incomplete` → 認証完了 → `active`** の遷移を、`4000 0000 0000 9995` で **`invoice.payment_failed` → `past_due`** の遷移を、実際にローカルで再現してから本番に出す。状態機械（§4）の各分岐は、こうして**観測可能な形で検証**できます。

---

## 7. セキュリティ：クライアントを信じない

決済は攻撃対象です。原則は一つ——**クライアントから来る値を、お金に関わる判断に使わない**。

### 7-1. 金額はクライアントから受け取らない

最も危険なアンチパターンが、**フロントが送ってきた金額でCheckout/課金を作る**ことです。攻撃者はリクエストを改ざんし、`amount: 100`（1円）でPro版を買えてしまう。

```ts
// ❌ 致命的：クライアントの金額を信じる
await stripe.checkout.sessions.create({
  line_items: [{ price_data: { unit_amount: body.amount, /* ... */ }, quantity: 1 }],
});

// ✅ 正：サーバーが持つ Price ID を使う。価格はStripe側で確定
await stripe.checkout.sessions.create({
  line_items: [{ price: PRICE_IDS[body.plan], quantity: 1 }], // planは許可リストで検証
});
```

価格は**Stripeダッシュボード/APIでPriceとして作成し、コードはPrice IDだけを参照**する。クライアントが送ってよいのは「どのプランか」という**識別子だけ**で、その識別子も許可リストで検証します。

### 7-2. 境界はZodで検証して型を絞る

外部入力（APIリクエスト、Webhookペイロード）は**境界でZod検証**し、内側には信頼できる型だけを流します。

```ts
import { z } from "zod";

const checkoutInput = z.object({
  plan: z.enum(["pro_monthly", "pro_yearly"]), // 許可リスト＝改ざん耐性
});

export async function POST(req: NextRequest) {
  const parsed = checkoutInput.safeParse(await req.json());
  if (!parsed.success) return NextResponse.json({ error: "bad request" }, { status: 400 });
  const priceId = PRICE_IDS[parsed.data.plan]; // 既知の値だけが通る
  // ...
}
```

### 7-3. 鍵は最小権限・PIIは残さない

- **APIキーは環境変数**に。`STRIPE_SECRET_KEY` は**サーバー専用**（クライアントバンドルに絶対に出さない）。署名シークレット `STRIPE_WEBHOOK_SECRET` も同様。
- 用途を限るなら**制限付きキー（restricted API keys）**で最小権限に絞る（読み取り専用のサービスにフル権限の鍵を渡さない）。
- Webhookの生ペイロードには**カード・メール・住所などのPIIが含まれる**。デバッグ用に保存するなら、**保存前にPIIを再帰的に墨消し**する。私のサブスク基盤では `billing_details`/`card`/`email` などのキーを `<redacted>` に置換してから保存しています（[実装](/blog/subscription-platform-billing-idempotency-type-safety)）。
- ログには `event.id` や `event.type` といった**追跡に必要なタグだけ**を出し、PIIや金額の生データを垂れ流さない。

---

## 8. よくある落とし穴

実装レビューで実際に頻出するものを、影響度順に。

- **❌ パース済みJSONで署名検証する** → 必ず失敗、もしくは検証を諦めて無効化（最悪）。raw body（`req.text()` / `express.raw()`）を使う（§2）。
- **❌ クライアントが送った金額で課金する** → 価格改ざんで赤字。Price IDをサーバーで持つ（§7-1）。
- **❌ `event.id` の重複排除をしない** → 二重配信で二重プロビジョニング・二重課金。一意制約で弾く（§3-1）。
- **❌ `invoice.payment_failed` を無視する** → 失効を放置し、督促もされず黙って解約まで進む（§4-1）。
- **❌ 順序を仮定する** → 古い `subscription.updated` で状態が巻き戻る。`event.created` で弾く（§3-2）。
- **❌ 重い処理を同期でやってから2xx** → タイムアウト→Stripeがリトライ→二重配信を自分で誘発。先に2xx、後で非同期（§3-4）。
- **❌ `success_url` のページでフルフィルメントする** → ユーザーが離脱すると未プロビジョニング。確定はWebhookで（§5-2）。
- **❌ 未知イベントに400を返す** → リトライが3日間続く。未対応イベントも2xxで受ける（§4-1）。
- **❌ `idempotencyKey` を毎回ランダム生成** → 冪等性が無効化。操作のアイデンティティから決定的に導出（§1-3）。

---

## 9. 本番運用の勘所（横断的設計）

機能と同じ優先度で、運用面を作り込みます。

- **可観測性**：すべてのWebhook処理で `event.id` / `event.type` / 処理結果をログ化。**ハンドラの失敗はアラート**に繋ぐ（決済の失敗を黙殺しない）。「どのイベントで状態がおかしくなったか」を後から追えることが、決済デバッグの生命線です。
- **回復性**：Stripe側は最大3日リトライしてくれる。あなた側も、ハンドラ内の一時的失敗（DB一時エラー等）は**5xxを返してリトライに乗せる**、恒久的失敗（不正データ）は**2xx＋記録**でリトライ地獄を避ける、と**返すコードで意図を表す**。処理しきれないイベントはデッドレターに退避し、人が拾える状態にする。
- **型安全**：`Stripe.Event` を `switch (event.type)` で**ナローイング**し、`event.data.object` を各イベントの型へ絞る。境界はZod。`as` の乱用は型の嘘になるので避ける。
- **コスト効率**：`reconcile`（Stripeへの再取得）は**必要な瞬間だけ**。毎イベントで叩かない。冪等記録のテーブルは**TTLで古いイベントを掃除**してストレージを抑える（Stripe側のキー保存が24時間なので、それ以上保持しても照合用途では不要なことが多い）。

---

## まとめ：Stripeは「壊れない前提」で設計すると素直になる

Stripeを本番品質で実装するとは、**分散システムの不確実性を設計で吸収する**ことです。要点を5行で。

1. **冪等性は二層で**。Stripeへは `Idempotency-Key`（操作から決定的に導出）、自分側は `event.id` の一意制約。再送で壊れない。
2. **署名検証は raw body で**。`stripe.webhooks.constructEvent` に未加工ボディを渡す。手前のパーサに body をいじらせない。
3. **Webhookは順不同・少なくとも1回**。`event.created` で巻き戻りを弾き、重要な値はStripeを真実源に照合。先に2xx、重い処理は非同期。
4. **サブスクは状態機械**。`active`/`past_due`/`unpaid`/`canceled` を表で固定し、`unpaid` は即剥奪・`past_due` は督促、と分けて型で網羅。
5. **クライアントを信じない**。金額はPrice IDでサーバー確定、境界はZod、鍵は最小権限、PIIは保存前に墨消し。

「動く決済」と「複数人で本番運用に耐える決済基盤」の差は、まさにこうした一つひとつの判断にあります。その実例が、本記事の原理の出どころである[サブスク学習プラットフォーム](/case-studies/subscription-learning-platform)（マルチチャネル課金・冪等な決済・代理店コミッションを Next.js 16 モノレポで構築）です。実装の深掘りは[アーキテクチャ徹底解剖](/blog/subscription-platform-billing-idempotency-type-safety)に、決済の冪等性をAWS側で実現した話は[DynamoDBの記事](/blog/dynamodb-payment-reliability-idempotency-zero-downtime)にあります。

Stripeを使ったサブスク課金・決済信頼性・型安全なドメイン設計を伴う新規開発や立て直しをご検討でしたら、要件定義から実装・運用まで、この水準でお引き受けします。[お問い合わせ](/contact)からお気軽にどうぞ。
