背景と前提
Rorkで開発したアプリも、収益化がなければ持続可能なビジネスにはなりません。最も堅牢で実績のある決済ゲートウェイであるStripe を使えば、わずか数時間でプロダクション級のサブスクリプション機能を実装できます。
このガイドでは、Rorkアプリに Stripe を統合し、サブスク料金を徴収・管理・分析するまでの完全なワークフローを、実装コード付きで解説します。
Stripe サブスクリプションの基本概念
Stripe の3つの課金モデル
| モデル | 用途 | 実装難易度 |
| ワンタイム課金 | 有料版へのアップグレード、付加機能購入 | ★☆☆ |
| サブスクリプション | 定期的な課金(月額、年額) | ★★☆ |
| 使用量ベース課金 | API 呼び出し数や容量に応じた動的課金 | ★★★ |
ここで整理するのはサブスクリプション(定期課金) に焦点を当てます。
Stripe の3つの価格設定パターン
Basic Plan:
名前: "Basic"
月額: $9.99
機能: 10記事まで保存、広告あり
Stripe Price ID: "price_1MqAaBxxxxxxxxx"
Pro Plan:
名前: "Pro"
月額: $19.99
機能: 無制限保存、広告なし、API アクセス
Stripe Price ID: "price_2NqBbByyyyyyyyy"
Premium Plan:
名前: "Premium"
月額: $49.99
機能: Pro + 優先サポート、エクスポート機能
Stripe Price ID: "price_3OqCcCzzzzzzzz"
ステップ 1: Stripe アカウント設定
1.1 Stripe ダッシュボードで計画を作成
- Stripe ダッシュボード にログイン
- Billing → Products に移動
- + Create Product をクリック
- 以下の情報を入力:
Product Name: "My App - Pro Plan"
Description: "Unlimited access, ad-free experience"
Type: Service ✓
Recurring: Monthly ✓
- 保存後、Pricing タブから月額料金を設定(例:$19.99)
重要: 各プランの Price ID をメモしておきます。これは後のコード内で使用します。
1.2 Webhook エンドポイントを登録
Webhook は、ユーザーが課金イベント(初回課金、更新、キャンセル)を発生させたときに、Stripe があなたのバックエンドに通知する仕組みです。
-
Stripe ダッシュボード Developers → Webhooks
-
+ Add endpoint をクリック
-
エンドポイントURL: https://yourapp.com/api/webhooks/stripe
-
監視対象イベント:
customer.subscription.created
customer.subscription.updated
customer.subscription.deleted
invoice.paid
invoice.payment_failed
-
Create endpoint をクリック
-
表示される Signing Secret をコピー(whsec_... の形)
ステップ 2: フロントエンド実装(React/React Native)
2.1 サブスク計画の表示
import { useEffect, useState } from 'react';
export function PricingPlans() {
const [plans, setPlans] = useState([]);
useEffect(() => {
// バックエンドから計画一覧を取得
fetch('/api/stripe/plans')
.then(res => res.json())
.then(data => setPlans(data))
.catch(err => console.error('Failed to load plans:', err));
}, []);
const handleSubscribe = async (priceId) => {
// ユーザーを Checkout セッションへ遷移
const response = await fetch('/api/stripe/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ priceId })
});
const { sessionId } = await response.json();
// Stripe Checkout へリダイレクト
window.location.href = `https://checkout.stripe.com/pay/${sessionId}`;
};
return (
<div className="pricing-grid">
{plans.map(plan => (
<div key={plan.id} className="pricing-card">
<h2>{plan.name}</h2>
<p className="price">
${plan.price_cents / 100} / 月
</p>
<ul className="features">
{plan.features.map(feature => (
<li key={feature}>{feature}</li>
))}
</ul>
<button
onClick={() => handleSubscribe(plan.price_id)}
className="btn-subscribe"
>
今すぐ登録
</button>
</div>
))}
</div>
);
}
// Expected output:
// ┌─────────────────┬─────────────────┬─────────────────┐
// │ Basic │ Pro │ Premium │
// │ $9.99 / month │ $19.99 / month │ $49.99 / month │
// │ ・保存数: 10 │ ・無制限保存 │ ・Pro のすべて │
// │ ・広告あり │ ・広告なし │ ・優先サポート │
// │ [登録] │ [登録] │ [登録] │
// └─────────────────┴─────────────────┴─────────────────┘
2.2 サブスク状態を持つユーザー情報コンポーネント
import { useEffect, useState } from 'react';
export function UserSubscriptionStatus() {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch('/api/user/subscription-status')
.then(res => res.json())
.then(data => {
setUser(data);
setLoading(false);
});
}, []);
if (loading) return <p>Loading...</p>;
if (!user) return <p>Please log in first</p>;
return (
<div className="user-subscription">
<h2>Your Subscription</h2>
{user.subscription ? (
<>
<p>
Plan: <strong>{user.subscription.plan_name}</strong>
</p>
<p>
Status: <strong>
{user.subscription.status === 'active' ? '✓ Active' : '⚠ ' + user.subscription.status}
</strong>
</p>
<p>
Next billing: {new Date(user.subscription.next_billing_date).toLocaleDateString()}
</p>
<button onClick={() => handleCancelSubscription(user.subscription.id)}>
サブスク解除
</button>
</>
) : (
<p>You are currently on the Free plan. <a href="/pricing">Upgrade now</a></p>
)}
</div>
);
}
const handleCancelSubscription = async (subscriptionId) => {
const confirmed = window.confirm('解除してもよろしいですか?');
if (!confirmed) return;
await fetch('/api/stripe/cancel-subscription', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ subscriptionId })
});
alert('サブスクリプションが解除されました。');
window.location.reload();
};
// Expected output:
// ┌──────────────────────────────────┐
// │ Your Subscription │
// │ Plan: Pro │
// │ Status: ✓ Active │
// │ Next billing: Mar 28, 2026 │
// │ [サブスク解除] │
// └──────────────────────────────────┘
ステップ 3: バックエンド実装(Node.js + Express)
3.1 Stripe クライアント初期化
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
apiVersion: '2023-10-16'
});
3.2 Checkout セッション作成エンドポイント
// POST /api/stripe/checkout
app.post('/api/stripe/checkout', async (req, res) => {
const { priceId } = req.body;
const userId = req.user.id; // 認証ユーザーから取得
try {
const session = await stripe.checkout.sessions.create({
payment_method_types: ['card'],
customer_email: req.user.email,
line_items: [
{
price: priceId,
quantity: 1
}
],
mode: 'subscription',
success_url: `${process.env.APP_URL}/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.APP_URL}/pricing`,
// 重要: メタデータでユーザー情報を紐付け
metadata: {
userId: userId
}
});
res.json({ sessionId: session.id });
} catch (error) {
console.error('Checkout error:', error);
res.status(500).json({ error: error.message });
}
});
// Expected output:
// POST /api/stripe/checkout
// Request: { "priceId": "price_1MqAaBxxxxxxxxx" }
// Response: { "sessionId": "cs_test_aBcDeFgHiJkLmNoPqRsT..." }
3.3 Webhook ハンドラー(重要!)
import express from 'express';
import bodyParser from 'body-parser';
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
// Webhook 用エンドポイント
// ⚠️ 通常の JSON パーサーではなく raw を使用(Stripe の署名検証に必須)
app.post(
'/api/webhooks/stripe',
bodyParser.raw({ type: 'application/json' }),
async (req, res) => {
const sig = req.headers['stripe-signature'];
let event;
try {
event = stripe.webhooks.constructEvent(
req.body,
sig,
webhookSecret
);
} catch (err) {
console.error('Webhook signature verification failed:', err.message);
return res.sendStatus(400);
}
// イベントタイプごとに処理分岐
switch (event.type) {
case 'customer.subscription.created': {
const subscription = event.data.object;
const userId = subscription.metadata.userId;
// データベースに保存
await db.subscription.create({
userId,
stripeSubscriptionId: subscription.id,
stripePriceId: subscription.items.data[0].price.id,
status: subscription.status,
currentPeriodEnd: new Date(subscription.current_period_end * 1000)
});
console.log(`✓ Subscription created for user ${userId}`);
break;
}
case 'customer.subscription.updated': {
const subscription = event.data.object;
const userId = subscription.metadata.userId;
// ステータス更新(特に cancel_at が設定された場合)
await db.subscription.update(
{ stripeSubscriptionId: subscription.id },
{
status: subscription.status,
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
canceledAt: subscription.canceled_at
? new Date(subscription.canceled_at * 1000)
: null
}
);
console.log(`✓ Subscription updated for user ${userId}`);
break;
}
case 'customer.subscription.deleted': {
const subscription = event.data.object;
const userId = subscription.metadata.userId;
// サブスク削除時の処理
await db.subscription.update(
{ stripeSubscriptionId: subscription.id },
{ status: 'canceled', deletedAt: new Date() }
);
// アプリのプロフィールを無料版に戻す
await db.user.update(
{ id: userId },
{ tier: 'free' }
);
console.log(`✓ Subscription deleted for user ${userId}`);
break;
}
case 'invoice.paid': {
const invoice = event.data.object;
console.log(`✓ Invoice paid: ${invoice.id}`);
// 請求書送信メール等の処理
break;
}
case 'invoice.payment_failed': {
const invoice = event.data.object;
console.warn(`⚠ Invoice payment failed: ${invoice.id}`);
// リトライアラートやメール送信
break;
}
default:
console.log(`Unhandled event type: ${event.type}`);
}
res.sendStatus(200);
}
);
// Expected output:
// Webhook イベント受信時:
// "✓ Subscription created for user 12345"
// "✓ Invoice paid: in_1MqAaBxxxxxxxxx"
// "⚠ Invoice payment failed: in_1MqBbCyyyyyyyyy"
3.4 サブスク状態取得エンドポイント
// GET /api/user/subscription-status
app.get('/api/user/subscription-status', async (req, res) => {
const userId = req.user.id;
const subscription = await db.subscription.findFirst({
where: { userId, status: 'active' }
});
if (!subscription) {
return res.json({
userId,
subscription: null,
tier: 'free'
});
}
res.json({
userId,
subscription: {
id: subscription.stripeSubscriptionId,
plan_name: subscription.stripePriceId.includes('pro') ? 'Pro' : 'Basic',
status: subscription.status,
next_billing_date: subscription.currentPeriodEnd
},
tier: 'premium'
});
});
// Expected output:
// {
// "userId": "12345",
// "subscription": {
// "id": "sub_1MqAaBxxxxxxxxx",
// "plan_name": "Pro",
// "status": "active",
// "next_billing_date": "2026-04-27T10:00:00Z"
// },
// "tier": "premium"
// }
ステップ 4: レシート検証とセキュリティ
ローカル検証(フロントエンド)
// アプリ起動時に Receipt を確認
async function verifySubscriptionOnAppStart() {
const token = localStorage.getItem('stripe_session_token');
if (token) {
const response = await fetch('/api/verify-receipt', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token })
});
if (response.ok) {
const { isValid, expiresAt } = await response.json();
if (isValid) {
// サブスク有効期限をチェック
if (new Date() < new Date(expiresAt)) {
activatePremiumFeatures();
} else {
// 期限切れ → 無料版に降格
deactivatePremiumFeatures();
}
}
}
}
}
// Expected output:
// アプリ起動
// ↓
// "✓ Subscription valid until: 2026-04-27"
// ↓
// プレミアム機能を有効化
ステップ 5: 解約率低減施策
5.1 退会前の確認ダイアログ
function CancelSubscriptionButton({ subscriptionId }) {
const [showConfirm, setShowConfirm] = useState(false);
const [selectedReason, setSelectedReason] = useState('');
const handleCancel = async () => {
// 解約理由を記録(リテンション施策の参考に)
await fetch('/api/subscription/cancel-feedback', {
method: 'POST',
body: JSON.stringify({
subscriptionId,
cancelReason: selectedReason,
timestamp: new Date()
})
});
// キャンセル処理
await fetch('/api/stripe/cancel-subscription', {
method: 'POST',
body: JSON.stringify({ subscriptionId })
});
setShowConfirm(false);
alert('Your subscription has been canceled.');
};
if (!showConfirm) {
return (
<button onClick={() => setShowConfirm(true)} className="btn-danger">
Cancel Subscription
</button>
);
}
return (
<div className="cancel-confirmation">
<h3>We're sorry to see you go!</h3>
<p>Why are you leaving? (This helps us improve)</p>
<select value={selectedReason} onChange={(e) => setSelectedReason(e.target.value)}>
<option value="">-- Select a reason --</option>
<option value="too-expensive">Too expensive</option>
<option value="not-using">Not using enough</option>
<option value="found-alternative">Found alternative</option>
<option value="other">Other</option>
</select>
{selectedReason === 'too-expensive' && (
<div className="retention-offer">
<p>💰 We have a 50% discount available for 3 months. Would you like to try that?</p>
<button className="btn-secondary">Apply Discount</button>
</div>
)}
<button onClick={handleCancel} className="btn-danger-confirm">
Confirm Cancellation
</button>
<button onClick={() => setShowConfirm(false)} className="btn-secondary">
Keep My Subscription
</button>
</div>
);
}
// Expected output:
// ┌──────────────────────────────────────┐
// │ We're sorry to see you go! │
// │ │
// │ Why are you leaving? │
// │ [v] -- Select a reason -- │
// │ → Too expensive │
// │ → Not using enough │
// │ → Found alternative │
// │ │
// │ [💰 Apply Discount] [Cancel] [Back] │
// └──────────────────────────────────────┘
5.2 自動リテンション施策
// 毎月実行される定期タスク
async function monthlyRetentionCheck() {
// 来月キャンセル予定のユーザーを抽出
const pendingCancellations = await db.subscription.findMany({
where: {
status: 'active',
canceledAt: { not: null }
}
});
for (const sub of pendingCancellations) {
const daysUntilCancellation = Math.ceil(
(new Date(sub.currentPeriodEnd) - new Date()) / (1000 * 60 * 60 * 24)
);
// 7日前にウィンバックメール送信
if (daysUntilCancellation === 7) {
await sendWinbackEmail({
email: sub.user.email,
plan: sub.stripePriceId,
offer: '特別オファー: 50% OFF for 3 months'
});
}
}
}
// Expected output:
// Retention task executed
// Found 23 pending cancellations
// Sent 23 winback emails
個人開発者の視点から(実体験メモ)
ここまでの要点
Rorkで作ったアプリも、Stripe を統合すれば、即座にプロダクション級のサブスク機能を持つビジネスになります。
重要なポイント:
- Webhook は必須 — Stripe の通知がなければ、ユーザーのサブスク状態を正確に把握できません
- レシート検証を実装 — フロントエンドだけでなく、必ずバックエンド検証も
- 解約データを記録 — なぜユーザーが去ったのか、改善のヒントを得られます
- リテンション施策を前もって用意 — キャンセル直前の割引オファーで救える顧客は多いです
さらに詳しく学びたい? Rork Lab では、Stripe 決済の応用パターン(複数通貨対応、サブスク + ワンタイム課金の組み合わせなど)も公開予定です。メルマガ購読で最新記事をお受け取りください。