# Stripe決済を本番品質で実装する完全ガイド（2026年版・公式ドキュメント準拠）：Checkout Sessions・Webhook・冪等性・Connectを実コードで

> Stripe公式ドキュメントに忠実な本番決済の実装ガイド。2026年標準の Checkout Sessions API でホスト型/埋め込み型決済を Next.js 16 に実装し、Webhook を唯一の真実源として冪等に処理、Idempotency-Key と event.id で二重課金を防ぎ、Connect でマーケットプレイスまで対応。本番二重課金0件の実務知見つき。

- 公開日: 2026-06-24
- 著者: 友田 陽大
- タグ: Stripe, 決済, Next.js, TypeScript, Webhook, 冪等性, セキュリティ, B2B SaaS, アーキテクチャ設計
- URL: https://tomodahinata.com/blog/stripe-checkout-sessions-payments-production-guide-2026

## 要点

- 2026年は Checkout Sessions API が公式推奨。UI はホスト型→埋め込み型→Elements の順に検討し YAGNI を効かせる
- 支払い完了の真実源はリダイレクトではなく Webhook。署名検証は生のリクエストボディに対して constructEvent で行う
- 二重課金は冪等性の2層で構造的に根絶する。送信側は決定的な Idempotency-Key、受信側は event.id の一意制約＋created 比較
- 金額はクライアントから受け取らず Price ID かサーバー計算で確定する。クライアントの amount を信じない
- 時間依存ロジックは Test Clocks と Stripe CLI で検証し、マーケットプレイスは Connect の application_fee_amount で組む

---

決済は「動いた」と「本番に出せる」の間が、ソフトウェアの中でもっとも遠い領域です。カード番号を入力して成功画面が出るデモは1日で作れます。しかし**二重課金を1件も出さない**、**ネットワークが切れても整合性が崩れない**、**Webリクエストが改ざんされても金額が守られる**——ここまでやって初めて、お客様のお金を預かる資格があります。

この記事は、**Stripe公式ドキュメントの最新仕様に忠実**でありながら、公式よりも「どの場面で・なぜ・どう使うか」がわかるように書いた実装ガイドです。執筆時点の最新 API バージョンは **`2026-05-27.dahlia`**。サンプルは私の主戦場である **Next.js 16（App Router / RSC）+ TypeScript strict** で示します。

> **この記事の信頼性について**：私は環境分野のサーバーレス決済プラットフォームで決済信頼性レイヤー（冪等性・原子的残高更新・ゼロダウンタイム移行）を設計・主導し、**本番で二重課金0件**を達成しました（リポジトリのコミット約6割＝694中403を担当）。また木材流通B2B SaaS（経済産業大臣賞受賞）では **Stripe Connect** を、金融教育のサブスク基盤では **Stripe 17 のWebhook冪等処理**を本番運用しています。本文の Stripe 仕様はすべて公式ドキュメントで裏取りし、ROIなどの未確認数値は扱いません。

---

以下、それぞれを実コードで深掘りします。

---

## 1. 2026年の地図：どの統合方式を選ぶか

Stripeの統合方式は歴史的に増えてきたため、古い記事だと「PaymentIntentを作って `clientSecret` を Elements に渡す」という**やや旧い前提**で書かれていることが多いです。2026年の公式の推奨は明確に変わっています。

> 公式の表現：「ほとんどの実装には **Checkout Sessions API** を推奨します。Payment Intents は決済フローのすべてを自分で管理し、税・割引・サブスク・通貨換算などの機能を**自前で再構築する場合にのみ**選択してください。」

つまり判断軸は「カスタマイズの自由度」と「自分で背負う実装量・保守量」のトレードオフです。

| 観点 | ホスト型 Checkout | 埋め込み型 Checkout | Elements + Payment Intents |
| --- | --- | --- | --- |
| 実装量 | **最小** | 小 | 大（自前で多くを再構築） |
| UIの自由度 | 低（Stripeページ） | 中（自サイトに枠を埋め込み） | **最大**（完全カスタム） |
| 税・割引・送料・住所収集 | **組み込み** | 組み込み | 手動実装 |
| サブスク作成 | 組み込み | 組み込み | 別途実装 |
| セッション自動失効 | あり（24時間） | あり | なし |
| Webhookイベント | 決済ライフサイクル全体 | 全体 | 決済ステータスのみ |
| PCI負担（自己問診） | **SAQ A（最小）** | SAQ A | SAQ A（要件を満たす実装が必要） |
| 向く場面 | まず作って売る／MVP／請求 | ブランド体験を保ちたいEC | 独自の決済UXが事業の核 |

