サブスク課金をWebだけに寄せた理由——iOS/Androidのアプリ内課金を捨てた設計と事業判断

E-SpaceはiOSとAndroidにIn-App Purchase(IAP)を実装していない。課金はWebのStripe一本だ。コスト・俊敏性・アーキテクチャの3つの理由と、IAPがWeb課金を上回る反証データ3点を交えて、その判断を記録する。

E-Spaceの課金は、WebのStripe一本だ。iOSとAndroidにIn-App Purchase(IAP)を実装していない。App Store・Google Playへの手数料を払わず、Stripe Billingで課金を一元管理している。


IAPを捨てた3つの理由

1. コストの非対称性

AppleのApp Store手数料は標準30%。Small Business Program(年間売上がUSD 100万未満の開発者が加入)では15%に下がる(Apple、Schedule 2 and 3 v126、2025年12月17日更新)。Google Playは年間USD 100万未満の最初の売上に対し15%、超過分は30%(Google Play Console Help、2021年7月施行)。

"The program features a reduced commission rate of 15% on paid apps and In-App Purchases, so you can invest more resources into your business to continue building quality apps that customers love."

— Apple App Store Small Business Program(developer.apple.com)

Appleは15%を「恩恵」として提示している。標準が30%であり、これは例外だ。

StripeのJapan向け料金はカード決済手数料3.6%+Stripe Billing手数料0.7%で実効~4.3%(Stripe Japan公式料金ページ、2024年7月10日統合プラン以降)。初期費用も月額固定費もない。

Apple Small Business(15%)とStripe(~4.3%)の差は約10.7ポイントだ。E-SpaceのPremiumプラン(¥4,980/月)では1件あたり約¥533の差になる。100教室がPremiumに加入した月は5.3万円、1年で約64万円になる。この差はチームの規模感では無視できない。

2. 価格変更にApp Storeレビューが不要

IAPで価格改定する場合、App Store ConnectとGoogle Play Consoleで商品情報を更新し、審査を待つ必要がある。Stripeなら、ダッシュボードでprice_idを更新すれば即日反映できる。

E-SpaceはFree / Basic(¥980)/ Premium(¥4,980)/ Unlimited(¥9,800)の4プランを持つ。初期フェーズで価格設計を走りながら調整するとき、「今日決めて今日から適用」という判断が何度もあった。IAPの審査待ちとは相性が悪い。

3. 課金ロジックを1系統に集約する

IAPをフルサポートすると、課金の実装が3系統に分岐する。

IAPをフルサポートするとiOS・Android・Webで課金ロジックが3系統に分岐しサーバ側も3系統に対応が必要になるのに対し、E-SpaceはStripe一本に集約しWebhookがFirestoreのsubscriptionPlanを更新して全プラットフォームが読むだけの構成にしていることを比較した図
  • iOS: StoreKit 2でpurchase・restore・receipt validation
  • Android: Google Play Billing Library(BillingClient、ProductDetails、PurchaseState)
  • Web: Stripe(Customer・Subscription・Webhook)

各プラットフォームで購入・復元・失効・キャンセル・リトライのステートマシンを書き、サーバー側でAppleのApp Store Server APIとGoogleのAndroidPublisher APIそれぞれに対応しなければならない。

Stripe一本に絞ると、課金ロジックはWebコンソール(Next.jsのAPI Route)に集約される。iOSとAndroidはFirestoreのsubscriptionPlanを読んで機能フラグを制御するReader Appとして実装し、購入フローを持たない。StripeのWebhookがFirestoreを更新し、全プラットフォームがそれを読む。


反証データ:Web課金が万能でない3つのファクト

「WebのStripeが常に正しい」は過信だ。業界データに反証が3点ある。これを先に出しておく。

反証①:IAPのコンバージョンはWebリンクアウトより高い

RevenueCatがDipseaアプリでA/Bテストを実施した結果、IAP経由のコンバージョン率は約28%、Webリンクアウト経由は約18%で、相対的に25〜45%の低下があった(Daring Fireball、2025年5月15日、RevenueCatレポートより)。手数料差(~10.7ポイント)がコンバージョン低下(~10ポイント)で部分的に相殺される。損益分岐点はかなり近い。

Apple Small Businessの15%とStripe実効4.3%の手数料差は約10.7ポイントである一方、IAP経由のコンバージョンは約28%、Webリンクアウト経由は約18%で、手数料の節約分がコンバージョン低下でほぼ相殺されうることを示した図

反証②:App-to-Web実装直後のコンバージョン低下

SuperwallがEpic v. Apple判決後にApp-to-Webを実装したところ、初期コンバージョンが11.9%低下した(Superwall、2025年5月7日)。Superwallは「30%増収を期待するなら失望する」と明言している。なお、Kompanionは同時期にコンバージョン微増を記録しており、結果は製品固有だ。

反証③:App Storeは発見チャネルとしての価値を持つ

BtoCで自力のWebマーケティング基盤を持たない小規模アプリにとって、App Storeはゼロコストの発見経路だ。15〜30%の手数料はApp Storeへの実質的な広告費として機能する。Web課金への誘導はApp Store ASO経由の新規ユーザーには摩擦増大になり、手数料節約分を超える機会損失が出うる(RevenueCat State of Subscription Apps 2026)。


なぜE-Spaceには当てはまらないのか

3点の反証はいずれも「消費者向けアプリ」「BtoCの衝動購買」「App Store発見依存」を前提にしている。E-Spaceは3点とも該当しない。

購買者は塾の運営者(管理者)であり、消費者ではない。月額¥980〜¥9,800のBtoBサービスの購入判断はデスクトップのWebコンソール(console.e-space.app)で行われ、スマホのIAPで衝動的に完結するシーンはほぼない。B2B SaaSの購買行動では「ブラウザを開いて契約を結ぶ」プロセスは期待値であり、摩擦ではない。

新規獲得はSEOブログ・営業・口コミが主で、App Store検索経由の比率は小さい。反証③が前提とするApp Store発見依存も当てはまらない。


Stripe実装の実録——SaaS課金アーキテクチャの全体像

