個人開発でアプリを作り始めた2014年ごろ、私の収益化はほぼ広告一本でした。壁紙アプリや癒し系のアプリに AdMob を入れて、表示回数とクリック率を眺める日々です。それはそれで成り立っていたのですが、ある時期から「ユーザーが本当に欲しい機能に、正面からお金を払ってもらう」形に作り替えたくなりました。広告は使ってもらうほど画面を邪魔し、課金は使ってもらうほど価値が増える。この非対称が、長く続けるアプリには効いてくると感じたためです。
Antigravity でアプリを組むようになってから、この「作り替え」はずいぶん速くなりました。課金まわりはバックエンド・クライアント・ストア審査が絡み合い、一人で全部を見るには重い領域です。ここでは Stripe を軸にした課金実装を、私自身が Dolice Labs のメンバーシップ運営でつまずいた箇所も交えながら、設計判断ごと残しておきます。コードは動く形で示し、なぜそう書くのかを必ず添えます。
収益化モデルの選択:サブスク・IAP・広告をどう束ねるか
Antigravity で開発するアプリは、単一のモデルに絞るより、複数の収益化モデルを役割分担させたほうが安定します。それぞれの性格を先に押さえておきます。
サブスクリプション型(月額課金)
予測可能な月間経常収益(MRR)を生み、ユーザーが継続的に価値を受け取るほど LTV が伸びます。その代わり、解約をどう減らすかが事業の生命線になります。生産性ツールやノート、ワークフロー系のように「毎日触る」アプリと相性が良いモデルです。
基本プラン(Free)
↓
スタンダードプラン($4.99/月)
↓
プレミアムプラン($9.99/月)
In-App Purchase(IAP)
ユーザーが必要な機能だけを個別に買える柔軟さが魅力です。一度の購入で長く価値を提供でき、App Store・Google Play のネイティブ決済に乗せられます。ゲーム内課金、コンテンツ販売、機能アンロックなど目的が広いのも利点です。
追加機能($0.99 〜 $19.99)
├─ 高度なフィルター機能
├─ 無制限クラウド保存
└─ カスタムテンプレート
ハイブリッド戦略
実務では、無料層でユーザーを獲得し、コア機能をサブスクで安定収益にし、尖った機能を IAP で個別販売する組み合わせが扱いやすいです。広告は無料層の補助に留めます。
Free ユーザー:基本機能 + 控えめな広告
Standard($3.99/月):無制限・チーム共有2人まで・広告なし
Premium($7.99/月):全機能 + AI アシスト + 優先サポート
私が課金モデルを選び直したときに効いた判断軸
Dolice Labs では、技術ブログ群に Stripe のメンバーシップを載せています。月額の Pro と、買い切りの Premium を並べた構成です。最初は「月額だけで十分だろう」と考えていたのですが、運用してみると、月額の更新を心理的に重く感じる読者が一定数いると分かりました。そこで買い切りの永久プランを足したところ、月額に踏み切れなかった層が動きました。
ここから私が学んだのは、価格表は「機能の階段」であると同時に「支払い方の選択肢」でもある、ということです。同じ価値でも、月額・買い切り・単品購入のどれで差し出すかで、財布の開き方が変わります。アプリの収益化でも、サブスク一本に決め打ちする前に、買い切りや単品 IAP を一枚挟めないかを必ず検討するようにしています。
もう一つ、無料記事をプレミアムへの入口(ファネル)にする運用を続けて痛感したのは、入口を削ると収益が止まるという当たり前の事実です。無料層は「コストを食う厄介者」ではなく、課金へ続く廊下です。アプリでも、無料層を細くしすぎると、そもそも有料への動線が痩せます。無料で十分役に立つ体験を用意したうえで、その先に自然な「もう一歩」を置く。この設計が、結局いちばん解約にも強いというのが、私の実感です。
Stripe 連携セットアップ
Antigravity アプリからの課金は、秘密鍵を握るサーバーサイドに寄せるのが安全の基本です。クライアントには公開鍵しか持たせません。
Stripe アカウント準備
stripe.com/jp でアカウントを作成し、API キー(公開鍵・秘密鍵)を取得します。Webhook エンドポイントを登録し、まず Test モードで通し、確認できてから Live モードへ移します。最初から本番鍵で触らないことが、地味ですが事故を防ぎます。
環境変数設定
サーバーの .env に鍵を置きます。クライアントのリポジトリには絶対に含めません。
STRIPE_SECRET_KEY=YOUR_STRIPE_SECRET_KEY
STRIPE_PUBLISHABLE_KEY=YOUR_STRIPE_PUBLISHABLE_KEY
STRIPE_WEBHOOK_SECRET=YOUR_STRIPE_WEBHOOK_SECRET
商品と価格の作成
Stripe では「商品(Product)」を作ってから、その下に各ティアの「価格(Price)」をぶら下げます。コードで一度に作っておくと、環境ごとの再現が楽になります。
// server/stripe-setup.ts
import Stripe from 'stripe' ;
const stripe = new Stripe (process.env. STRIPE_SECRET_KEY ! );
async function setupStripeProducts () {
const productStandard = await stripe.products. create ({
name: 'Standard Plan' ,
type: 'service' ,
});
const productPremium = await stripe.products. create ({
name: 'Premium Plan' ,
type: 'service' ,
});
await stripe.prices. create ({
product: productStandard.id,
unit_amount: 399 , // $3.99(セント単位)
currency: 'usd' ,
recurring: { interval: 'month' , interval_count: 1 },
});
await stripe.prices. create ({
product: productPremium.id,
unit_amount: 799 ,
currency: 'usd' ,
recurring: { interval: 'month' , interval_count: 1 },
});
console. log ( 'Stripe products and prices created.' );
}
setupStripeProducts (). catch (console.error);
サーバーサイド課金フロー
課金は「ボタンを押す → セッションを作る → Stripe で支払う → Webhook で確定 → 権限を上げる」という流れに分解できます。権限の付与は必ず Webhook 側、つまり支払いが確定した事実をサーバーが受け取った瞬間に行います。リダイレクト先の success_url に着いただけで権限を上げてはいけません。ここを取り違えると、支払わずに成功画面へ直接アクセスした人に権限が渡ってしまいます。
// server/routes/subscription.ts
import express from 'express' ;
import Stripe from 'stripe' ;
import { authenticateUser } from './middleware/auth' ;
const stripe = new Stripe (process.env. STRIPE_SECRET_KEY ! );
const router = express. Router ();
router. post ( '/create-subscription-session' , authenticateUser, async ( req , res ) => {
const { userId } = req.user;
const { priceId } = req.body;
try {
let customerId : string ;
const user = await db. getUser (userId);
if (user.stripeCustomerId) {
customerId = user.stripeCustomerId;
} else {
const customer = await stripe.customers. create ({
email: user.email,
metadata: { userId },
});
customerId = customer.id;
await db. updateUser (userId, { stripeCustomerId: customerId });
}
const session = await stripe.checkout.sessions. create ({
mode: 'subscription' ,
customer: customerId,
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${ process . env . APP_URL }/success?session_id={CHECKOUT_SESSION_ID}` ,
cancel_url: `${ process . env . APP_URL }/pricing` ,
});
res. json ({ sessionId: session.id });
} catch (error) {
res. status ( 500 ). json ({ error: (error as Error ).message });
}
});
export default router;
metadata に userId を入れておくと、後で Webhook が「どのユーザーの支払いか」を確実にたどれます。これは地味ですが、運用で何度も助けられる一手です。
Webhook の冪等性と二重計上の防止
ここが、私が個人開発で最初に痛い目を見た場所です。Stripe の Webhook は「少なくとも一度は届く」設計で、同じイベントが二度三度届くことがあります。受信のたびに無条件で「課金を1件加算」「権限を1段上げる」処理を書くと、ネットワークの再送やリトライで二重計上が起きます。
対策はシンプルで、受け取ったイベント ID を記録し、すでに処理済みなら何もしないことです。署名検証と組み合わせて、確定処理を一度きりにします。
// server/routes/webhook.ts
import Stripe from 'stripe' ;
const stripe = new Stripe (process.env. STRIPE_SECRET_KEY ! );
router. post ( '/webhook' , express. raw ({ type: 'application/json' }), async ( req , res ) => {
const sig = req.headers[ 'stripe-signature' ] as string ;
let event : Stripe . Event ;
// 1. 署名検証 — 偽のリクエストを弾く
try {
event = stripe.webhooks. constructEvent (
req.body,
sig,
process.env. STRIPE_WEBHOOK_SECRET !
);
} catch (err) {
return res. status ( 400 ). send ( `Webhook signature error: ${ ( err as Error ). message }` );
}
// 2. 冪等性 — 同じイベントを二度確定しない
const alreadyHandled = await db. hasProcessedEvent (event.id);
if (alreadyHandled) {
return res. json ({ received: true , duplicate: true });
}
try {
switch (event.type) {
case 'checkout.session.completed' : {
const session = event.data.object as Stripe . Checkout . Session ;
const userId = session.metadata?.userId;
if (userId) {
await db. updateUser (userId, { plan: 'premium' , status: 'active' });
}
break ;
}
case 'customer.subscription.deleted' : {
const sub = event.data.object as Stripe . Subscription ;
const userId = sub.metadata?.userId;
if (userId) {
await db. updateUser (userId, { plan: 'free' , status: 'canceled' });
}
break ;
}
}
// 3. 処理済みとして記録してから 200 を返す
await db. markEventProcessed (event.id);
res. json ({ received: true });
} catch (error) {
// 4. 失敗時は 500 を返し、Stripe に再送させる(記録はしない)
res. status ( 500 ). json ({ error: (error as Error ).message });
}
});
ポイントは処理順です。確定処理が成功してから markEventProcessed を呼び、最後に 200 を返します。途中で失敗したら 500 を返してわざと Stripe に再送させ、次の到達で改めて処理します。「先に処理済み記録 → あとで本処理」にすると、本処理が落ちたときにイベントが永遠に来なくなるので、順序を逆にしないことが肝心です。
App Store / Google Play IAP 実装
モバイルアプリでデジタル商品を売る場合、プラットフォームのネイティブ課金が必須になる場面が多くあります。ここでは StoreKit と Google Play Billing から購入を起こし、その結果をバックエンドで検証する形にします。検証を必ずサーバー側で行うのは、クライアントから来た「買いました」という申告だけを信じないためです。
iOS(App Store)IAP フロー
// client/ios/StoreKitPurchase.swift
import StoreKit
func purchasePremiumPlan () {
Task {
do {
let entitlements = try await AppStore. sync ()
if entitlements. contains ( where : { $0 .productID == "premium_monthly" }) {
await notifyBackendOfPurchase ( productId : "premium_monthly" )
}
} catch {
print ( "Purchase failed: \( error ) " )
}
}
}
Android(Google Play)IAP フロー
// client/android/GooglePlayIAP.java
billingClient = BillingClient. newBuilder (context)
. setListener (purchasesUpdatedListener)
. enablePendingPurchases ()
. build ();
billingClient. startConnection ( new BillingClientStateListener () {
@ Override
public void onBillingSetupFinished (BillingResult result ) {
if (result. getResponseCode () == BillingClient.BillingResponseCode.OK) {
queryPrices ();
}
}
@ Override
public void onBillingServiceDisconnected () { /* retry */ }
});
// 購入完了時はバックエンドへ purchaseToken を送り、サーバーで検証する
PurchasesUpdatedListener purchasesUpdatedListener = (result, purchases) -> {
if (result. getResponseCode () == BillingClient.BillingResponseCode.OK && purchases != null ) {
for (Purchase purchase : purchases) {
verifyPurchaseWithBackend (purchase. getPurchaseToken ());
}
}
};
サーバー側での検証
// server/routes/verify-iap.ts
router. post ( '/verify-google-play-iap' , async ( req , res ) => {
const { purchaseToken , userId , productId } = req.body;
try {
const response = await axios. get (
`https://androidpublisher.googleapis.com/androidpublisher/v3/applications/${ process . env . PACKAGE_NAME }/subscriptions/${ productId }/tokens/${ purchaseToken }` ,
{ headers: { Authorization: `Bearer ${ process . env . GOOGLE_PLAY_API_TOKEN }` } }
);
const { purchaseState } = response.data;
if (purchaseState === 0 ) { // PURCHASED
await db. updateUser (userId, {
plan: productId. includes ( 'premium' ) ? 'premium' : 'standard' ,
source: 'google_play' ,
iapPurchaseToken: purchaseToken,
});
res. json ({ success: true });
} else {
res. status ( 400 ). json ({ error: 'Invalid purchase state' });
}
} catch (error) {
res. status ( 500 ). json ({ error: (error as Error ).message });
}
});
Stripe と IAP の状態を二重管理しない
サブスクと IAP を両方サポートすると、必ず突き当たるのが「ユーザーの課金状態が二つの場所に分かれる」問題です。Stripe にも「premium」、Google Play にも「premium」が立ち、片方を解約しても、もう片方が生きているとアプリは有料のまま。逆に、両方から別々に「解約された」通知が来て、本来まだ有効な権利まで落としてしまうこともあります。
私が落ち着いた設計は、ユーザーの権限を判定する「真実の場所」をアプリ側の DB に一本化し、Stripe も IAP も「その DB を更新するための入力」として扱う、というものです。各経路には source を必ず持たせ、どこから来た権利かを区別します。解約イベントが来たら、その source の権利だけを失効させ、他の経路の権利は触りません。
// server/lib/entitlement.ts
type Source = 'stripe' | 'app_store' | 'google_play' ;
// 権限は「有効な経路が一つでもあれば premium」とみなす
async function recomputeEntitlement ( userId : string ) {
const grants = await db. getActiveGrants (userId); // source ごとの有効レコード
const isPremium = grants. some (( g ) => g.plan === 'premium' && g.status === 'active' );
await db. updateUser (userId, { plan: isPremium ? 'premium' : 'free' });
}
// あるソースの解約は、そのソースの grant だけを失効させる
async function revokeGrant ( userId : string , source : Source ) {
await db. expireGrant (userId, source);
await recomputeEntitlement (userId);
}
この「経路ごとに記録し、最後に合算して判定する」形にしてから、二重計上も誤失効もぴたりと止まりました。課金経路が増えても、足し算の対象が一つ増えるだけで済みます。
セルフサーブ解約と Customer Portal
解約を減らしたいあまり、解約導線を隠したくなる気持ちは分かります。けれど私の実感は逆で、解約を簡単にしておくほうが、結果的に信頼が残り、戻ってきてくれる人も増えます。Stripe には Customer Portal があり、プラン変更・支払い方法の更新・解約をユーザー自身に委ねられます。問い合わせ対応の手間が減るうえ、ユーザーは自分の財布を自分で管理できる安心感を得ます。
// server/routes/portal.ts
router. post ( '/create-portal-session' , authenticateUser, async ( req , res ) => {
const user = await db. getUser (req.user.userId);
const session = await stripe.billingPortal.sessions. create ({
customer: user.stripeCustomerId,
return_url: `${ process . env . APP_URL }/account` ,
});
res. json ({ url: session.url });
});
解約理由を任意で一言もらえるようにしておくと、次に直すべき箇所が見えてきます。引き止めるための仕掛けより、こうした「静かな改善のための耳」のほうが、長い目では効きます。
ハイブリッド収益の試算(あくまで設計の目安)
最後に、複数層を組み合わせたときの収益感を、仮の数字で置いてみます。これは実績ではなく、設計時に「どの層をどれだけ厚くするか」を考えるための試算です。
層 想定 月間売上の目安
Free (広告)2,000人 × $0.50 $1,000
Standard ($3.99)2,000人 $7,980
Premium ($7.99)500人 $3,995
IAP (単品)月1,000件 $2,000
数字そのものより、見てほしいのは比率です。多くのアプリで、売上の大半はごく一部の有料ユーザーが支えます。だからこそ、無料層を入口として丁寧に設計し、課金層には課金して良かったと思える密度を返す。この往復が崩れない限り、表の数字はあとから付いてきます。
重要指標の見方
収益化を最適化するには、いくつかの指標を継続して見る必要があります。すべてを毎日追う必要はなく、転換率(Free → Paid)と解約率の二つを軸にすると、施策の効き目が判断しやすくなります。
指標 定義 目安
Conversion Rate 無料から有料への転換率 数%が一つの目安
Churn Rate 月間解約率 低いほど良い
LTV / CAC 生涯価値 ÷ 獲得コスト 3倍以上が健全
MRR 月間経常収益 右肩上がりを維持
次の一歩として、まずは Webhook の冪等性だけ先に実装することをおすすめします。収益が乗り始めてから二重計上に気づくと、過去の整合性を取り直す作業がとても重くなるためです。土台が固まってから、層を一つずつ足していけば十分です。同じように個人で課金を組んでいる方の、遠回りを少しでも減らせたら嬉しく思います。