CLAUDE LABJP
FORK — Claude Code 2.1.212 changes what /fork does: it copies your conversation into a new background session with its own row in claude agents, so you can keep working. The old in-session subagent is now /subtaskLIMITS — WebSearch calls are now capped at 200 per session by default, and subagent spawns get the same 200 ceiling, so a runaway search or delegation loop stops on its ownMCPBG — MCP tool calls running past two minutes now move to the background automatically, keeping the session usable. Tune the threshold with CLAUDE_CODE_MCP_AUTO_BACKGROUND_MSPLANFIX — Fixed plan mode auto-running file-modifying Bash commands such as touch and rm without a permission prompt or an SDK canUseTool callbackSONNET5 — Claude Sonnet 5 is running on introductory pricing of $2 per million input tokens and $10 per million output. After August 31 it moves to $3 and $15IPO — Bankers are reportedly lining up investor meetings for Anthropic ahead of a possible public listing as soon as OctoberFORK — Claude Code 2.1.212 changes what /fork does: it copies your conversation into a new background session with its own row in claude agents, so you can keep working. The old in-session subagent is now /subtaskLIMITS — WebSearch calls are now capped at 200 per session by default, and subagent spawns get the same 200 ceiling, so a runaway search or delegation loop stops on its ownMCPBG — MCP tool calls running past two minutes now move to the background automatically, keeping the session usable. Tune the threshold with CLAUDE_CODE_MCP_AUTO_BACKGROUND_MSPLANFIX — Fixed plan mode auto-running file-modifying Bash commands such as touch and rm without a permission prompt or an SDK canUseTool callbackSONNET5 — Claude Sonnet 5 is running on introductory pricing of $2 per million input tokens and $10 per million output. After August 31 it moves to $3 and $15IPO — Bankers are reportedly lining up investor meetings for Anthropic ahead of a possible public listing as soon as October
Articles/API & SDK
API & SDK/2026-04-28Advanced

Building a Recurring Billing SaaS with Claude API and Stripe — From Architecture to Production

A complete architecture guide for building a SaaS product powered by Claude API with Stripe recurring billing. Covers usage metering, tiered pricing, webhook handling, and production deployment patterns.

claude-api81stripe6saas2monetization21billing8webhook4usage-meteringarchitecture10

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:

ModelInputOutput
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

  1. Idempotency is non-negotiable

    • Stripe retries failed webhooks. You must guard against processing the same event twice
    • Store stripeEventId in your database. If it exists, skip processing
    • This pattern prevents double-charging and duplicate records
  2. 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"
  3. 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:

  1. Create Products and Prices

    • Free (non-recurring)
    • Pro ($9.99/month)
    • Enterprise (custom quote)
  2. Enable Usage Metering (for Pro and Enterprise)

    • Dashboard → Billing → Billing Meter
    • Create meter: api_usage
    • In Prices, link api_usage meter to each tier's pricing
  3. Register Webhook

    • Developers → Webhooks → /api/stripe/webhooks
    • Subscribe to: invoice.payment_succeeded, invoice.payment_failed, customer.subscription.deleted
  4. 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:

  1. Understand your actual API costs (tokens → dollars)
  2. Design tiers with margins that survive reality
  3. Track every user's consumption in real time
  4. Sync usage to Stripe monthly
  5. Let Stripe handle payment logic (and send webhooks)
  6. Respond to webhooks with idempotent, automatic actions
  7. Enforce limits in middleware
  8. 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.

Share

Thank You for Reading

Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • Copy-paste ready implementation code
  • New advanced guides published daily
  • $5/mo or $10 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

API & SDK2026-06-24
When Stripe's Bill and Your Own Ledger Drift Apart: Field Notes on Metered Billing for Claude API
Usage-based billing for Claude API looks clean until month-end, when Stripe's total and your own usage ledger quietly disagree. Here are the field-tested patterns for idempotent meter events, reconciliation jobs, and pricing-change-proof credits.
API & SDK2026-06-15
When a Model Disappears Without Warning: A State Machine for Retirement, Withdrawal, and Overload
A model can become unusable in hours for reasons that have nothing to do with a technical outage. This guide models three distinct flavors of 'unavailable'—retirement, withdrawal, and transient overload—as one availability state machine, with a router that keeps automated pipelines running. Working TypeScript and Python included.
API & SDK2026-06-13
Managed Agents Adds Scheduled Deploys, a Vault, and Session Webhooks — Deciding What Leaves My Cron Setup
Scheduled deploys, vault credentials, and session-thread webhooks just landed in Claude Managed Agents. How I triaged my self-hosted cron jobs: what moves, what stays, and why idempotency decides.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →