個人開発でアプリを公開してきて、いちばん神経を使うのは「お金が正しく動いているか」を保証する部分でした。機能を足すのは楽しいのですが、課金まわりは静かに壊れます。ある月、サブスクの解約をユーザーが行ったのに権限がしばらく残り続けていたことがありました。原因は Webhook の取りこぼしでした。
Rork でエージェントを組み立てるところまでは、本当にあっという間です。難しいのはその先、「動くものを、お金が絡んでも安心して回せるサービスにする」ところです。料金体系、請求エンジン、顧客管理、API キーのローテーション。どれも地味ですが、ここを雑に作ると後でユーザーの信頼を失います。
以下では、Rork で作ったエージェントを SaaS として運用するために私が実際に組んだ構成を、つまずいた箇所を中心に整理していきます。土台になるのは三層の収益化モデルです。
- サブスクリプション層 — 月額プラン(Free / Pro / Enterprise)
- API 課金層 — 使用量ベースの従量課金
- Affiliate 層 — パートナー企業による再販売
このモデルを実装すれば、カジュアルユーザー、中堅企業、大手企業まで、すべてをカバーできます。
エージェント SaaS のアーキテクチャ
Rork で作ったエージェントを SaaS にするには、以下の層が必要です。
┌─────────────────────────────────────────────┐
│ Rork Agent (バックエンド) │
│ Claude / Gemini / Mixtral API 統合 │
└──────────────┬──────────────────────────────┘
│
┌──────────────▼──────────────────────────────┐
│ API Gateway (Express / Hono) │
│ 認証・レート制限・使用量トラッキング │
└──────────────┬──────────────────────────────┘
│
┌──────────────▼──────────────────────────────┐
│ Billing Engine (Stripe + RevenueCat) │
│ サブスク・従量課金・支払い処理 │
└──────────────┬──────────────────────────────┘
│
┌──────────────▼──────────────────────────────┐
│ Customer Dashboard (Next.js + Vercel) │
│ 利用状況・請求履歴・API キー管理 │
└─────────────────────────────────────────────┘
Step 1: Rork エージェントの API 化
最初に、Rork で構築したエージェントをプログラマティックに呼び出せるようにします。
Rork の SDK を使い、こんなコードで実装します。
// lib/rork-agent.ts
import { RorkClient } from '@rork/sdk';
const client = new RorkClient({
apiKey: process.env.RORK_API_KEY,
agentId: 'my-saas-agent',
});
export async function callAgent(input: string) {
const response = await client.chat.completions.create({
messages: [{ role: 'user', content: input }],
maxTokens: 2000,
});
return {
text: response.choices[0].message.content,
tokensUsed: response.usage.total_tokens,
costUsd: calculateCost(response.usage),
};
}
function calculateCost(usage: TokenUsage): number {
// 入力: $0.003 / 1K tokens, 出力: $0.015 / 1K tokens
const inputCost = (usage.prompt_tokens / 1000) * 0.003;
const outputCost = (usage.completion_tokens / 1000) * 0.015;
return inputCost + outputCost;
}
このエージェントをわかりやすい REST API でラップします。
// app/api/agent/chat/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { callAgent } from '@/lib/rork-agent';
import { trackUsage } from '@/lib/billing';
export async function POST(req: NextRequest) {
const authHeader = req.headers.get('Authorization');
const apiKey = authHeader?.replace('Bearer ', '');
if (!apiKey) {
return NextResponse.json(
{ error: 'Missing API key' },
{ status: 401 }
);
}
// API キーから顧客を検索
const customer = await findCustomerByApiKey(apiKey);
if (!customer) {
return NextResponse.json(
{ error: 'Invalid API key' },
{ status: 401 }
);
}
// レート制限チェック
const remainingRequests = await checkRateLimit(customer.id);
if (remainingRequests <= 0) {
return NextResponse.json(
{ error: 'Rate limit exceeded' },
{ status: 429 }
);
}
const { message } = await req.json();
try {
const result = await callAgent(message);
// 使用量をトラッキング
await trackUsage({
customerId: customer.id,
tokensUsed: result.tokensUsed,
costUsd: result.costUsd,
timestamp: new Date(),
});
return NextResponse.json({
response: result.text,
tokens_used: result.tokensUsed,
cost_usd: result.costUsd,
});
} catch (error: any) {
return NextResponse.json(
{ error: error.message },
{ status: 500 }
);
}
}
Step 2: Stripe + RevenueCat 統合
Stripe で課金エンジンを、RevenueCat でサブスクリプション管理をセットアップします。
Stripe の設定
pricing.ts で料金プランを定義します。
// src/config/pricing.ts
export const PRICING_PLANS = {
free: {
name: 'Free',
monthlyPrice: 0,
requestsPerMonth: 100,
priceId: 'price_free', // Stripe は Free プランの priceId 不要だが、記録用
features: [
'Basic agent access',
'100 requests/month',
'API documentation',
],
},
pro: {
name: 'Pro',
monthlyPrice: 99,
requestsPerMonth: 10000,
priceId: 'price_1KxH9bA7e5Wg8nQr0000',
features: [
'All Free features',
'10,000 requests/month',
'Priority support',
'Custom agent training',
],
},
enterprise: {
name: 'Enterprise',
monthlyPrice: 'custom',
requestsPerMonth: 'unlimited',
priceId: 'price_enterprise_contact_us',
features: [
'All Pro features',
'Unlimited requests',
'Dedicated support',
'On-premise deployment option',
],
},
};
Stripe Checkout セッション作成
// app/api/billing/create-checkout/route.ts
import Stripe from 'stripe';
import { NextRequest, NextResponse } from 'next/server';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: NextRequest) {
const { customerId, planId } = await req.json();
// 既存顧客をチェック
const customer = await getCustomer(customerId);
try {
const session = await stripe.checkout.sessions.create({
customer: customer.stripeCustomerId,
payment_method_types: ['card'],
mode: 'subscription',
line_items: [
{
price: PRICING_PLANS[planId].priceId,
quantity: 1,
},
],
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
billing_address_collection: 'auto',
});
return NextResponse.json({ sessionId: session.id });
} catch (error: any) {
return NextResponse.json(
{ error: error.message },
{ status: 500 }
);
}
}
Webhook で購読状態を更新
// app/api/webhooks/stripe/route.ts
import Stripe from 'stripe';
import { NextRequest, NextResponse } from 'next/server';
import { updateSubscription } from '@/lib/db';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: NextRequest) {
const body = await req.text();
const sig = req.headers.get('stripe-signature')!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
sig,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err: any) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
}
if (event.type === 'customer.subscription.updated') {
const subscription = event.data.object as Stripe.Subscription;
const customerId = subscription.customer as string;
const planId = getPlanIdFromPriceId(subscription.items.data[0].price.id);
await updateSubscription(customerId, planId, subscription.status);
}
return NextResponse.json({ received: true });
}
Step 3: API キーとレート制限
各顧客に対してユニークな API キーを生成し、使用量に応じてレート制限をかけます。
// lib/api-keys.ts
import crypto from 'crypto';
export function generateApiKey(): string {
return 'rk_' + crypto.randomBytes(32).toString('hex');
}
export async function createApiKeyForCustomer(
customerId: string
): Promise<string> {
const apiKey = generateApiKey();
await db.apiKeys.create({
customerId,
key: hashApiKey(apiKey),
plaintext: apiKey, // 最初は平文を返す(1回だけ)
createdAt: new Date(),
});
return apiKey;
}
export async function validateApiKey(
apiKey: string
): Promise<{ customerId: string; plan: string } | null> {
const hashedKey = hashApiKey(apiKey);
const record = await db.apiKeys.findUnique({
where: { key: hashedKey },
include: { customer: true },
});
if (!record) return null;
return {
customerId: record.customerId,
plan: record.customer.plan,
};
}
function hashApiKey(apiKey: string): string {
return crypto.createHash('sha256').update(apiKey).digest('hex');
}
レート制限の実装:
// lib/rate-limit.ts
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});
const rateLimits = {
free: new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(100, '30 d'), // 月100リクエスト
analytics: true,
}),
pro: new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(10000, '30 d'), // 月10,000リクエスト
analytics: true,
}),
enterprise: new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(1000000, '30 d'), // 月100万リクエスト
analytics: true,
}),
};
export async function checkRateLimit(
customerId: string,
plan: string
): Promise<boolean> {
const limiter = rateLimits[plan] || rateLimits.free;
const result = await limiter.limit(customerId);
return result.success;
}
Step 4: 従量課金の実装
API 使用量に応じた追加課金を Stripe Billing で管理します。
// lib/metered-billing.ts
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function recordUsage(
customerId: string,
quantity: number
): Promise<void> {
const customer = await getCustomer(customerId);
// メーター化価格の使用量を記録
// (Stripe の Price に `billing_scheme: "tiered"` が設定されている前提)
await stripe.billing.meterEvents.create({
event_name: 'api_requests',
customer_id: customer.stripeCustomerId,
value: quantity,
});
}
export async function addOneTimeCharge(
customerId: string,
amountUsd: number,
description: string
): Promise<void> {
const customer = await getCustomer(customerId);
// 次の請求サイクルにワンタイムチャージを追加
await stripe.invoiceItems.create({
customer: customer.stripeCustomerId,
amount: Math.round(amountUsd * 100), // セント単位
currency: 'usd',
description,
});
}
API 呼び出し時に使用量を記録:
// app/api/agent/chat/route.ts(修正版)
const result = await callAgent(message);
// 使用量を記録
await recordUsage(customer.id, result.tokensUsed);
// Pro プラン以上なら自動課金、Free プランなら警告
if (customer.plan === 'free' && monthlyUsage > PRICING_PLANS.free.requestsPerMonth) {
return NextResponse.json(
{ error: 'Monthly limit exceeded. Upgrade to Pro.' },
{ status: 429 }
);
}
if (customer.plan === 'pro' && monthlyUsage > PRICING_PLANS.pro.requestsPerMonth) {
// オーバーランを自動課金
const overageTokens = monthlyUsage - PRICING_PLANS.pro.requestsPerMonth;
const overageCost = (overageTokens / 1000) * 0.02; // $0.02 / 1K tokens
await addOneTimeCharge(
customer.id,
overageCost,
`API overage: ${overageTokens} tokens`
);
}
Step 5: Affiliate マーケティング層
パートナー企業があなたのエージェント API を再販売できる仕組みを作ります。
// lib/affiliates.ts
export async function createAffiliateLink(
partnerId: string,
commissionRate: number = 0.20 // 20%
): Promise<string> {
const affiliateCode = generateAffiliateCode();
await db.affiliates.create({
code: affiliateCode,
partnerId,
commissionRate,
createdAt: new Date(),
});
return affiliateCode;
}
export async function trackAffiliateSignup(
affiliateCode: string,
newCustomerId: string
): Promise<void> {
const affiliate = await db.affiliates.findUnique({
where: { code: affiliateCode },
});
if (affiliate) {
await db.affiliateCommissions.create({
affiliateId: affiliate.id,
customerId: newCustomerId,
initialAmount: 0, // 初回は0、サブスク決済時に更新
status: 'pending',
});
}
}
export async function calculateCommission(
customerId: string,
monthlyRevenue: number
): Promise<void> {
// このカスタマーを紹介したアフィリエイトを検索
const commission = await db.affiliateCommissions.findFirst({
where: { customerId },
include: { affiliate: true },
});
if (commission) {
const commissionAmount = monthlyRevenue * commission.affiliate.commissionRate;
await db.affiliateCommissions.update({
where: { id: commission.id },
data: {
amount: commissionAmount,
updatedAt: new Date(),
},
});
// 月1回、アフィリエイト報酬を Stripe Connect で支払う
await payAffiliateCommission(commission.affiliate.stripeConnectId, commissionAmount);
}
}
Affiliate Dashboard:
// app/dashboard/affiliate/page.tsx
export default function AffiliateDashboard() {
const [stats, setStats] = useState({
referredCustomers: 0,
monthlyCommission: 0,
totalEarnings: 0,
});
useEffect(() => {
fetch('/api/affiliate/stats')
.then(r => r.json())
.then(setStats);
}, []);
return (
<div className="max-w-4xl mx-auto p-6">
<h1 className="text-3xl font-bold mb-8">Affiliate Dashboard</h1>
<div className="grid grid-cols-3 gap-4 mb-8">
<Card>
<div className="text-3xl font-bold">{stats.referredCustomers}</div>
<p className="text-gray-600">Referred Customers</p>
</Card>
<Card>
<div className="text-3xl font-bold">${stats.monthlyCommission}</div>
<p className="text-gray-600">This Month</p>
</Card>
<Card>
<div className="text-3xl font-bold">${stats.totalEarnings}</div>
<p className="text-gray-600">Total Earnings</p>
</Card>
</div>
<div className="bg-blue-50 p-6 rounded-lg mb-8">
<h2 className="font-bold mb-2">Your Affiliate Link</h2>
<div className="flex gap-2">
<input
type="text"
value={`${process.env.NEXT_PUBLIC_APP_URL}?ref=${affiliate.code}`}
readOnly
className="flex-1 px-3 py-2 border rounded"
/>
<button className="bg-blue-600 text-white px-4 py-2 rounded">
Copy
</button>
</div>
<p className="text-sm text-gray-600 mt-2">
20% commission on every referred customer's subscription
</p>
</div>
</div>
);
}
Step 6: ダッシュボードで すべて管理
顧客が API キー、使用量、請求履歴をすべて 1 つのダッシュボードで管理できるようにします。
// app/dashboard/page.tsx
export default function Dashboard() {
const [data, setData] = useState({
currentPlan: 'pro',
monthlyRequests: 5234,
monthlyLimit: 10000,
monthlyBill: 99,
nextBillingDate: '2026-05-15',
apiKey: 'rk_****...****',
});
return (
<div className="max-w-4xl mx-auto p-6">
<h1 className="text-3xl font-bold mb-8">Your Account</h1>
{/* Current Plan */}
<section className="mb-8 p-6 border rounded-lg">
<h2 className="text-xl font-bold mb-4">Current Plan</h2>
<div className="flex justify-between items-center">
<div>
<p className="text-3xl font-bold">{data.currentPlan}</p>
<p className="text-gray-600">${data.monthlyBill}/month</p>
</div>
<a href="/pricing" className="text-blue-600">
Change Plan
</a>
</div>
</section>
{/* Usage */}
<section className="mb-8 p-6 border rounded-lg">
<h2 className="text-xl font-bold mb-4">This Month's Usage</h2>
<div className="mb-4">
<div className="flex justify-between mb-2">
<span>{data.monthlyRequests} / {data.monthlyLimit} requests</span>
<span>{Math.round((data.monthlyRequests / data.monthlyLimit) * 100)}%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full"
style={{ width: `${(data.monthlyRequests / data.monthlyLimit) * 100}%` }}
/>
</div>
</div>
<p className="text-sm text-gray-600">
Next billing date: {data.nextBillingDate}
</p>
</section>
{/* API Key */}
<section className="mb-8 p-6 border rounded-lg">
<h2 className="text-xl font-bold mb-4">API Key</h2>
<div className="flex gap-2 mb-4">
<code className="flex-1 bg-gray-100 p-3 rounded font-mono text-sm">
{data.apiKey}
</code>
<button className="bg-gray-600 text-white px-4 py-2 rounded">
Copy
</button>
</div>
<button className="text-red-600 text-sm">
Regenerate Key
</button>
</section>
{/* Billing History */}
<section className="p-6 border rounded-lg">
<h2 className="text-xl font-bold mb-4">Billing History</h2>
<table className="w-full text-sm">
<thead>
<tr className="border-b">
<th className="text-left py-2">Date</th>
<th className="text-left py-2">Amount</th>
<th className="text-left py-2">Status</th>
<th className="text-left py-2">Invoice</th>
</tr>
</thead>
<tbody>
{/* Invoices... */}
</tbody>
</table>
</section>
</div>
);
}
Webhook を冪等にする — 二重課金と取りこぼしを防ぐ
課金の本番運用で最初に痛い目を見たのが、ここでした。Stripe は Webhook の配信を保証するために、同じイベントを何度も再送します。ネットワークが一瞬切れただけでも再送が来ます。受け取る側が「同じイベントを2回処理しても1回分の結果になる」ように作っておかないと、権限の付与が重複したり、逆に順序が入れ替わって古い状態で上書きしたりします。
私は、処理済みイベントを記録するテーブルを一枚挟むだけで、この大半を防げると学びました。
// app/api/webhooks/stripe/route.ts(冪等化版)
import Stripe from 'stripe';
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: NextRequest) {
const body = await req.text();
const sig = req.headers.get('stripe-signature')!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body, sig, process.env.STRIPE_WEBHOOK_SECRET!
);
} catch {
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
}
// 1) event.id を主キーにして「初回だけ INSERT が通る」状態を作る
const firstTime = await db.processedEvents
.create({ data: { id: event.id, type: event.type } })
.then(() => true)
.catch(() => false); // 一意制約違反 = すでに処理済み
if (!firstTime) {
// 再送。200 を返して Stripe に「もう要らない」と伝える
return NextResponse.json({ received: true, duplicate: true });
}
if (event.type === 'customer.subscription.updated') {
const sub = event.data.object as Stripe.Subscription;
const customerId = sub.customer as string;
const planId = getPlanIdFromPriceId(sub.items.data[0].price.id);
// 2) 順序逆転対策:current_period_end が現在値より新しい時だけ更新
await db.$transaction(async (tx) => {
const cur = await tx.subscriptions.findUnique({ where: { customerId } });
const incomingEnd = sub.current_period_end;
if (cur && cur.periodEnd && cur.periodEnd > incomingEnd) {
return; // 古いイベントなので無視
}
await tx.subscriptions.update({
where: { customerId },
data: { plan: planId, status: sub.status, periodEnd: incomingEnd },
});
});
}
return NextResponse.json({ received: true });
}
ポイントは2つです。event.id の一意制約を「ロック代わり」に使うこと、そして current_period_end を比較して古いイベントを捨てること。再送と順序逆転は別々の問題なので、片方だけでは足りません。私はここを片方しか作っておらず、テスト環境では問題なく、本番の負荷で初めて顕在化しました。
エンタイトルメントの真実を1か所に持つ — Stripe と RevenueCat の突合
サブスクを Web(Stripe)とアプリ内課金(RevenueCat 経由の App Store / Google Play)の両方で売り始めると、「この人は今どのプランなのか」を答える場所が2つに割れます。Stripe を見ると Pro、RevenueCat を見ると Free、という食い違いが普通に起きます。同じユーザーが両方で課金したり、片方で解約したりするからです。
私の結論は、課金源は複数あっていいが、エンタイトルメント(権限)の正本は必ず1つにする、ということでした。各 Webhook はそれぞれ「自分の源での状態」を書き込むだけにして、最終的な権限は読み取り時にサーバー側で1回だけ解決します。
// lib/entitlement.ts
type Source = 'stripe' | 'revenuecat';
interface SourceState {
source: Source;
plan: 'free' | 'pro' | 'enterprise';
active: boolean;
periodEnd: number; // unix秒
}
// 各 Webhook はこのテーブルに「自分の源の状態」だけを upsert する
async function readSourceStates(userId: string): Promise<SourceState[]> {
return db.entitlementSources.findMany({ where: { userId } });
}
const PLAN_RANK = { free: 0, pro: 1, enterprise: 2 } as const;
// 真実は読み取り時に1回だけ計算する
export async function resolveEntitlement(userId: string) {
const states = await readSourceStates(userId);
const now = Math.floor(Date.now() / 1000);
// アクティブで期限内のものだけ採用し、最上位プランを正とする
const valid = states.filter((s) => s.active && s.periodEnd > now);
if (valid.length === 0) return { plan: 'free' as const, source: null };
const top = valid.reduce((a, b) =>
PLAN_RANK[b.plan] > PLAN_RANK[a.plan] ? b : a
);
return { plan: top.plan, source: top.source };
}
「最上位プランを正にする」のは、二重課金しているユーザーに不利益を与えないためです。返金や解約の調整は別途行うとして、まず権限の見え方で損をさせない。これは収益最大化よりユーザーの信頼を優先した判断で、長く運用するほどこちらが効いてくると感じています。
突合のずれを検知したいときは、日次バッチで entitlementSources を走査し、同一ユーザーで源ごとにプランが食い違うレコードを抽出してダッシュボードに出すだけでも十分でした。完全自動で直そうとせず、まず「見える化」してから手当てする方が事故が少なかったです。
解約の手前で止める — Dunning と Win-back を自動化する
解約の多くは「やめたい」ではなく「カードの期限切れ」でした。invoice.payment_failed を放置すると、本当は使い続けたかったユーザーまで失います。Stripe の Smart Retries に任せつつ、猶予期間(グレースピリオド)を設けて、その間は権限を即座には落とさない設計にしています。
// 支払い失敗時:即解約にせず、猶予付きの past_due として扱う
if (event.type === 'invoice.payment_failed') {
const invoice = event.data.object as Stripe.Invoice;
const customerId = invoice.customer as string;
await db.subscriptions.update({
where: { customerId },
data: {
status: 'past_due',
// 3日間は権限を維持。Stripe の再試行が成功すれば自動で active に戻る
graceUntil: Math.floor(Date.now() / 1000) + 3 * 24 * 60 * 60,
},
});
await sendDunningEmail(customerId); // 「カード情報の更新をお願いします」
}
権限チェック側は、active に加えて「past_due かつ graceUntil 内」も有効とみなします。こうするだけで、カード更新による自然復帰が目に見えて増えました。完全に解約された後の Win-back は、customer.subscription.deleted を受けて期間限定のオファーコードを送る程度に留めています。しつこい引き止めは、かえって戻ってこなくなるというのが個人的な実感です。
次の一歩
三層モデルそのものより、課金が静かに壊れないための土台のほうが、運用では効いてきます。もし1つだけ今日から始めるなら、Webhook の冪等化をおすすめします。event.id を主キーにした処理済みテーブルを足すだけで、本番で最も起きやすい「二重付与」と「順序逆転」の両方に効きます。
ここまでお読みいただき、ありがとうございました。同じように個人で課金まわりを抱えている方の、回り道を1つでも減らせたら嬉しいです。