Use Cases (Updated: 6/2/2026)

Build Stripe Subscriptions with Claude Code

Implement Stripe subscriptions with Claude Code: Checkout, Portal, webhooks, entitlements, and SaaS monetization.

Build Stripe Subscriptions with Claude Code

Subscription billing is an operating system, not a button

Stripe Checkout can start a subscription quickly, but recurring revenue needs more than a payment button. A real SaaS, paid newsletter, course library, or consulting funnel has to handle plan design, invoices, failed payments, cancellation timing, customer self-service, webhook retries, and feature access. If those states are vague, the first production incident usually appears as a support email from a paying customer.

Use Claude Code as the implementation harness. A harness is the practical support structure around the agent: requirements, official docs, database shape, test commands, and review rules. The goal is not to ask for “Stripe integration” in one vague prompt. The goal is to make Claude Code generate code that matches your revenue model and can be reviewed line by line.

The clean mental model is this: Stripe is the billing source of truth, while your app database stores the access decision. Stripe knows whether the subscription is active, past_due, canceled, or unpaid. Your app knows whether the user can download templates, read member content, create team seats, or request a review.

flowchart LR
  A["Pricing page"] --> B["Checkout Session<br/>mode=subscription"]
  B --> C["Stripe subscription"]
  C --> D["Signed webhook"]
  D --> E["billing_subscriptions"]
  D --> F["entitlements"]
  F --> G["Feature gate"]
  G --> H["SaaS, course, or product library"]
  H --> I["Customer Portal"]
  I --> C

Start with price and access design

Before writing code, decide what each plan unlocks. This keeps the article monetization-aware and prevents vague subscription state from leaking into product logic.

PlanReader or buyerMonthly exampleAnnual exampleEntitlements
FreeVisitors testing the content$0$0previews and sample downloads
ProSolo builders and info-product buyers$19$190full articles, templates, course library
Studioteams and recurring clients$99$990Pro access, team seats, review requests, training assets

Three concrete use cases help catch missing states. First, a micro-SaaS can unlock exports, dashboards, and automation only for paid plans. Second, an info-product funnel can move readers from a free article to a paid template library and then to a membership. Third, a consulting or training business can sell recurring access to team materials, review slots, and office-hour recordings. A fourth optional use case is usage add-ons, but do not add metered billing until the fixed subscription flow is stable.

Terms matter. An entitlement is simply a feature permission such as templates:download or team:seats. Dunning means the recovery workflow after a payment fails: emails, retry rules, and a link to update the payment method. Observability means you can see which event changed which user, plan, and access state.

Prompt Claude Code with official docs

Stripe changes over time, so point Claude Code at official docs instead of relying on memory.

Implement Stripe Billing subscriptions for a Next.js App Router app.
Use the official Stripe docs as the source of truth:
- Checkout Sessions API: https://docs.stripe.com/api/checkout/sessions/create
- Customer Portal: https://docs.stripe.com/customer-management
- Subscription webhooks: https://docs.stripe.com/billing/subscriptions/webhooks
- Webhooks and local testing: https://docs.stripe.com/webhooks
- Subscription object and statuses: https://docs.stripe.com/api/subscriptions/object

Requirements:
- TypeScript, Next.js App Router, Postgres
- Checkout uses mode="subscription"
- Customer Portal handles payment method updates, invoices, plan changes, and cancellation
- Webhook route verifies the Stripe signature and deduplicates event IDs
- Local entitlements table controls application access
- active/trialing grant access, past_due gets a 3-day grace period, unpaid/canceled/paused revoke access
- Include copy-paste code and local stripe listen commands

Store billing state separately from access

Use this Postgres schema as the minimum reliable shape. billing_subscriptions mirrors the latest Stripe subscription state. entitlements is what your app checks before showing paid features. webhook_events protects you from duplicate webhook delivery and retry noise.

create table if not exists app_users (
  id text primary key,
  email text not null unique,
  stripe_customer_id text unique,
  created_at timestamptz not null default now()
);

create table if not exists billing_subscriptions (
  user_id text primary key references app_users(id) on delete cascade,
  stripe_customer_id text not null,
  stripe_subscription_id text not null unique,
  plan_key text not null,
  status text not null check (
    status in ('incomplete', 'incomplete_expired', 'trialing', 'active', 'past_due', 'canceled', 'unpaid', 'paused')
  ),
  access_state text not null check (access_state in ('pending', 'granted', 'grace', 'revoked')),
  current_period_end timestamptz,
  cancel_at_period_end boolean not null default false,
  grace_until timestamptz,
  updated_at timestamptz not null default now()
);