**意思決定のショートカット：**

- 「とにかく早く・安全に決済を始めたい」→ **ホスト型 Checkout**。
- 「自社サイトから離脱させたくない（ブランド体験を維持したい）」→ **埋め込み型 Checkout**。
- 「決済フォームそのものがプロダクトの差別化要素で、状態管理も自前でやる覚悟がある」→ **Elements + Payment Intents**。

私の実務でも、9割のプロジェクトはホスト型か埋め込み型で十分でした。Payment Intents の生実装が要るのは、複数決済を1画面で束ねる、独自の与信フローを挟む、といった**本当にUXが核**のケースだけです。YAGNI（必要になるまで作らない）を効かせる典型的な分岐点です。

---

## 2. セットアップ：型安全な Stripe クライアントと秘密情報の境界

まず共有クライアント。**API バージョンは必ず固定**します。固定しないと、Stripe側のデフォルト更新でレスポンス形状が変わり、ある日突然壊れる——という再現性のない障害を招きます。

```typescript
// lib/stripe.ts — サーバー専用。"use client" のファイルから絶対に import しない。
import Stripe from "stripe";

const secretKey = process.env.STRIPE_SECRET_KEY;
if (!secretKey) {
  // 起動時に落とす。秘密鍵欠落のまま本番に出さない（フェイルファスト）。
  throw new Error("STRIPE_SECRET_KEY is not set");
}

export const stripe = new Stripe(secretKey, {
  // 執筆時点の最新。アップグレードは差分を確認してから意図的に上げる。
  apiVersion: "2026-05-27.dahlia",
  appInfo: { name: "your-app", version: "1.0.0" }, // サポート時の調査が速くなる
  typescript: true,
});
```

セキュリティ境界は3点だけ守れば事故りません。

- **`STRIPE_SECRET_KEY`（`sk_...`）はサーバーのみ。** 環境変数で注入し、ログにもクライアントバンドルにも出さない。
- **公開可能キー（`pk_...`）だけがブラウザに出てよい。** Next.jsなら `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY`。
- **Webhook署名シークレット（`whsec_...`）はエンドポイントごとに一意。** これも環境変数で。

> Next.jsの落とし穴：`lib/stripe.ts` を Client Component から import すると秘密鍵がバンドルに混入し得ます。Stripeを触るのは Route Handler・Server Action・Server Component に限定し、クライアントには `clientSecret` や `session.url` のような**使い捨ての値だけ**を渡すのが鉄則です。

---

## 3. ホスト型 Checkout：最小コードで本番に出せる決済

最短の本番経路です。サーバーで Checkout Session を作り、Stripeのホストページへリダイレクトします。Next.js 16 の Server Action で書くと、フォームのアクションにそのまま渡せて綺麗です。

```typescript
// app/checkout/actions.ts
"use server";

import { redirect } from "next/navigation";
import { headers } from "next/headers";
import { stripe } from "@/lib/stripe";

export async function createHostedCheckout(priceId: string): Promise<never> {
  const origin = (await headers()).get("origin") ?? "https://example.com";

  const session = await stripe.checkout.sessions.create(
    {
      mode: "payment", // 単発決済。サブスクなら "subscription"
      line_items: [
        // ❶ 金額はクライアントから受け取らない。サーバーが知る Price ID で確定する。
        { price: priceId, quantity: 1 },
      ],
      automatic_tax: { enabled: true }, // 税計算は Stripe に任せる（組み込み）
      success_url: `${origin}/checkout/success?session_id={CHECKOUT_SESSION_ID}`,
      cancel_url: `${origin}/checkout/cancel`,
    },
    // ❷ 冪等キー。二重サブミットでも Checkout Session を二重に作らせない（§6で詳述）
    { idempotencyKey: `checkout:${priceId}:${crypto.randomUUID()}` },
  );

  if (!session.url) throw new Error("Checkout Session has no URL");
  redirect(session.url); // 303 相当でリダイレクト
}
```

