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-05-03Advanced

Building a Subscription SaaS with Claude API and Stripe — A Complete 2026 Implementation Guide

An end-to-end implementation guide for shipping a subscription SaaS built on Claude API, Stripe, and Cloudflare Workers — covering checkout, webhooks, KV-backed access control, usage limits, and the production edge cases that always bite.

Claude API115Stripe15Cloudflare Workers14SaaS15Implementation

In the companion roadmap, I argued that indie SaaS economics live or die at the unit-cost layer. This piece is the implementation half: the actual Stripe + Cloudflare Workers + Claude API code that turns the roadmap into a running, billing product.

I run four AI-focused publications on this same stack — Claude Lab, Gemini Lab, Antigravity Lab, and Rork Lab — and over time I've stepped on almost every rake an indie developer can step on with subscription billing. This guide is the consolidated result: the architecture I'd recommend to a friend launching their first paid product.

The code targets Next.js 16 (App Router) on Cloudflare Workers via OpenNext, with Stripe v15. The logic translates fine to other stacks; only the deployment glue changes.

Reference Architecture

Here is the system we're building toward:

  • Authentication: NextAuth.js or Clerk (this guide uses NextAuth)
  • Billing: Stripe Checkout for subscriptions, Stripe Customer Portal for self-service cancellation
  • Authorization source of truth: Cloudflare KV, written by the Stripe webhook
  • API execution layer: Cloudflare Workers + Anthropic SDK
  • Paywall: Server middleware reads KV; non-members get blocked

KV as the single source of truth is the most important architectural decision. Querying Stripe on every page load is wrong on two axes — rate limits and latency. Update KV only when state changes (via webhook), and read only KV at runtime.

Step 1: Define Products and Prices in Stripe

Create products and prices in the Stripe Dashboard. My standard layout looks like this:

  • Pro Monthly (JPY): ¥580/month, recurring, stored as STRIPE_PRICE_PRO_JPY
  • Pro Monthly (USD): $5/month, stored as STRIPE_PRICE_PRO_USD
  • Premium Lifetime (JPY/USD): ¥2,480 / $15 one-shot, stored as STRIPE_PRICE_PREMIUM_*

