決済ページまで作り終えた夜、Webhook のログを開いて手が止まったことがあります。Stripe のダッシュボードには「成功」と出ているのに、自分のデータベースには権限が付いていませんでした。原因は、metadata のキー名を一か所だけ planType と plan_type で書き分けていたことでした。
エージェントそのものは、その時点でもう動いていました。それでも「売れる状態」には届いていなかったのです。動くことと売れることのあいだには、価格、決済、権限付与、上限管理、止め方といった、地味な層が何枚も挟まっています。
前編のAgentKit 2.0 で作ったAIエージェントを売る5つの収益化パスでは、配布チャネルの選び方をお話ししました。この記事はその続きとして、商品として完成させ、最初の課金を受け取るまでの90日を、関門つきの設計図にします。
作業を重ねる前に、止める条件を決めておきます。 90日の設計は、この一文に集約しています。
90日を「作業」ではなく「関門」で区切る
ロードマップというと、日付ごとのタスク一覧になりがちです。ところが個人開発では、タスクが終わっても次へ進んでよいとは限りません。進むか、直すか、やめるかを決める地点を、日付の代わりに置きます。
以下の基準は、私が自分の判断用に置いている叩き台です。数字は皆さまの商品に合わせて書き換えてください。
| 関門 | 時期の目安 | 通過の条件 | 通過できないとき |
|---|---|---|---|
| G1 商品定義 | Day 15 | 5つの問いに1ページで答えられる/想定ユーザー1人に実際に話を聞けた | 実装に入らず、定義を書き直す |
| G2 原価 | Day 30 | 1回の実行あたりの原価を実測できている/上限ガードが動いている | モデルの選び方か処理の分割を見直す |
| G3 決済 | Day 60 | テスト決済から権限付与までが署名検証つきで通る/二重配信でも二重付与にならない | 公開せず、Webhook を直す |
| G4 初回課金 | Day 75 | 知人以外から1件でも課金がある | 商品ページか価格を見直す |
| G5 撤退判断 | Day 90 | 損益分岐に対する現在地を数字で言える | ピボットか撤退を決める |
関門を置くと、「あと少し作り込めば売れるかもしれない」という気持ちに歯止めがかかります。胃が重くなる夜ほど、機能を足したくなるものです。
Phase 1(Day 1〜15)— 商品として定義する
最初の2週間は、コードを書かずに商品定義だけを進めます。技術から入ると、買う理由を後付けで探すことになり、たいてい行き詰まります。
5つの問いを1ページに収める
- 誰の、何を解決するのか:たとえば「個人のEC運営者が、商品ページの英訳に毎月かけている時間を短縮する」と書けるところまで具体にします
- 既存の選択肢と何が違うのか:汎用のチャット AI や既存の翻訳ツールでは足りない理由を、1文で説明します
- エージェントが行うステップ:入力、処理、出力の各段階を書き出します
- 失敗したときの振る舞い:想定外の入力、途中停止、外部 API の失敗を一つずつ想定します
- 価格の根拠:解決する時間、相手にとっての時間の価値、原価と手数料から逆算します
この1ページが書けないなら、それは商品としてまだ形になっていない、というだけのことです。落ち込む必要はなく、書けるまで質問を重ねる段階だと受け止めています。
最初はシングルエージェントから
マルチエージェント構成は魅力的ですが、原因の切り分け、ユーザーへの説明、原価の見積もりが、台数に応じて重くなります。最初の商品は1つの役割に絞り、売れ筋が見えてから分割しても遅くありません。
Phase 2(Day 16〜30)— 壊れにくさと原価を作り込む
この期間のテーマは、動かすことではなく、壊れにくく、止められることです。
特定 SDK に依存しない実行ラッパー
エージェントの呼び出し部分は、将来 SDK やモデルが変わっても差し替えられるよう、関数として抜き出しておきます。以下は、タイムアウトと再試行だけを担当する薄いラッパーです。
// lib/run-with-guards.ts — 呼び出し先(SDK・API)は call として注入する
type AgentCall = (
input: string,
signal: AbortSignal,
) => Promise<{ text: string; tokensIn: number; tokensOut: number }>;
function isRetryable(e: unknown): boolean {
if (e instanceof DOMException && e.name === 'AbortError') return true;
const status = (e as { status?: number })?.status;
return status === 429 || (status !== undefined && status >= 500);
}
export async function runWithGuards(
call: AgentCall,
input: string,
opts: { timeoutMs?: number; maxAttempts?: number } = {},
) {
const timeoutMs = opts.timeoutMs ?? 60_000;
const maxAttempts = opts.maxAttempts ?? 3;
let lastError: unknown;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
try {
return await call(input, ctrl.signal);
} catch (e) {
lastError = e;
if (!isRetryable(e) || attempt === maxAttempts) break;
// 指数バックオフ + ジッター(同時リトライの集中を避ける)
const wait = 2 ** attempt * 500 + Math.random() * 250;
await new Promise((r) => setTimeout(r, wait));
} finally {
clearTimeout(timer);
}
}
throw lastError;
}再試行の回数と待ち時間は、そのまま原価と体感速度に跳ね返ります。設計の考え方はエージェントの再試行バジェット設計で詳しく扱っています。
1日あたりの利用上限は D1 で数える
無料枠や月額プランを置くなら、上限のカウントはサーバー側で行います。Cloudflare KV は書き込み同士の整合性が保証されないため、連続リクエストで上限を超えてしまうことがあります。回数の集計には、SQL で原子的に更新できる D1 を使います。
CREATE TABLE IF NOT EXISTS usage (
user_id TEXT NOT NULL,
day TEXT NOT NULL,
runs INTEGER NOT NULL,
PRIMARY KEY (user_id, day)
);// 上限内なら +1 して true、上限に達していれば false を返す
export async function consumeQuota(
db: D1Database,
userId: string,
dailyLimit: number,
): Promise<boolean> {
const day = new Date().toISOString().slice(0, 10);
const row = await db
.prepare(
`INSERT INTO usage (user_id, day, runs) VALUES (?1, ?2, 1)
ON CONFLICT(user_id, day) DO UPDATE SET runs = runs + 1
WHERE runs < ?3
RETURNING runs`,
)
.bind(userId, day, dailyLimit)
.first<{ runs: number }>();
return row !== null;
}ON CONFLICT … DO UPDATE … WHERE の条件が偽になると、更新も RETURNING の行も生まれません。その「行が返らない」ことを、上限到達の合図として使っています。
原価は「1回あたり」で実測する
モデル名と単価は更新が頻繁なため、この記事では固定の値を置きません。代わりに、実行のたびに入力トークンと出力トークンを記録し、自分の使うモデルの最新の料金表から1回あたりの原価を計算します。ここで出た数字が、後述する損益分岐の入力になります。
簡単な入力には安価なモデル、長文や込み入った指示だけ上位のモデル、という振り分けは有効です。ただし、振り分けの基準は入力文字数だけでは足りません。品質を見ながら調整する対象として、環境変数に逃がしておくとよいでしょう。
const MODEL_LIGHT = process.env.MODEL_LIGHT!; // 安価・高速なモデルの ID
const MODEL_HEAVY = process.env.MODEL_HEAVY!; // 品質重視のモデルの ID
export function pickModel(input: string, needsReasoning: boolean) {
if (needsReasoning || input.length > 5000) return MODEL_HEAVY;
return MODEL_LIGHT;
}並列でエージェントを動かすときの上限の考え方は、並列エージェントの割当バジェット設計にまとめています。
Phase 3(Day 31〜45)— 配布の入口を絞る
前編の5つの配布パスから、最初は1つか2つに絞ります。私は、自社サイトでの直接販売を軸にして、マーケットプレイスは入口の一つとして試す構成をすすめています。理由は単純で、価格、顧客情報、返金の扱いを自分で決められるのが自社サイトだけだからです。
マーケットプレイスへの出品は、手数料、審査、価格の自由度、顧客情報の扱いが、場所によって大きく異なります。出品の前に、最新の規約と手数料表を必ずご自身で確認してください。この記事では特定のマーケットプレイスの条件を前提にしていません。
商品ページに最低限置くものは、次の5点です。
- 「誰の何を解決するか」を1文で書いたヒーロー部
- 実際にエージェントが動く30秒前後のデモ
- 入力、実行、結果の3ステップ
- 料金表(プランの違いが一目で分かるもの)
- 開発者自身がつまずいた点に絞った、1〜2問の FAQ
マーケットプレイスと自社サイトで価格を変える場合は、どちらかが一方を食わない設計にします。流入用の入口は価格を抑え、継続利用の機能は自社サイトの月額に置く、といった線引きです。
Phase 4(Day 46〜60)— Stripe の決済と権限付与
ここが、冒頭でつまずいた層です。Stripe Checkout でセッションを作り、決済完了を Webhook で受け取って権限を付けます。
プランは「使われ方」に合わせる
エージェントは「1回使えば結果が出る」商品になりやすく、その場合は月額だけでなく、1回利用や買い切りを併売すると選びやすくなります。金額は、手元の原価と手数料から逆算したうえで、次のような3層から試す方法があります。
| プラン | 想定する使い方 | 価格の置き方 |
|---|---|---|
| 1回利用 | 試したい人、単発の用事 | 原価の数倍。手数料を引いても黒字になる額 |
| 月額 | 定期的に使う個人 | 上限回数から最悪月の原価を逆算して決める |
| 買い切り | 購読を避けたい人 | 利用上限と更新方針を明記したうえで設定する |
Checkout セッションの作成
// app/api/checkout/route.ts
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const PLANS = {
oneshot: { mode: 'payment', amount: 480, name: '1回利用' },
monthly: { mode: 'subscription', amount: 980, name: '月額 Pro' },
lifetime: { mode: 'payment', amount: 4800, name: '買い切り' },
} as const;
export async function createCheckout(
plan: keyof typeof PLANS,
userId: string,
agentId: string,
origin: string,
) {
const p = PLANS[plan];
return stripe.checkout.sessions.create({
mode: p.mode,
line_items: [
{
quantity: 1,
price_data: {
currency: 'jpy', // JPY は最小単位が円なので 480 は 480 円
unit_amount: p.amount,
product_data: { name: p.name },
...(p.mode === 'subscription'
? { recurring: { interval: 'month' as const } }
: {}),
},
},
],
client_reference_id: userId,
metadata: { plan, agent_id: agentId }, // キー名は Webhook 側と一字一句そろえる
success_url: `${origin}/agent/${agentId}?checkout=success`,
cancel_url: `${origin}/agent/${agentId}`,
});
}ユーザー ID は client_reference_id に、商品とプランは metadata に入れています。キー名は定数にして、作る側と受ける側で同じ定義を読むようにすると、冒頭のような書き分けは起こりません。
署名を検証し、二重配信に耐える Webhook
Webhook は、署名を検証せずに受けると誰でも「決済完了」を偽装できます。また、Stripe は同じイベントを複数回届けることがあるため、権限付与は冪等でなければなりません。
// app/api/stripe/webhook/route.ts
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: Request) {
const body = await req.text(); // 署名検証には生の本文が必要
const sig = req.headers.get('stripe-signature');
if (!sig) return new Response('missing signature', { status: 400 });
let event: Stripe.Event;
try {
// Workers / Edge 環境では constructEventAsync を使う
event = await stripe.webhooks.constructEventAsync(
body,
sig,
process.env.STRIPE_WEBHOOK_SECRET!,
);
} catch {
return new Response('invalid signature', { status: 400 });
}
if (event.type === 'checkout.session.completed') {
const s = event.data.object as Stripe.Checkout.Session;
const userId = s.client_reference_id;
const plan = s.metadata?.plan;
const agentId = s.metadata?.agent_id;
if (!userId || !plan || !agentId) {
console.error('webhook metadata missing', event.id);
return new Response('missing metadata', { status: 400 }); // 再配信させて気づく
}
if (s.payment_status !== 'paid') {
return new Response('not paid yet', { status: 200 }); // 非同期決済は別イベントで付与
}
// event.id を主キーにした処理済みテーブルへ INSERT し、重複なら何もしない
await grantOnce({ eventId: event.id, userId, agentId, plan });
}
return new Response('ok', { status: 200 });
}grantOnce の中身は、処理済みイベントの ID を主キーに持つテーブルへの INSERT(重複時は無視)と、権限付与を同じトランザクションにまとめるだけで足ります。課金額の集計がずれたときの調べ方は、メータリング台帳と Stripe のズレの記録に整理しました。決済まわり全体の構成は、SaaS の Stripe フルスタック構成も併せてご覧ください。
Phase 5(Day 61〜75)— ローンチ前の点検
公開の前に、次の項目を一つずつ潰します。
- 商品ページの冒頭が、ユーザーの困りごとから始まっていること
- デモが30秒前後で、価値が伝わること
- 本番モードで少額の決済を実際に通し、返金まで動作を確認済みであること
- 解約の導線があり、解約後の権限の扱いが説明されていること
- 決済完了、解約、エラー時のメールが届くこと
- 実行ログが保存され、ユーザーが履歴を見られること
- 問い合わせ先が明記されていること
- 特定商取引法に基づく表記、プライバシーポリシー、利用規約の公開
告知は、X への投稿、技術記事、既存のフォロワーへの案内など、自分が普段使っている場所から始めます。投稿は「開発で詰まったこと」「個人でやるからこその工夫」「価格を決めた理由」の順に並べると、読み手が自分の状況と重ねやすくなります。初日の反応は運の要素が大きいため、そこで判断を下さず、関門 G4 の基準で見ることをおすすめします。
Phase 6(Day 76〜90)— 運用と、撤退ライン
毎日見る3つの数字
- 新規ユーザー数(自社サイトと外部チャネルを分けて記録)
- 転換率(訪問者のうち課金に至った割合)
- エラー率(想定どおりに完了しなかった実行の割合)
それぞれの警戒ラインは、ローンチ前に自分で決めて書き留めておきます。ラインを後から動かせるようにしておくと、数字が悪い日に、都合のよい解釈をしてしまうからです。
損益分岐を計算式で持つ
撤退ラインを「気持ち」ではなく計算式にします。1人あたりの月次の粗利と、固定費の2つがあれば、損益分岐の人数が出ます。
粗利(1人・月)= 月額 ×(1 − 決済手数料率)− 月の平均実行回数 × 1回あたりの原価
損益分岐の人数 = 月の固定費 ÷ 粗利(切り上げ)
以下は、置いた値を明示した試算です。月額 980 円、決済手数料率 4%、1回あたり原価 4 円、固定費(ホスティング、ドメイン、監視)を月 3,000 円と仮定しています。この値は説明のための仮置きで、実際の数字はご自身の請求書から入れてください。
| 利用の重さ | 月の実行回数 | 粗利(1人・月) | 損益分岐の人数 |
|---|---|---|---|
| 軽い | 20回 | 約 861 円 | 4人 |
| 標準 | 40回 | 約 781 円 | 4人 |
| 重い | 150回 | 約 341 円 | 9人 |
重いユーザーが増えると、損益分岐の人数が倍以上に伸びることが見て取れます。月額プランに1日あたりの上限を置いたのは、この「最悪月」を数字で押さえるためでした。上限が決まっていれば、粗利が最も薄い月でも、赤字の幅を事前に計算できます。
撤退のライン
私が個人開発で置いている撤退の条件は、次の三つのうち、いずれかが3か月続いたときです。
- 月の純利益が、固定費を下回ること
- 運用にかかる時間が、本業や制作の時間を圧迫し続けること
- 損益分岐の人数に対する現在地が、3か月たっても半分に届かないこと
数字を決めておく意味は、サンクコストに引きずられないためです。撤退は失敗ではなく、次の商品に時間を渡すための判断だと考えています。
ローンチ後に見直す三つのこと
- 説明できる範囲に絞る:商品名、説明、価格のすべてが3秒で伝わる状態を保ちます。複雑なマルチエージェント構成は、内部の設計として使い、ユーザーには1つの役割として見せます
- ユーザーと話す場所を持つ:メールでも、小さなコミュニティでも構いません。汎用エージェントとの競合が起きたとき、頼りになるのは機能の差よりも、困りごとを聞いてきた蓄積でした
- モデル指定を定期的に見直す:新しいモデルは、原価や品質が変わることがあります。見直しの周期は、請求書の傾向を見て決めるのが確実です
90日は「最短ルートの一例」にすぎません。本業のある方は倍の期間をかけても問題ありません。
まずは今夜、5つの問いを1ページに書き出してみてください。書き終えたら、G1 の基準で、その1ページを一度だけ読み返していただければと思います。