ここで一番大事なのは ❶ です。**`line_items` に生の金額（`price_data.unit_amount`）を、しかもクライアント由来の値で渡してはいけません。** ブラウザ側のJSは改ざん可能なので、`amount: 100` を `amount: 1` に書き換えられたら終わりです。Stripeダッシュボードで作った **Price ID（`price_...`）を参照**するか、どうしても動的価格が必要ならサーバー側のDB・計算結果からのみ `unit_amount` を組み立てます。これは OWASP でいう「クライアントを信頼しない」の決済版です。

クライアントは普通のフォームでよく、JavaScriptすら最小です。

```tsx
// app/checkout/buy-button.tsx — Server Component のままでよい
import { createHostedCheckout } from "./actions";

export function BuyButton({ priceId }: { priceId: string }) {
  return (
    <form action={createHostedCheckout.bind(null, priceId)}>
      <button type="submit" className="btn-primary">
        購入する
      </button>
    </form>
  );
}
```

**`success_url` を「決済成功」の根拠にしてはいけない**点は §5 で詳しく述べます。リダイレクトはユーザーの体験用、確定処理は Webhook 用、と役割を分けます。

---

## 4. 埋め込み型 Checkout：自サイトから離脱させない

ブランド体験を保ちたいECやSaaSでは、Stripeのページへ飛ばさず**自サイトに決済フォームを埋め込みます**。サーバー側は `ui_mode: 'embedded_page'` を指定し、`client_secret` をクライアントへ返します。リダイレクト先は `cancel_url` ではなく `return_url` になります。

```typescript
// app/api/checkout-session/route.ts
import { NextResponse } from "next/server";
import { headers } from "next/headers";
import { stripe } from "@/lib/stripe";

export async function POST(): Promise<NextResponse> {
  const origin = (await headers()).get("origin") ?? "https://example.com";

  const session = await stripe.checkout.sessions.create({
    ui_mode: "embedded_page", // ← 埋め込みモード
    mode: "payment",
    line_items: [{ price: process.env.PRICE_ID!, quantity: 1 }],
    automatic_tax: { enabled: true },
    // 完了後の戻り先。{CHECKOUT_SESSION_ID} は Stripe が実IDに展開する
    return_url: `${origin}/checkout/return?session_id={CHECKOUT_SESSION_ID}`,
  });

  return NextResponse.json({ clientSecret: session.client_secret });
}
```

クライアントは公式の React コンポーネント `EmbeddedCheckoutProvider` と `EmbeddedCheckout` を使います。`clientSecret` を取りに行く関数を渡すだけで、フォームのレンダリング・バリデーション・3Dセキュア（SCA）まで Stripe が面倒を見ます。

```tsx
// app/checkout/embedded.tsx
"use client";

import { useCallback } from "react";
import { loadStripe } from "@stripe/stripe-js";
import {
  EmbeddedCheckoutProvider,
  EmbeddedCheckout,
} from "@stripe/react-stripe-js";

// モジュールトップで一度だけ。再レンダリングのたびに loadStripe しない。
const stripePromise = loadStripe(
  process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!,
);

export function EmbeddedCheckoutForm() {
  const fetchClientSecret = useCallback(
    () =>
      fetch("/api/checkout-session", { method: "POST" })
        .then((res) => res.json())
        .then((data: { clientSecret: string }) => data.clientSecret),
    [],
  );

  return (
    <div id="checkout" aria-live="polite">
      <EmbeddedCheckoutProvider stripe={stripePromise} options={{ fetchClientSecret }}>
        <EmbeddedCheckout />
      </EmbeddedCheckoutProvider>
    </div>
  );
}
```

> **a11y の補足**：埋め込みフォーム自体のアクセシビリティは Stripe 側が担保しますが、**読み込み中のローディング状態**や**エラー時のメッセージ**は自前のUIです。`aria-live="polite"` で状態変化を読み上げさせ、決済ボタンの無効化中は `aria-disabled` と視覚的フィードバックを必ず添えます。決済画面のa11y欠落は「カートに入れたのに買えない」という最悪の離脱を生みます。

---

## 5. 真実源は Webhook：成功画面で注文を確定してはいけない

ここが初心者と本番経験者を分ける最大の分水嶺です。**`success_url`/`return_url` への到達は「決済成功」を意味しません。**

理由は3つあります。①ユーザーが決済後にタブを閉じてリダイレクトが完了しないことがある。②URLは推測・改ざんが可能（`?session_id=...` を勝手に叩かれる）。③銀行振込やコンビニ払いなど**後から確定する**決済手段では、リダイレクト時点で入金は未完了。

