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

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

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

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

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

この記事は、Stripeを再利用可能な本番品質で実装するための実装ガイドです。すべてのコードと挙動はStripe公式ドキュメントに忠実に裏付け、公式より判断軸(いつ・どう・なぜ)を厚くしています。私自身、金融リテラシー教育のサブスク学習プラットフォームで、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)。執筆時点の最新APIバージョンは 2026-05-27.dahlia。本記事のコードはこの前提です。

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


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はこの問題のために冪等リクエストを用意しています。POSTリクエストに一意なキーを添えると、Stripeが最初のリクエストの結果(ステータスコードとボディ)を保存し、同じキーの再送には保存済みの結果を返す——課金は1回しか起きません。

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

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 で決定的に組み立てています(詳細)。同じ原理です。

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


2. Webhook署名検証を正しくやる(raw bodyが全て)

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

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

2-2. HOW:stripe.webhooks.constructEvent に raw body を渡す

公式の署名検証が要求するのは3つの引数です。

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

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

Express の場合

Webhookルートだけ express.raw() を使い、express.json() より前に置きます(公式 quickstart)。

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() を使うとパース済みになり検証に失敗します。

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


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

ここが本記事で最も重要な章です。Stripe公式が明言するWebhookの配信セマンティクスを、設計の前提として腹に落とす必要があります。

At-least-once(少なくとも1回):「Webhookエンドポイントは、同じイベントを複数回受信する可能性があります」。本番では指数バックオフで最大3日間リトライされます。

順序保証なし:「Stripeは、イベントが生成された順序で配信されることを保証しません」。サブスク作成は customer.subscription.createdinvoice.createdinvoice.paid の順で生成されても、到着は前後しうる

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

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

§1のStripe側冪等性とは別に、Webhook処理側でも重複を弾きます。公式の推奨はシンプルです——「処理したイベントIDを記録し、すでに記録済みのイベントは処理しない」。

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

// 受信イベントを一意制約で記録 → 初回だけ処理する
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 時刻」を持ち、それより古いイベントを捨てること。

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で照合します。

// 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. サブスクのライフサイクルは「状態機械」として扱う

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

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

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

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

これを型で固めます。Union型 + 網羅検査で「新ステータス追加時に分岐漏れがコンパイルで落ちる」状態にします(enum を避ける理由は姉妹記事の NeverError を参照)。

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. どのイベントで何を更新するか

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

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 のユーザーが放置され、unpaidcanceled まで黙って進む。督促メール(dunning)と段階的なアクセス制限を起動するのがこのイベントの役割です。私のサブスク基盤では、銀行振込側の督促を「送信済み集合」で冪等化して二重送信を防いでいます。


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

決済UIを自前で組むのは、PCI準拠・3DS・各種決済手段対応を全部自分で背負うことを意味します。Stripe CheckoutCustomer 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 のとおり、stripe.checkout.sessions.create で生成し、session.url へリダイレクトします。サブスクなら mode: "subscription"、買い切りなら mode: "payment"line_items にはサーバーで管理する Price ID を渡します(金額を直接渡さない理由は §7)。

// 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 は、支払い方法の更新・プラン変更・解約・請求書の閲覧をStripeホストの画面で顧客にセルフサービスさせます。自分で作ると地獄の「解約フロー」を、設定だけで用意できます。

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 で、ローカルにWebhookを転送し、イベントを意図的に発火させて検証します。

# 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

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

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


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

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

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

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

// ❌ 致命的:クライアントの金額を信じる
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検証し、内側には信頼できる型だけを流します。

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> に置換してから保存しています(実装)。
  • ログには event.idevent.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.Eventswitch (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は保存前に墨消し。

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

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

友田

友田 陽大

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

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

金融リテラシー教育のサブスク学習プラットフォーム(マルチチャネル課金・冪等な決済・代理店コミッションをNext.js 16モノレポで構築)

ケーススタディを見る