PSP か MoR か——Cloudflare Workers で Stripe を採用した理由のサムネイル

はじめに

SaaS に決済を入れる前に答えるべき1つの問い

多くの開発者は「Stripe を使うか」から考え始める。

これは順番が逆だ。

あなたは販売主体になるのか?

決済インフラは構造的に2種類に分かれる。PSP(Payment Service Provider)と MoR(Merchant of Record)だ。この分岐を意識せずに Stripe を選ぶのと、選んだ上で Stripe を選ぶのでは、設計の深さが根本的に異なる。ClaudeMix ではこの分岐を検討した結果、Stripe を採用した。

この記事をお勧めしない人

  • 決済インフラの選定は「みんなが使っているから」で十分だと考えている人。
  • Webhook の処理は「とりあえず動けばいい」で済ませられると思っている人。
  • Cloudflare Workers と Node.js ランタイムの違いを意識しなくても問題ないと思っている人。

もし一つでも当てはまらないなら、読み進める価値があるかもしれません。

分岐を間違えると積み上がるコスト

  • PSP/MoR の違いを意識せず Stripe を選ぶと、税務処理・消費税対応・インボイス発行の設計が後から崩れる。
  • Webhook の到達確認ができないまま、Stripe ダッシュボードの配信失敗ログが静かに積み上がっていく。
  • 冪等性ガードなしで再送設定を入れると、一人のユーザーに対して複数の有効化処理が走り、課金データと DB のステータスが乖離する。

販売主体の設計が決済基盤の根幹になる

  • この記事を読めば、PSP と MoR という2つの選択肢の構造的違いと、ClaudeMix が Stripe(PSP)を選んだ設計判断の根拠を理解できます。
  • Cloudflare Workers で Stripe を正しく動かすための「環境固有の設定(httpClient)」と「署名検証の正しい順序」が手に入ります。
  • 2025-12-15.clover API バージョン対応や冪等性ガードなど、ネット上の情報では見かけない実戦的な落とし穴と対処法を習得できます。
  • この設計は ClaudeMix の本番サブスクリプション基盤で実証済みであり、Stripe 審査突破まで一貫して使われているパターンです。

このブログもそうでした

AI は Stripe の実装コードを書くことは簡単だ。しかし AI は「PSP か MoR か」というビジネス設計の判断はしてくれない。その判断は開発者が行う必要がある。ClaudeMix ではその判断の記録を残すことを目的として、この記事を書いた。

筆者も最初は「Stripe を使う」という結論から入り、PSP/MoR の分岐を後から意識した経緯があります。
また Cloudflare Workers への移行時に、Node.js 前提の Stripe SDK がそのままでは動作しないことを本番障害で学んだ経験があります。
この記事では、その経験から得た設計思想と実装ボイラープレートを、明日から使える形でまとめました。

延命医の診断

「Stripe を選んだ」のではない

Stripe を選んだのではない。

販売主体を自分に置いた。

その結果として Stripe になった。

MoR モデル(Paddle / Lemon Squeezy)は税務処理・消費税対応・インボイス発行を代行してくれる。しかし価格設定の自由度、API の細粒度、カスタムフローの実装可能性が制約される。ClaudeMix のような「1ヶ月/1年プランの使い分け × 購読停止・再開フロー」を自前で管理するには、販売主体として税務処理を自分でハンドルする代わりに、Stripe の Subscription API の細粒度を取ることが合理的だった。

PSP と MoR という2つの地平

決済インフラの選択は、まずこの2分類から始める。

分類 代表サービス 販売主体 税務処理 API の自由度
PSP Stripe 自社 自社が管理 高い
MoR Paddle / Lemon Squeezy サービス側が代行 代行してくれる 制約あり

【あなたは販売主体になるか?】

YES → PSP(Stripe)
NO  → MoR(Paddle / Lemon Squeezy)

この質問に答えられないなら、まだ決済を実装する段階ではない。

この問いに答えてから、Stripe を選ぶ。

Stripe の Web 標準準拠と Edge Runtime 対応

PSP の中で Stripe を選ぶ理由は、Cloudflare の Edge Runtime との構造的な整合性にある。Stripe は Stripe.createFetchHttpClient() という Fetch API ベースのクライアントを公式提供しており、Edge Runtime での動作が構造的に保証されている。他の PSP では Node.js 専用 SDK しか提供されていないケースが多く、Edge 移植コストが別途発生する。