したがって、**在庫の引き当て・ライセンス発行・メール送信・残高加算といった「確定処理」は、必ず Webhook で行います。** Stripeはイベントを「指数バックオフで最長3日間」再送し続けるので、あなたのサーバーが一瞬落ちても取りこぼしません。

> **本章は全体像に絞ります。** Webhookの署名検証・「少なくとも1回／順不同」配信への耐性・サブスクのライフサイクルを状態機械として設計する手法は、専用記事[StripeのWebhookと冪等性を本番品質で実装する](/blog/stripe-payments-production-guide-webhooks-idempotency-subscriptions)で実コードレベルに深掘りしています。決済全体の地図はこの記事、Webhookの作り込みは専用記事、という役割分担です。

### 5.1 署名検証：生ボディに対して `constructEvent`

Stripe公式が口を酸っぱくして言うのは「**未加工（raw）のリクエスト本文で署名検証せよ**」です。フレームワークがJSONを自動パースすると署名が合わなくなります。Next.js 16 の App Router は Route Handler で `await req.text()` を呼べば生ボディが取れるため、Pages Router時代の `bodyParser: false` 設定は不要です。

```typescript
// app/api/webhooks/stripe/route.ts
import { NextResponse } from "next/server";
import type Stripe from "stripe";
import { stripe } from "@/lib/stripe";
import { handleStripeEvent } from "@/lib/stripe/handle-event";

export const runtime = "nodejs"; // 署名検証に Node の crypto を使う

const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;

export async function POST(req: Request): Promise<NextResponse> {
  const body = await req.text(); // ← 生ボディ。JSON.parse しない
  const signature = req.headers.get("stripe-signature");
  if (!signature) {
    return NextResponse.json({ error: "missing signature" }, { status: 400 });
  }

  let event: Stripe.Event;
  try {
    // constructEventAsync はどの実行環境でも安全に動く（推奨）。
    event = await stripe.webhooks.constructEventAsync(
      body,
      signature,
      webhookSecret,
    );
  } catch (err) {
    // 署名不一致・タイムスタンプ超過（既定5分）はここで弾く＝リプレイ攻撃対策
    const message = err instanceof Error ? err.message : "invalid signature";
    return NextResponse.json({ error: message }, { status: 400 });
  }

  try {
    await handleStripeEvent(event); // ❸ 重い処理はここ。ただし速く返す工夫が要る
  } catch (err) {
    // 5xx を返すと Stripe が再送してくれる（＝後述の冪等処理が効く）
    console.error(`Webhook handler failed for ${event.id}`, err);
    return NextResponse.json({ error: "handler failed" }, { status: 500 });
  }

  return NextResponse.json({ received: true });
}
```

署名検証が同時に**リプレイ攻撃対策**になっている点に注目してください。`Stripe-Signature` ヘッダーにはタイムスタンプ（`t=`）が含まれ、既定で**5分の許容**を超えた古い署名は弾かれます。許容値を `0` にしてはいけない（時計のわずかなズレで正規イベントまで落ちる）、というのも公式の注意点です。Webhookルートは**CSRF保護の対象外**にし、署名検証だけを認証根拠にします。

### 5.2 「速く2xxを返す」と「重い処理」を両立する

公式は「タイムアウトを避けるため、複雑なロジックの前に素早く2xxを返せ」と言います。一方で確定処理は重い。この矛盾の正攻法は、**「受信したことだけ即コミット → 実処理は非同期」**に分けることです。小規模なら同期処理でも間に合いますが、メール送信や外部API連携が絡むなら、受信記録だけ先に保存してジョブキュー（Vercel Queues や DB ベースのアウトボックス）に流すのが定石です。

ここまでで「配信は最低1回（重複あり）／順不同もあり得る」という Webhook の現実に向き合う準備が整いました。次章がその核心です。

---

## 6. 二重課金を構造で根絶する：冪等性の2層モデル

決済で絶対に避けたいのは二重課金と状態の巻き戻りです。私が決済プラットフォームで**本番二重課金0件**を達成できた理由は、根性や監視ではなく、**冪等性をコードの構造に埋め込んだ**からです。Stripeでは2つのレイヤーで効かせます。

### 6.1 送信側：`Idempotency-Key` でリクエストを一意化

