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 (Opus / Sonnet / Haiku). As of early 2026, the approximate breakdown is:
| Model | Input | Output |
|---|---|---|
| Haiku | $0.80/M | $4/M |
| Sonnet | $3/M | $15/M |
| Opus | $15/M | $75/M |
(M = 1 million tokens, prices approximate)
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): (100 × $3 + 500 × $15) / 1,000,000 ≈ $0.0081 per request
If you have 1,000 requests per month, that's $8 in API costs alone. But if you're charging users $5/month for Pro, you're losing money on day one.
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'
) => {
const pricing = {
haiku: { input: 0.8, output: 4 },
sonnet: { input: 3, output: 15 },
opus: { input: 15, output: 75 }
};
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.0129',
// totalMonthlyCost: '6.45',
// monthlyProfit: '3.54',
// profitMargin: '35.4'
// }A 35% margin sounds healthy, but reality is messier:
- Users may send longer requests than you estimated
- They'll request longer responses (Opus instead of Sonnet)
- Usage grows unevenly—spikes aren't always predictable
For indie projects, aim for 50%+ margins initially. Once you have real data, optimize downward. Launching with tight margins is a recipe for panic pricing changes.
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,
});
};
const calculateTokenCost = (
model: string,
inputTokens: number,
outputTokens: number
): number => {
const rates: Record<string, { input: number; output: number }> = {
'claude-3-5-haiku-latest': { input: 0.8, output: 4 },
'claude-3-5-sonnet-latest': { input: 3, output: 15 },
'claude-opus-4-1': { input: 15, output: 75 },
};
const rate = rates[model] || rates['claude-3-5-sonnet-latest'];
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-3-5-haiku-latest' : 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
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.