「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つの引数です。
- raw(未加工)のリクエストボディ — パース前のバイト列。フレームワークがJSONに変換した値は使えない。
Stripe-Signatureヘッダ —t=タイムスタンプ,v1=署名の形式。- エンドポイントシークレット —
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 body:
constructEventは受け取ったバイト列とシークレットからHMACを再計算し、Stripe-Signatureのv1=と突き合わせます。1バイトでも変われば一致しません。「なぜか本番だけ署名検証が落ちる」の9割は、手前のミドルウェアがボディをいじっていることが原因です。
3. 黄金律:Webhookは「順不同・少なくとも1回」
ここが本記事で最も重要な章です。Stripe公式が明言するWebhookの配信セマンティクスを、設計の前提として腹に落とす必要があります。
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を記録し、すでに記録済みのイベントは処理しない」。
event.id(evt_...)にユニーク制約を張り、挿入できたら初回、衝突したらスキップします。ここで決定的に重要なのが、イベントIDの記録と業務処理を同じトランザクションでコミットすることです。
import { Prisma } from "@prisma/client";
type Tx = Prisma.TransactionClient;
// 受信記録と業務処理を1つのトランザクションでコミットする
async function handleStripeEvent(event: Stripe.Event): Promise<void> {
await prisma.$transaction(
async (tx) => {
// ① 受信記録。skipDuplicates は PostgreSQL では ON CONFLICT DO NOTHING になる。
// 同じ event.id の行がすでにあれば count は 0(例外ではなく戻り値で重複を判定する)
const { count } = await tx.stripeWebhookEvent.createMany({
data: [{ stripeEventId: event.id, eventType: event.type }],
skipDuplicates: true,
});
if (count === 0) {
console.info(`duplicate event skipped: ${event.id}`);
return; // 重複配信。何もせず COMMIT して 2xx
}
// ② 業務処理。失敗すれば①も ROLLBACK され、Stripe の再送で正しく再処理される
await dispatch(event, tx);
},
{ timeout: 10_000 }, // §3-2 で Stripe API を読むため、既定の 5 秒より長くする
);
// 例外はそのまま投げる → 5xx を返し、Stripe の再送に任せる
}
WHY 同じトランザクションか:受信記録を先に別のトランザクションでコミットしてから業務処理をすると、業務処理が失敗したとき、再送は「記録済み=処理済み」として弾かれ、そのイベントは永久に処理されません。逆に業務処理を先にコミットしてから記録すると、その間にプロセスが落ちたとき、再送で二重処理になります。2つの書き込みを1つのコミットにまとめれば、どちらも起きません。この受信側の設計(Transactional Inbox)と、同じイベントが同時に届いたときの挙動は、Webhookの冪等性をTransactional Inboxで実装する方法で、PostgreSQLで再現しながら詳しく扱っています。
WHY DB制約に頼るか:アプリ層で「SELECTして無ければINSERT」とやると、二重配信が同時に届いたとき両方がSELECTで「無い」と判断し、二重処理が起きます(TOCTOU)。一意制約は競合をDBが原子的に裁くので、レースに強い。同時に届いた2本目の INSERT は、1本目のトランザクションが終わるまで待ち、1本目がコミットすれば
countが 0 になり、ROLLBACK すれば2本目が処理を引き継ぎます。
WHY 例外(P2002)で重複を判定しないか:「一意制約違反(P2002)が出たら重複として 2xx」と書くと、
dispatchの中の業務側の一意制約違反(下で勧める請求書IDごとの一意制約など)まで「重複」と扱われます。トランザクションごと ROLLBACK されて受信記録も消えたまま 2xx を返すので、そのイベントは二度と処理されません——防ぎたかった永久消失そのものです。重複の判定は受信記録の INSERT の戻り値で行い、業務側の書き込みは upsert や ON CONFLICT で例外にしない形にします。
改訂履歴(2026-10-04):初版では、イベントIDを記録してから業務処理を行う(記録と処理が別のコミットになる)コードを掲載していました。上記の永久消失が起きるため、同じトランザクションにまとめる形に改めました。
なお、Stripe の公式ドキュメントは、場合によっては同じ事実について2つの別々の Event オブジェクトが送られることがあり、その重複は data.object のIDと event.type で識別するよう案内しています。イベントIDの重複排除はこれを防げないので、業務側の書き込み(例:請求書IDごとの入金記録)にも一意制約を張り、ハンドラ自体を冪等にしておきます。
3-2. (b) 順序非依存:created ではなく、状態遷移と最新状態で判断する
二重排除だけでは順序問題は解けません。customer.subscription.updated が2回飛び、新しい方が先に処理された後で古い方が到着したら、ペイロードをそのまま保存するとサブスク状態が古い値に巻き戻ります。
ここで「event.created を比べて古いイベントを捨てる」方法を思いつきますが、Stripe の公式ドキュメントはこれを明確に否定しています(2026年10月時点)。created は秒単位で記録されるため別々のイベントが同じ値を持ち得るとして、created を順序の判定や処理済みの判定に使わないよう書かれています。
改訂履歴(2026-10-04):初版では
event.createdで巻き戻りを弾く方法を紹介していましたが、上記の公式ドキュメントの記述に合わせて改めました。
代わりに、イベントの種類に応じて2つの方法を使い分けます。
- 単調な状態遷移として書けるものは、そう書く。例えば
invoice.paidは「paid/void以外のときだけpaidに進める」と書けば、どの順で届いても最終状態は同じになります。 - 単調でないもの(サブスクの
statusはactiveとpast_dueを行き来する)は、イベントを「変化があった」という合図として扱い、Stripe API から最新のオブジェクトを取得して保存します。公式ドキュメントも、先に届いたイベントの情報から関連オブジェクトを API で取得する方法を案内しています。
2つ目には落とし穴が1つあります。同じサブスクの同期が2本同時に走ると、先に取得した(古い)結果が後から保存されることがあります。取得から保存までを、サブスクごとに直列化します。
import { createHash } from "node:crypto";
// サブスクIDを PostgreSQL の Advisory Lock のキー(bigint)に写像する
function advisoryKey(namespace: string, id: string): bigint {
return createHash("sha256").update(`${namespace}:${id}`).digest().readBigInt64BE(0);
}
// Webhook は「見に行くきっかけ」。保存する値は Stripe から取得した最新のもの
async function syncSubscription(tx: Tx, subscriptionId: string): Promise<void> {
// 同じサブスクの「取得 → 保存」を直列化する。COMMIT / ROLLBACK で自動解放される
await tx.$queryRaw`SELECT 1 FROM pg_advisory_xact_lock(${advisoryKey("stripe-subscription", subscriptionId)})`;
const fresh = await stripe.subscriptions.retrieve(subscriptionId);
await tx.subscription.upsert({
where: { stripeId: fresh.id },
create: { stripeId: fresh.id, status: fresh.status },
update: { status: fresh.status },
});
}
ロックを取ってから取得するので、後からロックを取った同期は、必ず先の同期が保存した後に、より新しい状態を取得します。pg_advisory_xact_lock はトランザクションの終了で自動解放されるので、解放漏れも起きません(Advisory Lock の使い方と注意点は PostgreSQL Advisory Lock の実践ガイド)。
トレードオフ:このコードはトランザクションとロックを握ったまま Stripe API を呼びます。読み取り(GET)なので、ROLLBACK しても外部に副作用は残りませんが、Stripe の応答が遅いと同じサブスクの同期が待たされ、DB 接続も握り続けます。サブスク単位のロックなので待つのは同じサブスクのイベントだけですが、Webhook の量が多いなら、受信記録だけをコミットして 2xx を返し、同期は非同期のワーカーで行う形に切り替えます(§3-4)。
3-3. 究極の安全網:Stripeを真実源に「照合」する
Webhook は通知であって真実そのものではない。§3-2 の再取得は「イベントが届いたとき」にしか動かないので、Webhook の再送期間(最大3日間)を超える障害や、処理のバグで失われたイベントは拾えません。
本当に重要な状態(サブスクが有効か、入金済みか)は、定期的に Stripe の API と突き合わせ、ズレを見つけたら §3-2 と同じ正規の書き込み経路で直します。全件を一定期間内に確実に見るカーソルの設計、確認できなかった状態を健全とみなさない分類、自動で直してよいズレと人が見るべきズレの区別は、Reconciliation(整合性チェック)の設計にまとめました。
コスト注意:照合は追加のAPI呼び出しで、Stripe のレート制限は本番の決済と共有されます。毎イベントで全件を叩くと無駄。照合ジョブには上限の一部だけを割り当て、矛盾を検知したときや、入金確定など金額が動く状態を優先して照合する、というメリハリが現実解です(可観測性 + コスト効率)。
3-4. なぜ「素早く2xx → 非同期処理」なのか
公式は「タイムアウトを起こしうる複雑なロジックの前に、素早く2xxを返せ」と明言します。理由は2つ。
- 2xxが遅いとStripeはリトライする(成功が届かないと失敗扱い)。重い処理を同期でやると、タイムアウト→リトライ→二重配信を自分で誘発する。
- 月初の一斉更新などWebhookが急増すると、同期処理ではエンドポイントが詰まる。公式推奨は「受信イベントを非同期キューで処理」。
設計としては「署名検証 → event.id を『未処理』として記録 → 2xx即返し → 重い処理はキュー/バックグラウンド」。このとき記録は「処理済み」の証拠ではなく「こちらが処理を引き受けた」証拠です。ワーカーは、記録を「処理済み」にする更新と業務処理を同じトランザクションでコミットします(§3-1 と同じ理由)。サーバーレス(Vercel等)なら、軽い処理はその場で、重い処理は専用のジョブ/Queueに逃がします。
4. サブスクのライフサイクルは「状態機械」として扱う
サブスクの status を if (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生成なし。 | 保留 | 支払い方法追加→再開を待つ |
公式が特に強調する判断軸:
- アクセスを付与してよいのは
activeかtrialingのときだけ。incompleteで先に開放すると、支払い未完了のまま使われる。 past_dueとunpaidは別物。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. どのイベントで何を更新するか
サブスク運用で実際に効くイベントは限られます。
// tx は §3-1 の受信記録と同じトランザクション。業務処理は全てこの中で行う
async function dispatch(event: Stripe.Event, tx: Tx): Promise<void> {
switch (event.type) {
case "checkout.session.completed":
// Checkout完了。subscriptionモードなら subscription を取得して反映
await onCheckoutCompleted(event.data.object as Stripe.Checkout.Session, tx);
break;
case "customer.subscription.created":
case "customer.subscription.updated":
case "customer.subscription.deleted":
// 状態機械を回す唯一の入口に集約。保存するのは Stripe から取得した最新の状態(§3-2)
await syncSubscription(tx, (event.data.object as Stripe.Subscription).id);
break;
case "invoice.paid":
// 継続課金の成功。利用期間を延長(請求書IDの一意制約で二重延長を防ぐ)
await extendEntitlement(event.data.object as Stripe.Invoice, tx);
break;
case "invoice.payment_failed":
// ★ ここを無視するのが最頻出の事故。督促(dunning)を起動
await startDunning(event.data.object as Stripe.Invoice, tx);
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 のとおり、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 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版を買えてしまう。
// ❌ 致命的:クライアントの金額を信じる
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.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)。 - ❌ 受信記録と業務処理を別々にコミットする → 先に記録すれば処理失敗時にイベントが永久に失われ、後で記録すればクラッシュ時に二重処理になる。同じトランザクションに入れる(§3-1)。
- ❌ 重い処理を同期でやってから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への再取得)は必要な瞬間だけ。毎イベントで叩かない。冪等記録のテーブルは古いイベントを定期的に掃除してストレージを抑える。ただし保持期間は、Webhook の再送期間(本番で最大3日間)より十分長くする。それより短いと、3日目に届いた再送が重複として弾かれずに再処理される(筆者は障害調査の期間も含めて30日程度にすることが多い)。
まとめ:Stripeは「壊れない前提」で設計すると素直になる
Stripeを本番品質で実装するとは、分散システムの不確実性を設計で吸収することです。要点を5行で。
- 冪等性は二層で。Stripeへは
Idempotency-Key(操作から決定的に導出)、自分側はevent.idの一意制約。再送で壊れない。 - 署名検証は raw body で。
stripe.webhooks.constructEventに未加工ボディを渡す。手前のパーサに body をいじらせない。 - Webhookは順不同・少なくとも1回。受信記録と業務処理は同じトランザクションで。
createdで順序を判定せず、状態遷移として書くか最新の状態を取得して保存し、重要な値は定期的にStripeと照合。先に2xx、重い処理は非同期。 - サブスクは状態機械。
active/past_due/unpaid/canceledを表で固定し、unpaidは即剥奪・past_dueは督促、と分けて型で網羅。 - クライアントを信じない。金額はPrice IDでサーバー確定、境界はZod、鍵は最小権限、PIIは保存前に墨消し。
「動く決済」と「複数人で本番運用に耐える決済基盤」の差は、まさにこうした一つひとつの判断にあります。その実例が、本記事の原理の出どころであるサブスク学習プラットフォーム(マルチチャネル課金・冪等な決済・代理店コミッションを Next.js 16 モノレポで構築)です。実装の深掘りはアーキテクチャ徹底解剖に、決済の冪等性をAWS側で実現した話はDynamoDBの記事にあります。
Stripeを使ったサブスク課金・決済信頼性・型安全なドメイン設計を伴う新規開発や立て直しをご検討でしたら、要件定義から実装・運用まで、この水準でお引き受けします。お問い合わせからお気軽にどうぞ。