ネットワークタイムアウトやサーバーレスの再試行で、**同じ決済リクエストが複数回 Stripe に届く**ことは正常系です。`Idempotency-Key` を付けると、Stripeは最初のリクエストの結果（ステータスコードとボディ）を保存し、同じキーの再送には**まったく同じ結果を返す**——新しい課金を作りません。公式仕様を正確にまとめます。

- **対象は全ての `POST` リクエスト。** `GET`/`DELETE` は定義上べき等なので不要。
- **キーの形式**は「V4 UUID、または衝突を避けるのに十分なエントロピーを持つランダム文字列」、**最大255文字**。
- **メールアドレスや個人識別子をキーにしない**（漏洩・推測リスク）。
- **保持期間は最低24時間**。それを過ぎてキーが剪定された後に再利用すると、新規リクエスト扱いになる。
- **最初のリクエストのパラメータと異なるパラメータを同じキーで送るとエラー**になる（誤用防止）。
- 結果は成功・失敗を問わず保存され、`500` エラーすらそのまま返る。

```typescript
// 業務的に意味のあるキーを使うと、リトライがちゃんと同一視される。
// 例：注文IDベース。同じ注文の二重サブミットは同じキー → 二重課金にならない。
const session = await stripe.checkout.sessions.create(params, {
  idempotencyKey: `order:${orderId}:checkout:v1`,
});
```

ポイントは「**何を同一とみなすか**」を業務で設計することです。`crypto.randomUUID()` を毎回振ると技術的にはユニークですが、それでは「同じ注文の二度押し」を吸収できません。**注文ID＋操作種別＋リビジョン**のような決定的キーにすると、ユーザーの二度押しもサーバーレスの再試行も同じキーに収束し、課金が1回に畳まれます。

### 6.2 受信側：`event.id` で Webhook の重複・順序逆転を吸収

Stripeは Webhook を「最低1回」配信するため、**同じイベントが複数回届きます**。公式の推奨は明快で、「**処理した `event.id` を記録し、記録済みのイベントは再処理しない**」。これを一意制約付きのテーブルで実装します。

```typescript
// lib/stripe/handle-event.ts（概念コード）
import type Stripe from "stripe";

export async function handleStripeEvent(event: Stripe.Event): Promise<void> {
  // ❶ 第1層：event.id の一意制約で「再送」を吸収する。
  //    INSERT が衝突したら＝処理済み。安全にスキップ（重複排除）。
  const inserted = await db.insertIfAbsent("processed_webhook_events", {
    id: event.id, // PRIMARY KEY / UNIQUE
    type: event.type,
    created: event.created,
  });
  if (!inserted) return; // 既に処理済み → 何もしない（冪等）

  // ❷ 第2層：順序逆転を弾く。古い event.created は無視する。
  //    例：subscription.updated が pause→active の順で来るべきが逆転した場合、
  //    対象行の「最後に処理した created」より古ければ状態を巻き戻さない。
  switch (event.type) {
    case "checkout.session.completed": {
      const session = event.data.object as Stripe.Checkout.Session;
      await fulfillOrder(session); // 在庫引当・ライセンス発行など（これ自体も冪等に）
      break;
    }
    case "payment_intent.succeeded":
    case "payment_intent.payment_failed":
      // 必要なイベントだけ処理。未知のイベントは黙って 2xx で受け流す。
      break;
    default:
      break;
  }
}
```

この「**第1層＝一意制約で再送吸収／第2層＝`created` 比較で順序逆転を弾く**」の2層構造が、Webhook を本番品質にする最小公倍数です。私が金融教育のサブスク基盤（Stripe 17）で実装した冪等Webhookの設計と、その PII 墨消し・状態機械までの作り込みは、別記事で実コードベースに解剖しています（本記事末尾のリンク先）。

> **設計原則として**：`fulfillOrder` の中身自体も冪等に書きます（「すでに発行済みのライセンスは再発行しない」）。外側（event.id）と内側（業務処理）の両方を冪等にして初めて、「2層のどこかで漏れても二重実行しない」という多重防御になります。

---

## 7. テスト容易性：時計を進める・イベントを撃ち込む

決済は「1ヶ月後に更新される」「3日後に失効する」といった**時間依存ロジック**の塊です。実時間を待ってテストはできません。Stripeはこのために2つの公式ツールを用意しています。

- **Stripe CLI** でローカルにイベントを転送・発火できます。署名シークレットも CLI が払い出すので、本番Webhookと同じ経路を手元で検証できます。