Stripe はバージョン付きエンドポイントを長期サポートし、PCI DSS Level 1 認定によるセキュリティ基盤の安定性も、5〜10年スパンで見た長期生存の根拠になる。

2024年の地殻変動

2024年、Stripe は Lemon Squeezy を買収した。PSP の王者が MoR プレイヤーを傘下に収めた形だ。

Stripe (PSP)
Stripe (MoR) ← 旧 Lemon Squeezy

世界は今、Stripe による PSP/MoR 両取り戦略に向かっている。この文脈を知った上で Stripe を選ぶと、単なる「実装の利便性」を超えた選択の意味が見えてくる。

選定理由

PSP を選んだ根拠

MoR モデルの不採用理由は「税務処理が面倒だから」ではなく、【設計の自由度の問題】だ。

サービス 分類 不採用理由
Paddle MoR 税務処理は代行されるが、価格設定・プラン切り替え・Webhook のカスタマイズに制約がある
Lemon Squeezy MoR Paddle 同様。加えて日本国内での実績が薄く、長期生存リスクがある(Stripe に買収済み)
PayPal PSP 開発者体験が悪く SDK が古い。Webhook の信頼性と Edge Runtime 対応が不明確
Square PSP 実店舗決済が主軸。サブスクリプション API のエコシステムが薄い

なぜ Stripe(PSP)が Cloudflare Workers に合うか

PSP の中で Stripe を選ぶ決定的な理由は【Edge Runtime との構造的整合性】だ。

Stripe は Stripe.createFetchHttpClient() を公式提供しており、Cloudflare Workers の Fetch API ベースの実行環境と直接つながる。他の PSP が Node.js の http モジュールに依存した SDK しか提供しない中、Stripe だけが Edge Runtime での動作を構造的に保証している。

Webhook フロー全体像

Stripe は【イベント発生装置】として機能する。決済の実態は Cloudflare の設計の中にある。

ユーザー

Stripe Checkout(PCI DSS スコープを Stripe に委任)

payment_intent.succeeded / customer.subscription.created 等

Webhook → Cloudflare Worker(署名検証 + 冪等性ガード)

Cloudflare D1(サブスク状態を更新)

ユーザーのアクセス権が有効化

Stripe の記事は山ほどある。しかし Cloudflare Workers × Stripe × D1 の実運用構成はほとんど公開されていない。このフローの実装がこの記事の本体だ。

構造的メリット

  1. Webhook による非同期ステータス管理: 決済完了・更新・失敗・キャンセルを単一の Webhook エンドポイントで一元管理できます。ポーリングが不要で、Cloudflare Workers の CPU 時間制限の制約にも優しい設計です。

  2. Checkout Session による PCI DSS スコープの外部化: カード番号を自社サーバーに受け渡さず、Stripe の Hosted Payment Page に委任することで、PCI DSS のスコープをほぼゼロにできます。AI 駆動開発でセキュリティ実装を最小化したい場合に特に有効です。

  3. Stripe CLI によるローカル Webhook テスト: stripe listen コマンドで本番と同一の Webhook フローをローカルで再現できます。Cloudflare Workers のローカル開発サーバー(wrangler dev)と組み合わせることで、デプロイなしにエンドツーエンドの検証が可能です。

Cloudflare Workers での実装パターン

落とし穴 1:httpClient の指定が必須

Cloudflare Workers は Node.js ランタイムではなく、Web API(Fetch API)ベースの Edge Runtime です。
Stripe の Node.js SDK は内部的に http / https モジュールを使おうとするため、明示的に Fetch ベースのクライアントを指定しないとランタイムエラーになります。

// ❌ NG: Cloudflare Workers では動作しない
const stripe = new Stripe(env.STRIPE_SECRET_KEY)

//  OK: fetchHttpClient を明示指定
const stripe = new Stripe(env.STRIPE_SECRET_KEY!, {
  apiVersion: '2025-12-15.clover',
  typescript: true,
  httpClient: Stripe.createFetchHttpClient(), // これが必須
})

落とし穴 2:Webhook 署名検証は request.text() が先

Stripe の Webhook 署名検証は Raw ボディ(文字列)を必要とします。
React Router の request.json()request.formData() でボディをパースした後では検証が失敗します。
text() を先に呼ぶことで、Raw ボディを保持したまま署名検証に渡せます。