Splitting products by currency matters. Stripe Checkout shows the product name and description, and you want those localized properly per locale. A single bilingual product invariably leaks the wrong language into checkout (see project STUMBLING_POINTS #79).

Store the price IDs in wrangler.toml. Put the actual secrets (STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET) in wrangler secret put instead.

Step 2: Implement the Checkout Endpoint

In Next.js App Router, place a POST handler at app/api/checkout/route.ts. The example below handles both subscriptions and one-shot purchases through the same endpoint:

import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { getPriceIds, getProductData } from '@/config/pricing';
 
export const runtime = 'edge';
 
export async function POST(req: NextRequest) {
  const session = await getServerSession(authOptions);
  if (!session?.user?.email) {
    return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
  }
 
  const { plan, locale, articleSlug } = await req.json();
  const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
    apiVersion: '2025-10-31',
  });
 
  const product = getProductData(plan, locale);
 
  const params: Stripe.Checkout.SessionCreateParams = {
    mode: plan === 'pro' ? 'subscription' : 'payment',
    success_url: `${process.env.SITE_URL}/${locale}/thanks?session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: `${process.env.SITE_URL}/${locale}/membership`,
    customer_email: session.user.email,
    line_items: [{
      price_data: {
        currency: locale === 'ja' ? 'jpy' : 'usd',
        recurring: plan === 'pro' ? { interval: 'month' } : undefined,
        unit_amount: product.amount,
        product_data: {
          name: product.name,
          description: product.description,
          images: [product.imageUrl],
        },
      },
      quantity: 1,
    }],
    metadata: {
      plan_type: plan,
      user_email: session.user.email,
      article_slug: articleSlug ?? '',
    },
  };
 
  const checkoutSession = await stripe.checkout.sessions.create(params);
  return NextResponse.json({ url: checkoutSession.url });
}

Always set metadata.plan_type. The webhook needs it to distinguish a subscription start from a tip from a one-shot purchase. Misclassifying these is the single most common indie billing bug — seeing a tip accidentally grant lifetime premium access is a war story I've heard from at least four developers personally.

Step 3: Handle the Stripe Webhook

The webhook is the heart of the system. Payment success → KV write happens entirely here.

// app/api/stripe/webhook/route.ts
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';
import { getCloudflareContext } from '@opennextjs/cloudflare';
 
export const runtime = 'edge';
 
export async function POST(req: NextRequest) {
  const sig = req.headers.get('stripe-signature');
  const body = await req.text();
  const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
    apiVersion: '2025-10-31',
  });
 
  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(
      body, sig!, process.env.STRIPE_WEBHOOK_SECRET!
    );
  } catch (e) {
    return NextResponse.json({ error: 'invalid signature' }, { status: 400 });
  }
 
  const { env } = getCloudflareContext();
  const kv = env.MEMBERSHIP_KV;
 
  // Idempotency: skip duplicates
  const eventKey = `site:claudelab:webhook:${event.id}`;
  const seen = await kv.get(eventKey);
  if (seen) {
    return NextResponse.json({ received: true, duplicate: true });
  }
  await kv.put(eventKey, '1', { expirationTtl: 86400 * 7 });
 
  switch (event.type) {
    case 'checkout.session.completed': {
      const s = event.data.object as Stripe.Checkout.Session;
      const planType = s.metadata?.plan_type;
      const email = s.customer_email ?? s.metadata?.user_email;
      if (!email) break;
 
      if (planType === 'pro') {
        const subscription = await stripe.subscriptions.retrieve(s.subscription as string);
        await kv.put(
          `site:claudelab:member:${email}`,
          JSON.stringify({
            plan: 'pro',
            current_period_end: subscription.current_period_end,
            subscription_id: subscription.id,
          }),
          { expirationTtl: subscription.current_period_end - Math.floor(Date.now() / 1000) + 86400 * 3 }
        );
      } else if (planType === 'premium') {
        await kv.put(
          `site:claudelab:member:${email}`,
          JSON.stringify({ plan: 'premium' }),
          { expirationTtl: 86400 * 365 * 10 }
        );
      } else if (planType === 'article') {
        const slug = s.metadata?.article_slug;
        if (slug) {
          await kv.put(
            `site:claudelab:article:${email}:${slug}`,
            '1',
            { expirationTtl: 86400 * 365 * 10 }
          );
        }
      }
      // tips are intentionally not granted any access
      break;
    }
    case 'customer.subscription.deleted': {
      const sub = event.data.object as Stripe.Subscription;
      const customer = await stripe.customers.retrieve(sub.customer as string);
      if ('email' in customer && customer.email) {
        await kv.delete(`site:claudelab:member:${customer.email}`);
      }
      break;
    }
  }
  return NextResponse.json({ received: true });
}

The TTL design matters more than it looks. Setting Pro members to "next renewal date + 3 days of buffer" creates an automatic safety valve: if a webhook is somehow lost during cancellation, KV will simply forget the user a few days later. Lifetime purchases get 10 years, which is functionally infinite.

Step 4: Read Authorization at the Paywall

Pull the membership state out of KV through a small, central helper. I keep this in lib/premium.ts:

import { getCloudflareContext } from '@opennextjs/cloudflare';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
 
export async function canViewPremium(): Promise<boolean> {
  const session = await getServerSession(authOptions);
  if (!session?.user?.email) return false;
  try {
    const { env } = getCloudflareContext();
    const raw = await env.MEMBERSHIP_KV.get(`site:claudelab:member:${session.user.email}`);
    if (!raw) return false;
    const data = JSON.parse(raw);
    return data.plan === 'pro' || data.plan === 'premium';
  } catch (e) {
    return false; // deny by default
  }
}
 
export async function getArticleAccess(slug: string): Promise<boolean> {
  const session = await getServerSession(authOptions);
  if (!session?.user?.email) return false;
  try {
    const { env } = getCloudflareContext();
    const raw = await env.MEMBERSHIP_KV.get(
      `site:claudelab:article:${session.user.email}:${slug}`
    );
    return raw === '1';
  } catch (e) {
    return false; // deny by default
  }
}

The fallback returning false is non-negotiable. The day someone "fixes" this to return true for "robustness" is the day a KV outage opens every premium article to the public. Deny by default isn't a stylistic preference; it's a property your business needs.

Step 5: Implement Per-User Usage Caps

The hybrid plan I recommended in the roadmap (flat fee + overage) requires per-user monthly counters. Use KV again, with a month-scoped key:

const PLAN_LIMITS: Record<string, number> = {
  free: 5,
  pro: 100,
  premium: 9999,
};
 
export async function checkAndIncrementUsage(
  email: string,
  plan: 'free' | 'pro' | 'premium'
): Promise<{ allowed: boolean; remaining: number; overage: number }> {
  const { env } = getCloudflareContext();
  const month = new Date().toISOString().slice(0, 7);
  const key = `site:claudelab:usage:${email}:${month}`;
 
  const raw = await env.MEMBERSHIP_KV.get(key);
  const current = raw ? parseInt(raw, 10) : 0;
  const limit = PLAN_LIMITS[plan];
 
  const now = new Date();
  const monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 1);
  const ttl = Math.floor((monthEnd.getTime() - now.getTime()) / 1000) + 86400;
 
  await env.MEMBERSHIP_KV.put(key, String(current + 1), { expirationTtl: ttl });
 
  if (current >= limit) {
    return { allowed: plan === 'pro', remaining: 0, overage: current + 1 - limit };
  }
  return { allowed: true, remaining: limit - current - 1, overage: 0 };
}

In a hybrid plan, Pro users who exceed the cap still get allowed: true; the overage is recorded for end-of-month billing. The full Stripe Usage Records dance is overkill for indie scale — sending users a polite end-of-month email with their overage and a one-click link to top up performs surprisingly well.

Step 6: Call the Anthropic SDK

After authorization and quota checks pass, finally call Claude:

import Anthropic from '@anthropic-ai/sdk';
 
export const runtime = 'edge';
 
export async function POST(req: Request) {
  const session = await getServerSession(authOptions);
  if (!session?.user?.email) return new Response('unauthorized', { status: 401 });
 
  const isMember = await canViewPremium();
  const plan = isMember ? 'pro' : 'free';
 
  const usage = await checkAndIncrementUsage(session.user.email, plan);
  if (!usage.allowed) {
    return Response.json({ error: 'usage_limit_exceeded' }, { status: 429 });
  }
 
  const { prompt } = await req.json();
  const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
 
  const message = await anthropic.messages.create({
    model: 'claude-sonnet-4-6',
    max_tokens: 2000,
    messages: [{ role: 'user', content: prompt }],
  });
 
  return Response.json({
    text: message.content[0].type === 'text' ? message.content[0].text : '',
    remaining: usage.remaining,
    overage: usage.overage,
  });
}

Always set max_tokens explicitly. The classic indie disaster is "infinite max_tokens in a retry loop burns the entire month's budget in one afternoon." Set Anthropic Console budget alerts on top, not instead of, this guardrail.

Step 7: Cancellation Flows and Edge Cases

Production cancellation flows always have at least three rakes lying around.

Last-minute usage on canceled accounts. When a user clicks cancel, they should still be able to use the product through the end of their billing period. Listen for customer.subscription.updated (with cancel_at_period_end: true) to mark the upcoming cancellation, and only treat customer.subscription.deleted as the actual access removal point.

Auto-downgrade after expiration. The TTL pattern (period_end + 3 days) handles this gracefully when webhooks are reliable. For belt-and-suspenders safety, run a Cron Trigger on the first of each month that fetches recently-expired subscriptions from Stripe and reconciles KV.

Duplicate webhooks causing double charges. Stripe sometimes resends events. The event.id idempotency check shown in Step 3 isn't optional — it's the difference between a calm operations life and a string of "I got charged twice" support tickets.

Step 8: Fight Cloudflare Edge Cache Pollution

The most-overlooked indie SaaS bug: Cloudflare Workers HTML caching ignores cookies by default, so the same membership-CTA HTML gets served to paying and non-paying users alike.

The fix is two-layered. First, in cache-worker.js, bypass the cache for any request carrying a premium_token or article_purchases cookie. Second, render any membership-aware UI from a client-side fetch to /api/premium-status so the post-payment refresh sees fresh content.

addEventListener('fetch', event => {
  const req = event.request;
  const cookie = req.headers.get('cookie') || '';
 
  if (cookie.includes('premium_token') || cookie.includes('article_purchases')) {
    event.respondWith(fetch(req));
    return;
  }
  event.respondWith(handleCachedRequest(event));
});

Skip this and you'll get a flood of "I paid but the upgrade prompt is still there" tickets within a week (project STUMBLING_POINTS #73).

Pre-Launch Checklist

Before you flip the switch on production:

  • The Stripe webhook endpoint URL points at production
  • STRIPE_WEBHOOK_SECRET is the live-mode secret, not the test one
  • KV keys are namespaced with site:{name}: so multi-site reuse can't cross-contaminate
  • Monthly budget alerts are configured in Anthropic Console
  • You've tested the create → cancel → resubscribe path end-to-end in Stripe test mode
  • Premium features are confirmed blocked after subscription deletion
  • cache-worker.js cookie bypass is verified live

Hit those marks and you have a Claude-powered subscription SaaS that bills correctly from the first paying user. From here, the work is no longer architectural — it's the patient retention craft from the roadmap article.

If you get stuck in a corner, the four sites I run on this exact stack — Claude Lab, Gemini Lab, Antigravity Lab, and Rork Lab — are open as a working reference. Email me through any of their support pages and I'll help where I can.

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-04-27
Indie Developer's Claude API SaaS Launch Blueprint — A 90-Day Roadmap from Idea to Paying Customers
A complete 90-day roadmap for building an indie Claude API business: idea validation, Stripe integration, SEO, subscription pricing tests, and the operational and emotional discipline that makes it last. Drawing on twelve years of solo app development and the new realities of AI APIs.
API & SDK2026-04-25
Implementing Usage-Based Billing for Claude API Services — Token Tracking, Price Conversion, and Stripe Metering from Scratch
A complete implementation guide for usage-based billing in Claude API services. Covers token measurement, markup calculation, Stripe Metered Billing integration, and per-user plan limits — with production-ready code throughout.
API & SDK2026-04-24
Claude API × MCP: Building a Paid Consulting SaaS That Runs Without You
Build a sustainable consulting SaaS solo. Learn the complete architecture, implementation, and operations behind a ¥30,000/month revenue system using Claude API, MCP, Stripe, and CloudFlare KV. Includes real code, cost breakdowns, and hard-won lessons from scaling to enterprise.
📚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 →