```bash
# ローカルの Webhook ハンドラへ実イベントを転送（whsec_... が表示される）
stripe listen --forward-to localhost:3000/api/webhooks/stripe

# 任意のイベントを撃ち込む。冪等処理が効いているか、二度撃って確認する。
stripe trigger checkout.session.completed
stripe trigger payment_intent.succeeded
```

- **Test Clocks（テストクロック）**で、サブスクの請求サイクルを仮想的に数ヶ月先まで進め、更新・失効・リトライの挙動を**数秒で**検証できます。サブスクや猶予期間の状態機械は、テストクロックでの結合テストを必ず1本通すのが本番運用の保険になります。

ユニットテストの観点では、**料金計算・状態遷移・冪等キー生成のような純粋ロジックを Stripe から切り離し**、Stripeへの副作用を持たない関数として単体テストするのが鉄則です。`amount` の決定や猶予期間の判定を Webhook ハンドラの中に直書きせず、引数→戻り値が決定的な純粋関数に追い出せば、DB も Stripe もなしで全分岐をテストできます。

---

## 8. 応用：マーケットプレイス決済を Connect で組む

「出店者に売上を配分し、プラットフォームが手数料を取る」——この**マルチパーティ決済**は Stripe Connect の領域です。私が木材流通の B2B SaaS（経済産業大臣賞受賞）で実装したのもこの形でした。2026年の Connect は **Accounts v2 API** による統一アカウントモデルに進化していますが、課金の考え方は3パターンに整理できます。

> **本章は要点に絞ります。** アカウント種別の使い分け（destination / direct / separate charges & transfers）・KYCオンボーディングの状態ガード・Connect固有のWebhook冪等化・コミッション台帳の整数設計は、専用記事[Stripe Connect マーケットプレイス決済 本番ガイド](/blog/stripe-connect-marketplace-payments-idempotency-production-guide)で詳説しています。

| 方式 | 誰の上で課金するか | 主なパラメータ | 向く場面 |
| --- | --- | --- | --- |
| **Destination charge** | プラットフォーム | `transfer_data.destination` ＋ `application_fee_amount` | 多くのマーケットプレイス（標準） |
| **Direct charge** | 連結アカウント | `Stripe-Account` ヘッダ ＋ `application_fee_amount` | 出店者がStripeと直接の関係を持つ |
| **Separate charges & transfers** | プラットフォーム | Transfers API で個別送金 | 1決済を複数出店者へ按分 |

もっとも一般的な **destination charge** を Checkout Session で組むとこうなります。プラットフォームがお金を受け、手数料を差し引いて、残りを出店者の連結アカウントへ即時送金します。

```typescript
const session = await stripe.checkout.sessions.create(
  {
    mode: "payment",
    line_items: [{ price: priceId, quantity: 1 }],
    payment_intent_data: {
      // ❶ プラットフォーム手数料（最小通貨単位の整数。円なら「円」そのもの）
      application_fee_amount: 500,
      // ❷ 送金先の連結アカウント（出店者）
      transfer_data: { destination: connectedAccountId },
      // ❸ この決済の名義人を連結アカウントにする。
      //    出店者の国で処理され、明細の表示名・手数料体系も連結アカウント基準になる。
      on_behalf_of: connectedAccountId,
    },
  },
  { idempotencyKey: `order:${orderId}:connect:v1` },
);
```

ここでも**お金は整数で扱う**のが鉄則です。`application_fee_amount` は最小通貨単位（JPYは円、USDはセント）の**整数**であり、浮動小数の手数料率計算を挟むと丸め誤差が必ず混入します。手数料率はベーシスポイント（10000=100%）の整数で持ち、`BigInt` で計算してから整数に確定する——これは私がコミッション台帳で採用した設計とまったく同じ考え方です。出店者の本人確認（KYC）オンボーディングは Account Links か埋め込みオンボーディングで実装し、`charges_enabled` が立つまで送金先に指定しない、という**状態ガード**を必ず入れます。

---

## 9. よくある質問（実装前に潰しておくべき論点）

**Q. Payment Intents はもう使わないの？**
A. 「生で書く必要が減った」が正確です。Checkout Sessions は内部で Payment Intent を生成・管理してくれます。`payment_intent.succeeded` などのイベントも普通に飛びます。**自分で Payment Intent を作って `clientSecret` を Elements に渡す**フル実装が要るのは、決済UXが事業の核で完全制御が必要なときだけ、というのが2026年の公式スタンスです。