create table if not exists entitlements (
  user_id text not null references app_users(id) on delete cascade,
  feature_key text not null,
  active boolean not null default true,
  expires_at timestamptz,
  updated_at timestamptz not null default now(),
  primary key (user_id, feature_key)
);

create table if not exists webhook_events (
  event_id text primary key,
  event_type text not null,
  status text not null default 'processing',
  attempts integer not null default 1,
  processed_at timestamptz,
  last_error text,
  created_at timestamptz not null default now()
);

Checkout and Customer Portal

Install the server dependencies and add the environment variables. Replace the demo header-based user lookup with your real auth provider.

npm install stripe postgres
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_PRO_PRICE_ID=price_...
STRIPE_STUDIO_PRICE_ID=price_...
NEXT_PUBLIC_APP_URL=http://localhost:3000
DATABASE_URL=postgres://user:pass@localhost:5432/app
STRIPE_AUTOMATIC_TAX=false
// src/lib/db.ts
import postgres from "postgres";

export const sql = postgres(process.env.DATABASE_URL!, {
  max: 5,
  idle_timeout: 20,
});
// src/lib/billing.ts
import Stripe from "stripe";
import { sql } from "@/lib/db";

export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

const APP_URL = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";

export const PLANS = {
  free: { priceId: null, features: ["article:preview"] },
  pro: {
    priceId: process.env.STRIPE_PRO_PRICE_ID!,
    features: ["article:full", "templates:download", "course:library"],
  },
  studio: {
    priceId: process.env.STRIPE_STUDIO_PRICE_ID!,
    features: ["article:full", "templates:download", "course:library", "team:seats", "review:request"],
  },
} as const;

export type PlanKey = keyof typeof PLANS;
export type PaidPlanKey = Exclude<PlanKey, "free">;

export function isPaidPlanKey(value: unknown): value is PaidPlanKey {
  return value === "pro" || value === "studio";
}

