Build Stripe Subscriptions with Claude Code
Implement Stripe subscriptions with Claude Code: Checkout, Portal, webhooks, entitlements, and SaaS monetization.
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.
| Plan | Reader or buyer | Monthly example | Annual example | Entitlements |
|---|---|---|---|---|
| Free | Visitors testing the content | $0 | $0 | previews and sample downloads |
| Pro | Solo builders and info-product buyers | $19 | $190 | full articles, templates, course library |
| Studio | teams and recurring clients | $99 | $990 | Pro 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.
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.
About the Author
Masa
Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.
Related Posts
Claude Code Obsidian to CLAUDE.md Workflow: Stop Re-explaining Context
Turn Obsidian working notes into concise CLAUDE.md operating notes that make Claude Code sessions easier to resume.
Claude Code Revenue CTA Routing: Send Articles to PDF, Gumroad, and Consultation
A Claude Code workflow for routing article readers to the free PDF, Gumroad products, or consultation by intent.
Claude Code Team Handoff Rules: Review Evidence, Permissions, Rollback, and Revenue Paths
A practical Claude Code handoff format for team review, proof, permission rules, rollback, free PDF, Gumroad, and consultation paths.
Related Products
50 Battle-Tested Claude Code Prompt Templates
Copy, paste, ship. 50 production-ready prompts.
Use proven prompts for code review, refactoring, testing, documentation, debugging, architecture, and incident response.
The Complete Claude Code Setup & Configuration Guide
From install to team-ready workflow.
A practical guide to installation, CLAUDE.md, hooks, MCP servers, permissions, IDE setup, and CI/CD workflows.