メインコンテンツへスキップ
友田 陽大
決済・課金
Stripe
決済
Next.js
TypeScript
Webhook
冪等性
セキュリティ
B2B SaaS
アーキテクチャ設計

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

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

公開日
読了時間
21分
著者
友田 陽大
シェア

決済は「動いた」と「本番に出せる」の間が、ソフトウェアの中でもっとも遠い領域です。カード番号を入力して成功画面が出るデモは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埋め込み型 CheckoutElements + Payment Intents
実装量最小大(自前で多くを再構築)
UIの自由度低(Stripeページ)中(自サイトに枠を埋め込み)最大(完全カスタム)
税・割引・送料・住所収集組み込み組み込み手動実装
サブスク作成組み込み組み込み別途実装
セッション自動失効あり(24時間)ありなし
Webhookイベント決済ライフサイクル全体全体決済ステータスのみ
PCI負担(自己問診)SAQ A(最小)SAQ ASAQ A(要件を満たす実装が必要)
向く場面まず作って売る/MVP/請求ブランド体験を保ちたいEC独自の決済UXが事業の核

意思決定のショートカット:

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

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


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

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

// 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_KEYsk_...)はサーバーのみ。 環境変数で注入し、ログにもクライアントバンドルにも出さない。
  • 公開可能キー(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 に限定し、クライアントには clientSecretsession.url のような使い捨ての値だけを渡すのが鉄則です。


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

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

// 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: 100amount: 1 に書き換えられたら終わりです。Stripeダッシュボードで作った Price ID(price_...)を参照するか、どうしても動的価格が必要ならサーバー側のDB・計算結果からのみ unit_amount を組み立てます。これは OWASP でいう「クライアントを信頼しない」の決済版です。

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

// 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 になります。

// 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 コンポーネント EmbeddedCheckoutProviderEmbeddedCheckout を使います。clientSecret を取りに行く関数を渡すだけで、フォームのレンダリング・バリデーション・3Dセキュア(SCA)まで Stripe が面倒を見ます。

// 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と冪等性を本番品質で実装するで実コードレベルに深掘りしています。決済全体の地図はこの記事、Webhookの作り込みは専用記事、という役割分担です。

5.1 署名検証:生ボディに対して constructEvent

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

// 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 エラーすらそのまま返る。
// 業務的に意味のあるキーを使うと、リトライがちゃんと同一視される。
// 例:注文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 を記録し、記録済みのイベントは再処理しない」。これを一意制約付きのテーブルで実装します。

// 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と同じ経路を手元で検証できます。
# ローカルの 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 マーケットプレイス決済 本番ガイドで詳説しています。

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

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

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.completedpayment_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、純粋関数への切り出し。
  • マーケットプレイスは Connectapplication_fee_amounttransfer_data.destination、お金は整数。

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

あわせて読みたい(Stripe実装クラスタ)

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

友田

友田 陽大

経済産業大臣賞 受賞プロダクト開発者。TypeScript + Python + AWS で、SaaS・業界DX・ 実用レベルの生成AI(RAG)を、要件定義からインフラ・運用まで一人で完遂します。

この記事で解説した技術の適用事例

経済産業大臣賞受賞 | 木材流通業界のDXを実現したB2BサブスクリプションSaaS(Stripe Connectで実装)

ケーススタディを見る