課金の実装はWebコンソール(Next.js App Router)のAPI Routeに集約している。/api/stripe/* のサーバ側ルートから Firebase Admin SDK でFirestoreを更新する構成だ。使っているのはNode.js版のStripe SDK(stripe)で、APIバージョンは 2026-02-25.clover に固定している。固定しておかないと、Stripe側のAPI更新でレスポンス構造が変わったときに黙って壊れる。

教室管理者がWebコンソールでプランを選ぶと、Customers APIで顧客を作成しCheckout Sessions APIでStripeのHosted Checkoutへ遷移し、決済後のWebhookで署名検証と冪等チェックを経てFirestoreのsubscriptionPlanが更新され、iOS・Android・Webの全端末に反映されるまでの流れを示した図

使っているStripe APIとイベント

E-Spaceが実際に呼んでいるAPIは5つ、受け取っているイベントは6種類だ。公式ドキュメントと合わせて並べておく。

API用途ドキュメント
customers.create 教室ごとのStripe顧客を作成し、metadata.classRoomId で教室と紐付ける Create a customer
checkout.sessions.create 新規契約。Stripeがホストする決済ページのURLを発行する Create a Checkout Session
billingPortal.sessions.create 解約・支払い方法の変更・請求書の閲覧をStripeのホスト画面に任せる Create a portal session
subscriptions.update コンソール内からのプラン変更。日割り計算を有効にして即時反映する Update a subscription
webhooks.constructEvent 受信したWebhookの署名を検証し、改ざんされていないことを確認する Webhooks

1. 新規契約——顧客を先に作ってからCheckout Sessionを発行する

Checkout Sessionは customer_email を渡すだけでも作れるが、E-Spaceでは先に Customers API で顧客レコードを作り、そのIDを渡している。

// /api/stripe/checkout
// 初回のみ顧客を作成し、classRoomId を metadata に持たせる
const customer = await stripe.customers.create({
  metadata: { classRoomId },
})
await classRoomRef.update({ stripeCustomerId: customer.id })

const session = await stripe.checkout.sessions.create({
  mode: 'subscription',
  customer: customer.id,                        // customer_email ではなく Customer ID
  line_items: [{ price: priceId, quantity: 1 }],
  metadata: { classRoomId },
  allow_promotion_codes: true,
  subscription_data: { trial_period_days: 30 }, // トライアル未使用の教室のみ
  success_url: `${APP_URL}/settings/billing?success=1`,
  cancel_url: `${APP_URL}/settings/billing`,
})

顧客レコードを先に作る理由は、後段のWebhookで「このイベントはどの教室のものか」を必ず解決できるようにするためだ。metadata.classRoomId と、Firestoreに保存した stripeCustomerId からの逆引きという2つの経路が用意でき、どちらかが欠けても教室を特定できる。メールアドレスだけを頼りにすると、管理者がメールを変えた時点で紐付けが切れる。

trial_period_daysSubscription trials の仕組みで、有料プラン1ヶ月の無料トライアルにあたる。ただし無条件では付けない。Firestore側に hasUsedTrial を持ち、一度でもトライアルを使った教室にはこのパラメータ自体を渡さない。プランを行き来してトライアルを繰り返し取得されるのを防ぐためだ。

2. プラン変更・解約——自前UIを作らない

解約・支払い方法の変更・領収書のダウンロードは、Billing Portal にそのまま任せている。自前で作ると、カード更新・失敗時のリトライ・請求書のPDF出力まで面倒を見ることになる。

// /api/stripe/portal
const portalSession = await stripe.billingPortal.sessions.create({
  customer: stripeCustomerId,
  return_url: `${APP_URL}/settings/billing`,
})

// /api/stripe/subscription/update — コンソール内でのプラン変更のみ自前で持つ
await stripe.subscriptions.update(subscriptionId, {
  items: [{ id: subscriptionItemId, price: priceId }],
  proration_behavior: 'create_prorations',
})

プラン変更だけは自前のAPIを用意している。塾の生徒数は年度替わりで動くので、アップグレードの導線はコンソール内で完結させたい。proration_behavior: 'create_prorations' を指定すると、残期間を日割りして差額を次回請求に載せてくれる(Prorations)。ここを自前で計算しようとすると、月の途中の変更・トライアル中の変更・返金の扱いで確実に事故る。

3. Webhook——署名検証と冪等性が本体

課金ステータスの正はStripe側にあり、その変化はWebhookで受け取る。ここが実装の本体だ。

Webhook受信後に署名検証を行い、失敗すれば400を返し、続いてevent.idをキーにFirestoreのstripe_eventsコレクションで処理済みかを確認し、処理済みなら200でスキップ、未処理ならハンドラを実行して成功時にprocessedAtを記録し、例外時は500を返してStripeに再送させる流れを示した図

まず署名検証。Next.jsのApp Routerでは、request.json() ではなく request.text() で生のボディを取る必要がある。パース済みのオブジェクトを再度文字列化したものでは署名が一致しない。

// /api/stripe/webhook
const rawBody = await request.text()   // JSON パース済みボディでは署名が合わない
const signature = request.headers.get('stripe-signature')

const event = stripe.webhooks.constructEvent(
  rawBody,
  signature,
  process.env.STRIPE_WEBHOOK_SECRET,
)

次に冪等性。Stripeのイベント配送はat-least-onceで、同じイベントが複数回届きうる(WebhooksIdempotent requests)。プラン更新自体は冪等なので二重に走っても結果は同じだが、購買履歴のログとSlack通知は二重に積み上がる。event.id をドキュメントIDにした stripe_events コレクションをFirestoreに置き、トランザクションでゲートしている。

const ref = adminDb.collection('stripe_events').doc(event.id)

const alreadyProcessed = await adminDb.runTransaction(async (tx) => {
  const snap = await tx.get(ref)
  // 処理済み(processedAt あり)ならスキップ、失敗済みなら再処理を許可
  if (snap.exists && snap.data()?.processedAt != null) return true
  // 未処理なら pending として作成し、そのまま処理へ進む
  return false
})

if (alreadyProcessed) {
  return NextResponse.json({ received: true, deduplicated: true })
}

ハンドラが例外を投げたら、ドキュメントに status: 'failed' を記録して500を返す。Stripeは5xxを受け取ると指数バックオフで再送してくれるので、失敗したイベントは次の配送で自動的に再処理される。逆に、ここで握りつぶして200を返すと、課金だけ通ってプランが上がらない教室が静かに生まれる。

受け取っているイベント

購読しているイベントは6種類だ(定義は Types of events、サブスクリプション運用での使い分けは Subscription webhooks にまとまっている)。

  • checkout.session.completed:決済完了。metadata.classRoomId から教室を引き、stripeSubscriptionId とプランを書き込む
  • customer.subscription.created / updated:状態変化の正。trialing / active / canceled / unpaid をFirestoreの subscriptionPlan にマッピングする
  • customer.subscription.deleted:契約終了。subscriptionPlan を0(Free)に戻す
  • customer.subscription.trial_will_end:トライアル終了前の予告。本課金の前に連絡する導線に使う
  • invoice.paid:継続課金の成功。プラン更新は subscription.updated に任せ、ここでは購買履歴として記録するだけ
  • invoice.payment_failed:決済失敗。Slackに飛ばして人が追える状態にする

設計上のポイントは「書き込みの正をどれか1つに決める」ことだ。checkout.session.completedcustomer.subscription.updated はどちらもプラン確定の情報を持っているが、正は後者に置いている。前者は「決済が終わった瞬間にUIへ反映したい」ための先行書き込みで、最終的な状態は必ず subscription.updated が上書きする。両方を対等な書き込み元にすると、到着順が入れ替わったときに古い状態で上書きされる。

4. アプリ側は読むだけ

iOSとAndroidはFirestoreのsubscriptionPlanを読んで機能フラグを制御するだけだ。購入UIを持たないため、App Store Guideline 3.1.3(a)が定めるReader Appの要件を満たす。

この構成の効用は、課金の不具合調査が1箇所で済むことにある。「プランが上がらない」という問い合わせが来たとき、見るのはStripeダッシュボードのイベントログと stripe_events コレクション、そして class_rooms の該当ドキュメントだけだ。StoreKitのレシートとPlay Billingの購入トークンを突き合わせる作業は発生しない。

なお、2025年12月18日に完全施行された「スマートフォン特定ソフトウェア競争促進法」(2024年6月12日可決)は、Apple・Googleに対してサードパーティ決済の利用禁止を禁止している(公正取引委員会、ガイドライン2025年7月公表)。アプリ内でWebへの課金誘導を明示することが法的に許容されたが、E-SpaceのBtoB構造でこれが必要になるシーンは限定的だ。


結論

「Web課金一本」は常に正しい選択ではない。RevenueCatのA/Bテスト(IAP 28% vs Web 18%)とSuperwall(11.9%低下)が示す通り、消費者向けアプリでWeb誘導すると10〜45%のコンバージョン低下が起きうる。手数料差(~10.7ポイント)は、コンバージョン低下でほぼ相殺される可能性がある。

Apple Small Business(15%)とStripe(~4.3%)の差は約10.7ポイントで、100教室規模でも年64万円以上になる。この差はチームの規模感では無視できない。それでも、この判断はE-Spaceの文脈——購買者が塾運営者であること、発見経路がApp Store ASO依存でないこと——があって初めて正当化できる。誰もが真似すべき選択ではない。

E-Space

E-Spaceのエンジニアチームへ

iOS(SwiftUI / Swift 6.2)・Android(Kotlin / Jetpack Compose)・Web(Next.js)の3プラットフォームを少人数で動かしています。こういう判断を一緒にしていく仲間を探しています。

採用情報を見る

関連記事

2026.06.23 / 運営ノウハウ

塾管理アプリのマルチデバイス同期 — iPhone・Android・PCで同じ予定を

2026.06.09 / 運営ノウハウ

塾の入退室管理アプリと保護者への自動通知——安全確認をシステム化する仕組み