I built an AI app using Claude API. It worked. Users came. But then I hit the wall: how do I actually charge for this?
I've been there myself. An AI-powered app isn't a commodity product where you can just slap a price tag on it and call it done. The Claude API isn't free. Every request costs money—sometimes more than users are willing to pay per month. This is where most indie developers with promising AI apps give up.
What separates a hobby project from a sustainable SaaS isn't just having users—it's having a billing architecture that tracks actual API costs, allocates them fairly across pricing tiers, and recovers them through monthly subscriptions.
This article walks you through exactly how to build that. From understanding Claude's token economics to implementing Stripe Webhooks with proper idempotency to monitoring costs in production—we'll cover the full stack. By the end, you'll have a mental model for why certain architecture decisions matter, and concrete code to implement them.
Understanding Claude API's Cost Structure
Before you can price anything, you need to understand what it actually costs you to serve a request.
Claude pricing is straightforward: you pay per million input tokens and per million output tokens, with rates depending on the model generation and size. Here is the current generation as of August 2026:
| Model | Input | Cache read | Output |
|---|---|---|---|
| Claude Haiku 4.5 | $1/M | $0.10/M | $5/M |
| Claude Sonnet 5 | $2/M | $0.20/M | $10/M |
| Claude Opus 5 | $5/M | $0.50/M | $25/M |
(M = 1 million tokens. Check the Claude Platform pricing page for current values.)
Do not copy that table into your billing code. Rates change. Sonnet 5's $2/$10 was originally announced as introductory pricing through August 31, 2026, with a scheduled increase to $3/$15 on September 1. That increase never happened — $2/$10 became the standard price. Anyone who hardcoded the scheduled bump would have been overstating their cost of goods by roughly 30% starting in September.
Rates belong in configuration, not in code. That is the first boundary to draw in a billing system.
The critical insight here is the input-to-output cost ratio. Opus's output is 5x more expensive than its input. If your app generates long responses, charging by "number of requests" will bankrupt you. You're essentially subsidizing users' longer outputs.
A Real Example
Imagine a coding assistant SaaS:
- User asks a 100-token question: "How do I use this API?"
- Claude returns a detailed 500-token explanation
- Your cost (Sonnet 5): (100 × $2 + 500 × $10) / 1,000,000 ≈ $0.0052 per request
At 1,000 requests per month, that's about $5.20 in API costs alone. Charge $5/month for Pro and the API bill eats the entire subscription. Payment processing fees and hosting come out of your own pocket from there.
This is why so many indie AI SaaS products fail. Not because the idea is bad—because the unit economics were never checked.
The Break-Even Formula
Before you design pricing, calculate your margin for each tier.
// Calculate monthly profit for a given tier
const calculateBreakEven = (
monthlyPrice,
avgInputTokens,
avgOutputTokens,
estimatedRequestsPerMonth,
model = 'sonnet'
) => {
// Current generation as of Aug 2026 — read these from config, not source
const pricing = {
haiku: { input: 1, output: 5 }, // Claude Haiku 4.5
sonnet: { input: 2, output: 10 }, // Claude Sonnet 5
opus: { input: 5, output: 25 } // Claude Opus 5
};
const rate = pricing[model];
const costPerRequest = (
(avgInputTokens * rate.input + avgOutputTokens * rate.output) / 1_000_000
);
const totalMonthlyCost = costPerRequest * estimatedRequestsPerMonth;
const profit = monthlyPrice - totalMonthlyCost;
const margin = (profit / monthlyPrice) * 100;
return {
costPerRequest: costPerRequest.toFixed(4),
totalMonthlyCost: totalMonthlyCost.toFixed(2),
monthlyProfit: profit.toFixed(2),
profitMargin: margin.toFixed(1)
};
};
// Check if Pro tier is sustainable
const breakEven = calculateBreakEven(
9.99, // $9.99/month
250, // avg input tokens per request
800, // avg output tokens per request
500, // estimated requests/month
'sonnet'
);
console.log(breakEven);
// {
// costPerRequest: '0.0085',
// totalMonthlyCost: '4.25',
// monthlyProfit: '5.74',
// profitMargin: '57.5'
// }A 57.5% margin looks comfortable. Margins like this rarely collapse loudly — they erode quietly:
- Users send longer requests than you estimated
- They ask for longer responses (Opus instead of Sonnet)
- Usage grows unevenly, and spikes aren't predictable
- You upgrade to a newer model generation and the same text produces more tokens (more on this later)
That last one is the nastiest, because nothing about your code or your users' behavior changed. We'll cover its measured impact and the fix further down.
For indie projects, aim for 50%+ margins initially. Once you have real data, optimize downward. My own rule: if a tier drops below 50%, I decide that same month whether to raise the price or change model routing. Having the threshold written down as a number is what keeps the decision from sliding.
Tier Design: Free, Pro, Enterprise
The 3-tier model works because it creates a clear upgrade path without over-complicating operations.
// lib/billing/tiers.ts
export const BILLING_TIERS = {
free: {
name: 'Free',
monthlyPrice: 0,
requestLimit: 10,
maxOutputTokens: 500,
model: 'haiku',
supportEmail: false,
},
pro: {
name: 'Pro',
monthlyPrice: 9.99,
requestLimit: 500,
maxOutputTokens: 4000,
model: 'sonnet',
supportEmail: true,
},
enterprise: {
name: 'Enterprise',
monthlyPrice: null,
requestLimit: Infinity,
maxOutputTokens: Infinity,
model: 'opus',
supportEmail: true,
features: ['priority-support', 'api-access', 'sso']
}
} as const;
export type BillingTier = keyof typeof BILLING_TIERS;
export const getTierConfig = (tier: BillingTier) => BILLING_TIERS[tier];The Free tier is strategic. Not generous—strategic. A user with 10 monthly requests gets a real feel for the product. With Haiku (1/5 the cost of Sonnet), your server cost is roughly $0.05/month. That's defensible loss-leader math. Compare that to "1 free request only"—users won't even get an impression.
Real-Time Usage Tracking
Stripe's Usage Records let you report consumption at month-end. But here's the thing: you don't want to discover on day 31 that a user went 10x over quota. Real-time tracking is essential.
Maintain a usage table that captures every token consumption event:
// lib/db/usage.ts
export interface UsageRecord {
userId: string;
timestamp: Date;
inputTokens: number;
outputTokens: number;
model: 'haiku' | 'sonnet' | 'opus';
requestId: string;
cost: number; // USD
}
export const recordTokenUsage = async (
db: Database,
userId: string,
response: Message,
model: string
) => {
const inputTokens = response.usage.input_tokens;
const outputTokens = response.usage.output_tokens;
// Calculate dollar cost from token count
const costUSD = calculateTokenCost(model, inputTokens, outputTokens);
await db.usage.create({
userId,
timestamp: new Date(),
inputTokens,
outputTokens,
model,
requestId: response.id,
cost: costUSD,
});
};
class UnknownModelError extends Error {}
const calculateTokenCost = (
model: string,
inputTokens: number,
outputTokens: number
): number => {
const rates: Record<string, { input: number; output: number }> = {
'claude-haiku-4-5-20251001': { input: 1, output: 5 },
'claude-sonnet-5': { input: 2, output: 10 },
'claude-opus-5': { input: 5, output: 25 },
};
const rate = rates[model];
// Never silently fall back. An unknown model must fail loudly.
if (!rate) throw new UnknownModelError(`No billing rate for model: ${model}`);
return (inputTokens * rate.input + outputTokens * rate.output) / 1_000_000;
};
export const getMonthlySummary = async (
db: Database,
userId: string,
year: number,
month: number
) => {
const startDate = new Date(year, month - 1, 1);
const endDate = new Date(year, month, 1);
const records = await db.usage.findMany({
where: {
userId,
timestamp: {
gte: startDate,
lt: endDate,
},
},
});
return {
totalRequests: records.length,
totalInputTokens: records.reduce((sum, r) => sum + r.inputTokens, 0),
totalOutputTokens: records.reduce((sum, r) => sum + r.outputTokens, 0),
totalCost: records.reduce((sum, r) => sum + r.cost, 0),
byModel: records.reduce((acc, r) => {
if (!acc[r.model]) acc[r.model] = 0;
acc[r.model]++;
return acc;
}, {} as Record<string, number>),
};
};Once a month (typically month-end), sync these totals to Stripe for usage-based billing:
// jobs/sync-usage-to-stripe.ts
export const syncUsageToStripe = async () => {
const now = new Date();
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
// Get all active subscriptions
const subscriptions = await db.subscription.findMany({
where: {
status: 'active',
tier: { in: ['pro', 'enterprise'] },
},
include: { user: true },
});
for (const sub of subscriptions) {
const summary = await getMonthlySummary(
db,
sub.userId,
now.getFullYear(),
now.getMonth() + 1
);
// Convert to cents (Stripe requires integers)
const costInCents = Math.round((summary.totalCost || 0) * 100);
// Report to Stripe
await stripe.billing.meterEvents.create({
event_name: 'api_usage',
payload: {
value: costInCents,
stripe_customer_id: sub.stripeCustomerId,
},
});
}
};Key Implementation Details
- Precision: Stripe works in cents (integers). Never accumulate floating-point errors; round at the very end
- Timezone: Decide upfront whether month-end is UTC-based or per-user timezone. Changing this mid-year is chaos
- Idempotency: If this sync job fails partway through, you need a way to retry only the failed records
Stripe Webhooks: Handling Payments, Failures, and Downgrades
Stripe will post events when subscriptions change, payments succeed or fail, and customers cancel. You must handle these reliably.
// app/api/stripe/webhooks/route.ts
import { stripe } from '@/lib/stripe';
import { db } from '@/lib/db';
import { NextRequest } from 'next/server';
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
export async function POST(req: NextRequest) {
const body = await req.text();
const signature = req.headers.get('stripe-signature')!;
// Verify webhook signature
let event;
try {
event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
} catch (err: any) {
return new Response(`Webhook Error: ${err.message}`, { status: 400 });
}
// Idempotency: skip if already processed
const eventExists = await db.webhookEvent.findUnique({
where: { stripeEventId: event.id },
});
if (eventExists) {
return new Response('Event already processed', { status: 200 });
}
// Record event to prevent reprocessing
await db.webhookEvent.create({
data: {
stripeEventId: event.id,
type: event.type,
processedAt: new Date(),
},
});
// Route by event type
switch (event.type) {
case 'invoice.payment_succeeded': {
const invoice = event.data.object as any;
const subscription = await stripe.subscriptions.retrieve(invoice.subscription);
const customerId = subscription.customer as string;
await db.subscription.update({
where: { stripeCustomerId: customerId },
data: { status: 'active', failedPaymentCount: 0 },
});
break;
}
case 'invoice.payment_failed': {
const invoice = event.data.object as any;
const subscription = await stripe.subscriptions.retrieve(invoice.subscription);
const customerId = subscription.customer as string;
const sub = await db.subscription.findUnique({
where: { stripeCustomerId: customerId },
});
const failureCount = (sub?.failedPaymentCount || 0) + 1;
// Auto-downgrade after 3 consecutive failures
if (failureCount >= 3) {
await db.subscription.update({
where: { stripeCustomerId: customerId },
data: { tier: 'free', status: 'downgraded' },
});
// Notify user (send email)
// await sendEmail(...)
} else {
await db.subscription.update({
where: { stripeCustomerId: customerId },
data: { failedPaymentCount: failureCount },
});
}
break;
}
case 'customer.subscription.deleted': {
const subscription = event.data.object as any;
const customerId = subscription.customer as string;
await db.subscription.update({
where: { stripeCustomerId: customerId },
data: { status: 'cancelled', tier: 'free' },
});
break;
}
}
return new Response('OK', { status: 200 });
}Three critical webhook patterns
-
Idempotency is non-negotiable
- Stripe retries failed webhooks. You must guard against processing the same event twice
- Store
stripeEventIdin your database. If it exists, skip processing - This pattern prevents double-charging and duplicate records
-
Fail gracefully
- If your database is down during webhook processing, return HTTP 500 so Stripe retries
- If processing succeeds, return HTTP 200
- A successful response tells Stripe "got it, no need to retry"
-
Handle payment failures automatically
- Three consecutive failures → downgrade to Free
- One failure → increment counter, try again next month
- Automatic downgrade beats support tickets from confused users
Rate Limiting and Feature Gating with Next.js Middleware
Before every Claude API call, you need to enforce tier limits. Next.js middleware is perfect for this:
// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
import { jwtVerify } from 'jose';
import { db } from '@/lib/db';
import { BILLING_TIERS } from '@/lib/billing/tiers';
const secret = new TextEncoder().encode(process.env.JWT_SECRET!);
export async function middleware(request: NextRequest) {
// Only enforce on API routes
if (!request.nextUrl.pathname.startsWith('/api/chat')) {
return NextResponse.next();
}
// Extract user ID from JWT
const authHeader = request.headers.get('authorization');
if (!authHeader?.startsWith('Bearer ')) {
return new NextResponse('Unauthorized', { status: 401 });
}
const token = authHeader.slice(7);
let payload;
try {
const verified = await jwtVerify(token, secret);
payload = verified.payload;
} catch {
return new NextResponse('Invalid token', { status: 401 });
}
const userId = payload.sub as string;
// Fetch user's subscription
const user = await db.user.findUnique({
where: { id: userId },
include: { subscription: true },
});
if (!user?.subscription) {
return new NextResponse('No subscription', { status: 403 });
}
// Count this month's usage
const now = new Date();
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
const monthlyUsage = await db.usage.aggregate({
where: {
userId,
timestamp: { gte: startOfMonth },
},
_count: true,
});
const tier = BILLING_TIERS[user.subscription.tier];
const requestCount = monthlyUsage._count;
// Check request quota
if (requestCount >= tier.requestLimit) {
return new NextResponse(
JSON.stringify({
error: 'Monthly request limit exceeded',
limit: tier.requestLimit,
used: requestCount,
}),
{ status: 429 }
);
}
// Pass tier info to route handler
const response = NextResponse.next();
response.headers.set('x-user-id', userId);
response.headers.set('x-tier', user.subscription.tier);
response.headers.set('x-requests-remaining', String(tier.requestLimit - requestCount));
return response;
}
export const config = {
matcher: ['/api/chat/:path*'],
};In your route handler, use these headers to control which model you call and how long responses can be:
// app/api/chat/route.ts
import { NextRequest, NextResponse } from 'next/server';
import Anthropic from '@anthropic-ai/sdk';
import { recordTokenUsage } from '@/lib/billing/usage';
import { BILLING_TIERS } from '@/lib/billing/tiers';
const client = new Anthropic();
export async function POST(request: NextRequest) {
const userId = request.headers.get('x-user-id')!;
const tier = request.headers.get('x-tier')! as any;
const tierConfig = BILLING_TIERS[tier];
const body = await request.json();
const { messages, model: requestedModel } = body;
// Free users get Haiku only
const model = tier === 'free' ? 'claude-haiku-4-5-20251001' : requestedModel;
try {
const response = await client.messages.create({
model,
max_tokens: tierConfig.maxOutputTokens,
messages,
});
// Record usage immediately
await recordTokenUsage(db, userId, response, model);
return NextResponse.json({
content: response.content,
usage: {
inputTokens: response.usage.input_tokens,
outputTokens: response.usage.output_tokens,
},
});
} catch (error) {
if (error instanceof Anthropic.RateLimitError) {
return NextResponse.json(
{ error: 'Claude API rate limited. Try again later.' },
{ status: 429 }
);
}
throw error;
}
}The middleware pattern keeps your business logic cleanly separated from your API code. Every route handler inherits rate limiting and feature gating automatically.
Monitoring: Catching Cost Explosions Before They Drain Your Account
The nightmare scenario: a bug causes bots to hammer your API, and you wake up to a $50,000 bill.
// jobs/monitor-api-costs.ts
export const monitorAPICosts = async () => {
const now = new Date();
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
// Current month's total
const monthlyCost = await db.usage.aggregate({
where: { timestamp: { gte: startOfMonth } },
_sum: { cost: true },
});
const totalCost = monthlyCost._sum.cost || 0;
const budget = 1000; // $1000 monthly budget
// Alert at 80%
if (totalCost > budget * 0.8) {
await sendAlert({
to: 'admin@yourapp.com',
subject: `Cost Alert: ${(totalCost / budget * 100).toFixed(0)}% of budget used`,
body: `Current API cost: $${totalCost.toFixed(2)} / $${budget}`,
});
}
// Detect anomalies: compare yesterday to daily average
const yesterday = new Date(now);
yesterday.setDate(yesterday.getDate() - 1);
yesterday.setHours(0, 0, 0, 0);
const yesterdayCost = await db.usage.aggregate({
where: {
timestamp: {
gte: yesterday,
lt: new Date(yesterday.getTime() + 24 * 60 * 60 * 1000),
},
},
_sum: { cost: true },
});
const dailyAverage = totalCost / now.getDate();
const yesterdayTotal = yesterdayCost._sum.cost || 0;
// If yesterday was 3x normal, investigate
if (yesterdayTotal > dailyAverage * 3) {
await sendAlert({
to: 'admin@yourapp.com',
subject: `Anomaly: Yesterday cost $${yesterdayTotal.toFixed(2)} (avg: $${dailyAverage.toFixed(2)})`,
body: `Check for bot activity or billing issues.`,
});
}
};Run this daily (via cron or Cloudflare Cron Triggers). A $50k bill is still a $50k bill, but catching it on day 2 instead of day 31 changes everything.
Configuring Stripe: Products, Pricing, and Webhooks
You've built the application logic. Now set up Stripe:
-
Create Products and Prices
- Free (non-recurring)
- Pro ($9.99/month)
- Enterprise (custom quote)
-
Enable Usage Metering (for Pro and Enterprise)
- Dashboard → Billing → Billing Meter
- Create meter:
api_usage - In Prices, link
api_usagemeter to each tier's pricing
-
Register Webhook
- Developers → Webhooks →
/api/stripe/webhooks - Subscribe to:
invoice.payment_succeeded,invoice.payment_failed,customer.subscription.deleted
- Developers → Webhooks →
-
Test Locally
stripe listen --forward-to localhost:3000/api/stripe/webhooks stripe trigger invoice.payment_succeeded
The Tokens That Never Make It Onto Your Invoice
One month my dashboard read $62. The Console invoice read $78. I could not account for the $16, so I audited the aggregation query, then the timezone handling, then finally looked at a raw API response — and there it was.
With prompt caching enabled, usage reports cached tokens in separate fields:
{
"usage": {
"input_tokens": 105,
"cache_creation_input_tokens": 7345,
"cache_read_input_tokens": 7123,
"output_tokens": 6039
}
}input_tokens is 105. The 7,000-plus tokens you are actually billed for live in cache_creation_input_tokens and cache_read_input_tokens. Any metering code that sums only input_tokens and output_tokens becomes structurally under-reported the moment caching is turned on.
Cache rates are multipliers on the base input rate:
| Operation | Multiplier on base input | Sonnet 5 |
|---|---|---|
| 5-minute cache write | 1.25x | $2.50/M |
| 1-hour cache write | 2x | $4/M |
| Cache read (hit) | 0.1x | $0.20/M |
Writes cost a premium; reads cost one tenth. A 5-minute cache pays for itself after a single read, and a 1-hour cache after two. The multipliers are documented on the prompt caching pricing section.
Here is the corrected version of the recordTokenUsage helper from earlier:
type Usage = {
input_tokens: number;
output_tokens: number;
cache_creation_input_tokens?: number;
cache_read_input_tokens?: number;
};
const calculateTokenCostFull = (
model: string,
usage: Usage,
cacheTtl: '5m' | '1h' = '5m'
): number => {
const rate = rates[model];
if (!rate) throw new UnknownModelError(`No billing rate for model: ${model}`);
const writeMultiplier = cacheTtl === '1h' ? 2 : 1.25;
const cost =
usage.input_tokens * rate.input +
(usage.cache_creation_input_tokens ?? 0) * rate.input * writeMultiplier +
(usage.cache_read_input_tokens ?? 0) * rate.input * 0.1 +
usage.output_tokens * rate.output;
return cost / 1_000_000;
};The ?? 0 matters more than it looks. On requests without caching, those fields may be absent entirely, and multiplying undefined propagates NaN through the whole monthly rollup. I lost a month of reporting to exactly that.
One more thing worth saying plainly. Caching genuinely lowers your costs — but if you never record the savings, you have simply hidden your own room to lower prices or widen tiers. You can only pass a discount along once you can see it in your own ledger.
The Same Text, More Tokens: How a Model Upgrade Breaks Tier Design
The easiest thing to miss in billing design is that a "token" is not a fixed unit across model generations.
Claude 4.7 and later models use a newer tokenizer that produces roughly 30% more tokens for the same text (Sonnet 4.6 and earlier use the previous one). The exact increase depends on your content. This is stated directly in the pricing page notes.
Here is how that plays out:
- Your users' behavior is identical to last month
- Your code is unchanged
- You switched to a newer model generation
- And now a "100,000 tokens/month" free tier delivers roughly 70,000 tokens' worth of work
To users it looks like you quietly tightened the limits. To you it does not feel like a price increase at all. That mismatch is one of the hardest churn reasons to explain after the fact.
Your unit economics move too. Re-running the Pro tier from earlier ($9.99/month, 250 input / 800 output tokens, 500 requests/month) with 30% more tokens:
| Metric | Previous tokenizer | Newer tokenizer (+30% assumed) |
|---|---|---|
| Avg input / output | 250 / 800 | 325 / 1,040 |
| Cost per request | $0.0085 | $0.01105 |
| Monthly cost | $4.25 | $5.53 |
| Margin | 57.5% | 44.6% |
A 13-point drop, with not a single rate changed.
Three things fix this.
1. Stop denominating quotas in tokens. Show users "N requests per month" and enforce "N dollars per month" internally. Tokens are a ruler that stretches between generations — a poor unit for a contract.
// Budgets in dollars; tokens are recorded but never enforced
export const TIER_BUDGETS = {
free: { requests: 100, hardCostCapUSD: 0.5 },
pro: { requests: 5000, hardCostCapUSD: 4.5 }, // cost cap under a $9.99 plan
} as const;
export async function assertWithinBudget(db: Database, userId: string, tier: keyof typeof TIER_BUDGETS) {
const spent = await getMonthlyCostUSD(db, userId);
const cap = TIER_BUDGETS[tier].hardCostCapUSD;
if (spent >= cap) {
throw new BudgetExceededError(`Monthly cost cap reached: $${spent.toFixed(2)} / $${cap}`);
}
}2. If you must show token quotas, keep a per-model conversion factor. Normalize displayed consumption back to a baseline generation before deducting from the quota, and the user experience stays continuous across upgrades.
3. Measure your own prompts before migrating. The 30% figure is a general guideline, not a promise about your text. Send your actual system prompt and a handful of representative inputs through the token counting endpoint on both generations, and migrate with your own number in hand. For my mostly-Japanese prompts, the increase came out somewhat above the guideline.
Before you pick a migration date, put at least item 1 in place. A single dollar-denominated cap means a tokenizer change can never spike your invoice.
Production Lessons Learned
Payment Failures
Auto-downgrade is your friend. Users who can't pay aren't going to suddenly find a new payment method—they'll just get frustrated when the app stops working. Downgrading them to Free and sending a polite "we had trouble charging you" email leads to faster resolution than "your account is now locked."
Usage Bursts
Normal users send 10 requests/day. Then you get someone processing a 10,000-item CSV in one session—1,000 requests in an hour. This isn't malicious; it's just a different use case. Consider adding "burst limits" (rate limiting per 15 minutes) alongside monthly caps.
Proactive Notifications
Don't wait for users to hit 100% quota. At 70%, send email + dashboard notification. This isn't nagging—it's a natural upgrade prompt. Users appreciate knowing their limits before they hit them.
Putting It All Together
Building a Claude API SaaS with real billing isn't about clever tricks. It's a straightforward pipeline:
- Understand your actual API costs (tokens → dollars)
- Design tiers with margins that survive reality
- Track every user's consumption in real time
- Sync usage to Stripe monthly
- Let Stripe handle payment logic (and send webhooks)
- Respond to webhooks with idempotent, automatic actions
- Enforce limits in middleware
- Monitor costs continuously
Each piece is independent. You can build and test them one by one. Start with Free / Pro (2 tiers), add Enterprise later. Measure real user behavior before optimizing.
If you're at the "I built an AI app, now what?" stage, start here: create a simple cost-tracking spreadsheet. Model three scenarios: "things go badly" (4x usage), "things go well" (2x), and "things go as planned." Which pricing survives all three? That's your starting point.