export async function action({ request, context }: ActionFunctionArgs) {
  const signature = request.headers.get('stripe-signature')
  if (!signature) {
    return json({ error: 'Missing stripe-signature header' }, { status: 400 })
  }

  // ここが重要: json() や formData() を呼ぶ前に text() でボディを取得
  const payload = await request.text()

  const event = stripe.webhooks.constructEvent(
    payload,                     // Raw 文字列をそのまま渡す
    signature,
    env.STRIPE_WEBHOOK_SECRET!
  )
}

落とし穴 3:API バージョン 2025-12-15.clover での破壊的変更

Stripe の API バージョン 2025-12-15.clover では、current_period_start / current_period_end が Subscription オブジェクトのルートから items.data[0] 配下に移動しました。
移行期には両方を参照するフォールバックパターンが安全です。

//  フォールバックパターン(移行期の安全策)
const periodStart =
  subscription.current_period_start ??
  subscription.items?.data?.[0]?.current_period_start

const periodEnd =
  subscription.current_period_end ??
  subscription.items?.data?.[0]?.current_period_end

冪等性ガード:同一イベントの二重処理を防ぐ

Stripe の Webhook は「最低1回配信(at-least-once delivery)」を保証します。
つまり、同じイベントが複数回届く可能性があります。
冪等性ガードなしでは、ユーザーのサブスクリプションが二重に有効化されたり、キャンセル処理が重複するリスクがあります。

// D1 の webhook_events テーブルでイベント ID の重複チェック
const alreadyProcessed = await isWebhookEventProcessed(
  event.id,
  context as CloudflareLoadContext
)

if (alreadyProcessed) {
  console.log(`Event ${event.id} already processed, skipping`)
  return json({ received: true, skipped: true })
}

// --- ここで実際の処理を行う ---

// 処理完了後にイベント ID を記録(次回の重複チェック用)
await recordWebhookEvent(event.id, event.type, context as CloudflareLoadContext)

ローカル開発環境のセットアップ

Webhook をローカルで受け取るには Stripe CLI の stripe listen コマンドが必須です。

# ターミナル1: Cloudflare Workers ローカルサーバーを起動
npm run dev:wrangler

# ターミナル2: Stripe Webhook をローカルに転送
stripe listen --forward-to localhost:3000/api/webhooks/stripe

stripe listen 起動時に表示される whsec_....dev.varsSTRIPE_WEBHOOK_SECRET に設定します。
この値は【本番の Webhook Secret とは別物】である点に注意してください。

# .dev.vars(ローカル専用。本番シークレットとは別の値)
STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxxxxx
STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxxxxx

環境変数の管理方針は以下のとおりです。

変数名 ローカル(.dev.vars) 本番(Workers Secrets)
STRIPE_SECRET_KEY sk_test_... sk_live_...
STRIPE_WEBHOOK_SECRET stripe listen が発行する whsec_... Stripe ダッシュボードで発行した whsec_...

公式リファレンス・更新履歴

認証・状態管理・メール・決済——Cloudflare ネイティブ層で必要な外部連携がすべて揃った。残る問いはフロントエンドの持続可能性だ。Workers の実行制約下でバンドルサイズをどう制御するか——CSS インフラの選定が次の課題になる。

あなたのリポジトリへの適用

どちらを採用しますか?(1つ選択)

SAFETY|安全性を高める

Stripe Webhookの冪等性ガードとRawボディ検証が実装されているか確認する

調査:

  1. Webhookエンドポイントで同じイベントが複数回処理されないよう冪等性ガードが実装されているか確認せよ
  2. Stripe-Signatureヘッダーの検証にRawボディが使われているか確認せよ
  3. Cloudflare Workers環境でRawボディの取得が正しく実装されているか確認せよ

この内容をClaude Codeに貼り付けて実行します

SEPARATION|責務を分離

PSP(Stripe)とアプリケーションロジックの境界が明確に設計されているか確認する

調査:

  1. Stripeへの呼び出しがアプリケーションロジックから分離されたサービス層に集約されているか確認せよ
  2. Stripe固有の型(StripeのオブジェクトID等)がアプリのドメイン型に漏れ出していないか確認せよ
  3. Stripe Checkout/Portalへのリダイレクト処理がルーター層で行われているか確認せよ

この内容をClaude Codeに貼り付けて実行します

外部コードのローカル実行にはリスクがあります。ブラウザ環境での実行を推奨します。