export async function createCheckoutSession(userId: string, planKey: PaidPlanKey) {
  const customerId = await findOrCreateCustomer(userId);
  const plan = PLANS[planKey];

  const session = await stripe.checkout.sessions.create({
    customer: customerId,
    mode: "subscription",
    line_items: [{ price: plan.priceId, quantity: 1 }],
    client_reference_id: userId,
    allow_promotion_codes: true,
    automatic_tax: { enabled: process.env.STRIPE_AUTOMATIC_TAX === "true" },
    success_url: `${APP_URL}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: `${APP_URL}/pricing`,
    subscription_data: { metadata: { userId, planKey } },
    metadata: { userId, planKey },
  });

  if (!session.url) throw new Error("Stripe did not return a Checkout URL");
  return session.url;
}

export async function createPortalSession(userId: string) {
  const customerId = await getCustomerId(userId);
  const session = await stripe.billingPortal.sessions.create({
    customer: customerId,
    return_url: `${APP_URL}/settings/billing`,
  });
  return session.url;
}

async function findOrCreateCustomer(userId: string) {
  const users = await sql`
    select id, email, stripe_customer_id
    from app_users
    where id = ${userId}
  `;
  const user = users[0] as { id: string; email: string; stripe_customer_id: string | null } | undefined;
  if (!user) throw new Error("User not found");
  if (user.stripe_customer_id) return user.stripe_customer_id;

  const customer = await stripe.customers.create({
    email: user.email,
    metadata: { userId },
  });

  await sql`
    update app_users
    set stripe_customer_id = ${customer.id}
    where id = ${userId}
  `;
  return customer.id;
}

async function getCustomerId(userId: string) {
  const rows = await sql`
    select stripe_customer_id
    from app_users
    where id = ${userId}
  `;
  const customerId = rows[0]?.stripe_customer_id as string | undefined;
  if (!customerId) throw new Error("Stripe customer is not linked");
  return customerId;
}
// src/app/api/billing/checkout/route.ts
import { NextRequest, NextResponse } from "next/server";
import { createCheckoutSession, isPaidPlanKey } from "@/lib/billing";

export async function POST(request: NextRequest) {
  const userId = request.headers.get("x-demo-user-id");
  if (!userId) return NextResponse.json({ error: "Missing x-demo-user-id" }, { status: 401 });

  const { planKey } = await request.json();
  if (!isPaidPlanKey(planKey)) return NextResponse.json({ error: "Invalid plan" }, { status: 400 });

  const url = await createCheckoutSession(userId, planKey);
  return NextResponse.json({ url });
}
// src/app/api/billing/portal/route.ts
import { NextRequest, NextResponse } from "next/server";
import { createPortalSession } from "@/lib/billing";

export async function POST(request: NextRequest) {
  const userId = request.headers.get("x-demo-user-id");
  if (!userId) return NextResponse.json({ error: "Missing x-demo-user-id" }, { status: 401 });

  const url = await createPortalSession(userId);
  return NextResponse.json({ url });
}

Webhooks and entitlements

The success URL is not proof of payment. It is a user experience path. Provision access from signed webhooks or from the latest Stripe API state. Stripe also notes that webhook events can arrive out of order, so this handler retrieves the current subscription before writing access.

// src/lib/entitlements.ts
import Stripe from "stripe";
import { sql } from "@/lib/db";
import { PLANS, PlanKey, stripe } from "@/lib/billing";

type AccessState = "pending" | "granted" | "grace" | "revoked";

export async function syncSubscriptionFromStripe(subscriptionId: string) {
  const subscription = await stripe.subscriptions.retrieve(subscriptionId, {
    expand: ["items.data.price"],
  });

  const item = subscription.items.data[0];
  const planKey = planKeyFromPrice(item?.price.id);
  const userId = subscription.metadata.userId;
  if (!userId) throw new Error(`Subscription ${subscription.id} has no userId metadata`);

  const accessState = accessStateFor(subscription.status);
  const currentPeriodEnd = item?.current_period_end ? new Date(item.current_period_end * 1000) : null;
  const graceUntil = accessState === "grace" ? new Date(Date.now() + 3 * 24 * 60 * 60 * 1000) : null;

  await sql.begin(async (tx) => {
    await tx`
      insert into billing_subscriptions (
        user_id, stripe_customer_id, stripe_subscription_id, plan_key,
        status, access_state, current_period_end, cancel_at_period_end, grace_until, updated_at
      )
      values (
        ${userId}, ${subscription.customer as string}, ${subscription.id}, ${planKey},
        ${subscription.status}, ${accessState}, ${currentPeriodEnd},
        ${subscription.cancel_at_period_end}, ${graceUntil}, now()
      )
      on conflict (user_id) do update set
        stripe_customer_id = excluded.stripe_customer_id,
        stripe_subscription_id = excluded.stripe_subscription_id,
        plan_key = excluded.plan_key,
        status = excluded.status,
        access_state = excluded.access_state,
        current_period_end = excluded.current_period_end,
        cancel_at_period_end = excluded.cancel_at_period_end,
        grace_until = excluded.grace_until,
        updated_at = now()
    `;

    const features = accessState === "granted" || accessState === "grace" ? PLANS[planKey].features : PLANS.free.features;
    await tx`delete from entitlements where user_id = ${userId}`;
    for (const feature of features) {
      await tx`
        insert into entitlements (user_id, feature_key, active, expires_at, updated_at)
        values (${userId}, ${feature}, true, ${graceUntil}, now())
      `;
    }
  });

  console.info("stripe.subscription.synced", { userId, subscriptionId: subscription.id, status: subscription.status, accessState, planKey });
}

export async function hasEntitlement(userId: string, featureKey: string) {
  const rows = await sql`
    select 1 from entitlements
    where user_id = ${userId}
      and feature_key = ${featureKey}
      and active = true
      and (expires_at is null or expires_at > now())
    limit 1
  `;
  return rows.length > 0;
}

function planKeyFromPrice(priceId: string | undefined): PlanKey {
  const entry = Object.entries(PLANS).find(([, plan]) => plan.priceId === priceId);
  return entry ? (entry[0] as PlanKey) : "free";
}

function accessStateFor(status: Stripe.Subscription.Status): AccessState {
  if (status === "active" || status === "trialing") return "granted";
  if (status === "past_due") return "grace";
  if (status === "incomplete") return "pending";
  return "revoked";
}
// src/app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
import { sql } from "@/lib/db";
import { stripe } from "@/lib/billing";
import { syncSubscriptionFromStripe } from "@/lib/entitlements";

export async function POST(request: NextRequest) {
  const payload = await request.text();
  const signature = request.headers.get("stripe-signature");
  if (!signature) return NextResponse.json({ error: "Missing signature" }, { status: 400 });

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(payload, signature, process.env.STRIPE_WEBHOOK_SECRET!);
  } catch (error) {
    console.error("stripe.webhook.signature_failed", error);
    return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
  }

  const reserved = await reserveEvent(event);
  if (!reserved) return NextResponse.json({ received: true, duplicate: true });

  try {
    switch (event.type) {
      case "checkout.session.completed": {
        const session = event.data.object as Stripe.Checkout.Session;
        const subscriptionId = expandableId(session.subscription);
        if (subscriptionId) await syncSubscriptionFromStripe(subscriptionId);
        break;
      }
      case "customer.subscription.created":
      case "customer.subscription.updated":
      case "customer.subscription.deleted": {
        const subscription = event.data.object as Stripe.Subscription;
        await syncSubscriptionFromStripe(subscription.id);
        break;
      }
      case "invoice.paid":
      case "invoice.payment_failed": {
        const invoice = event.data.object as Stripe.Invoice;
        const subscriptionId = expandableId(invoice.parent?.subscription_details?.subscription ?? null);
        if (subscriptionId) await syncSubscriptionFromStripe(subscriptionId);
        break;
      }
      default:
        console.info("stripe.webhook.ignored", { id: event.id, type: event.type });
    }

    await markEventProcessed(event.id);
    return NextResponse.json({ received: true });
  } catch (error) {
    await markEventFailed(event.id, error);
    return NextResponse.json({ error: "Webhook handler failed" }, { status: 500 });
  }
}

function expandableId(value: string | { id: string } | null): string | null {
  if (!value) return null;
  return typeof value === "string" ? value : value.id;
}

async function reserveEvent(event: Stripe.Event) {
  const rows = await sql`
    insert into webhook_events (event_id, event_type, status)
    values (${event.id}, ${event.type}, 'processing')
    on conflict (event_id) do update set
      attempts = webhook_events.attempts + 1,
      status = 'processing',
      last_error = null
    where webhook_events.status <> 'processed'
    returning event_id
  `;
  return rows.length > 0;
}

async function markEventProcessed(eventId: string) {
  await sql`update webhook_events set status = 'processed', processed_at = now(), last_error = null where event_id = ${eventId}`;
}

async function markEventFailed(eventId: string, error: unknown) {
  await sql`
    update webhook_events
    set status = 'failed', last_error = ${error instanceof Error ? error.message : String(error)}
    where event_id = ${eventId}
  `;
}

Test locally

Run the listener and copy the whsec_... value into STRIPE_WEBHOOK_SECRET.

stripe listen --events checkout.session.completed,customer.subscription.updated,customer.subscription.deleted,invoice.paid,invoice.payment_failed --forward-to localhost:3000/api/webhooks/stripe

Browser-based Checkout is the most realistic test. Triggered events are useful for smoke tests, but they might not contain your real Price IDs or metadata.

stripe trigger checkout.session.completed
stripe trigger invoice.payment_failed

Pitfalls that usually cost money

Do not unlock paid features from the return URL alone. Customers might not return, payment can require additional action, and webhooks can arrive later. Show “processing” in the UI and rely on webhook/API state.

Do not store userId only on the Checkout Session if later code needs it on the Subscription. Put it in subscription_data.metadata too.

Do not treat past_due, unpaid, and canceled as the same state. A short grace period for past_due may be good customer experience; unpaid and canceled should normally revoke paid entitlements.

Do not forget the Customer Portal configuration in the Stripe Dashboard. The API creates a portal session, but the Dashboard controls which products, plan changes, payment methods, and cancellation options customers can use.

Do not leave tax, currency, refund, and annual discount rules until launch week. Stripe can help with billing automation, but product classification and legal policy are still business decisions.

Monetization path and next step

The highest-leverage part of this build is the state table. Once active, trialing, past_due, unpaid, and canceled are mapped to clear app access, Claude Code prompts, tests, dashboards, and support replies become much easier to align.

For related implementation work, read Stripe payments with Claude Code, authentication with Claude Code, and analytics implementation with Claude Code. If your team wants this adapted to a real SaaS, course site, or product funnel, ClaudeCodeLab can help through Claude Code training and consultation or the product and template library.

Official references to check before implementation: Checkout Sessions API, Customer Portal, subscription webhooks, webhooks, subscription statuses, and Claude Code setup.

#Claude Code #Stripe #subscriptions #SaaS #TypeScript
Free

Free PDF: Claude Code Cheatsheet

Enter your email and download the one-page Claude Code cheatsheet for commands, review habits, and safe workflows.

We handle your data with care and never send spam.

Level up your Claude Code workflow

Start with the free PDF, use Gumroad guides when you need repeatable workflows, and book consultation when rollout or revenue paths need human judgment.

Masa

About the Author

Masa

Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.