**Q. 金額をフロントから渡してはダメな理由は？**
A. ブラウザのコードは改ざん可能だからです。`amount` をクライアントが送る設計は、価格を1円に書き換えられる脆弱性に直結します。**Price ID を参照するか、サーバー側のデータからのみ金額を確定**してください。

**Q. Webhookが二重で届いた／順番が逆だった。バグ？**
A. 仕様です。配信は最低1回（重複あり）・順不同もあり得ます。`event.id` の一意制約で重複を吸収し、`event.created` の比較で順序逆転を弾く——§6の2層モデルで正常系として処理します。

**Q. `success_url` に来たのに入金されていないことがある。**
A. 銀行振込・コンビニ払いなど**非同期決済**ではリダイレクト時点で未確定です。確定処理は必ず `checkout.session.completed` や `payment_intent.succeeded` の Webhook で行ってください。

**Q. API バージョンは上げ続けるべき？**
A. 固定したうえで、**差分（changelog）を読んでから意図的に上げる**のが正解です。固定しないと Stripe 側の更新でレスポンス形状が変わり、再現性のない障害になります。

**Q. テスト環境で1ヶ月後の更新を試したい。**
A. Test Clocks で仮想的に時計を進めます。Stripe CLI の `stripe trigger` と併用すれば、更新・失効・リトライを数秒で検証できます。

---

## まとめ：本番品質の決済は「構造」で作る

Stripeの実装は、APIの呼び方を覚えることではありません。**信頼できない外部（クライアント・ネットワーク・再試行）を前提に、正しさをコードの構造で保証すること**です。本記事の要点を一枚に畳みます。

- **方式選定**：まず Checkout Sessions（ホスト型→埋め込み型→Elementsの順に検討）。YAGNIで生Payment Intentsを避ける。
- **金額の真実源はサーバー**：Price ID かサーバー計算。クライアントの `amount` を信じない。
- **確定の真実源は Webhook**：リダイレクトは体験用。確定処理は署名検証済みイベントで。
- **冪等性は2層**：送信側 `Idempotency-Key`（決定的キー）＋受信側 `event.id` 一意制約＋`created` 比較。
- **時間依存はテスト可能に**：Test Clocks と Stripe CLI、純粋関数への切り出し。
- **マーケットプレイスは Connect**：`application_fee_amount` ＋ `transfer_data.destination`、お金は整数。

私はこの水準を、環境分野の決済プラットフォーム（**本番二重課金0件**）、木材流通のB2B SaaS（**Stripe Connect**・経済産業大臣賞受賞）、金融教育のサブスク基盤（**Stripe 17 の冪等Webhook**）で実装・運用してきました。Stripeを使った新規決済の立ち上げ、既存決済の信頼性の立て直し（二重課金・整合性・Webhook設計）、マーケットプレイス/サブスクの構築をご検討でしたら、**要件定義から実装・本番運用・テスト容易性の担保まで、この記事の水準でお引き受けします**。一人 × 生成AI（Claude Code）を verification gate で運用することで、この品質を速く・安全にお届けできます。

> **あわせて読みたい（Stripe実装クラスタ）**：
> - [StripeのWebhookと冪等性を本番品質で実装する](/blog/stripe-payments-production-guide-webhooks-idempotency-subscriptions) — 署名検証・少なくとも1回配信・サブスク状態機械の専用解説
> - [Stripe Connect マーケットプレイス決済 本番ガイド](/blog/stripe-connect-marketplace-payments-idempotency-production-guide) — アカウント種別・手数料・送金設計
> - [Stripe Billing 実装ガイド](/blog/stripe-billing-subscriptions-usage-based-customer-portal-guide) — サブスク・従量課金・顧客ポータル
> - [サブスク売上を取り戻すStripe実装ガイド（ダンニング）](/blog/stripe-subscription-dunning-failed-payment-recovery-churn-guide) — 支払い失敗からの回収
> - [サブスク学習プラットフォームのアーキテクチャ徹底解剖](/blog/subscription-platform-billing-idempotency-type-safety) — Webhook冪等処理・PII墨消し・状態機械の実コードレベルの深掘り

*（本記事のStripe仕様は2026年6月時点の公式ドキュメント／API版 `2026-05-27.dahlia` に基づきます。最新の挙動は必ず公式ドキュメントで確認してください。）*
