Implémenter des abonnements Stripe avec Claude Code
Implémentez Stripe avec Claude Code : Checkout, portail client, webhooks, droits d'accès et monétisation SaaS.
Un abonnement est une mécanique de revenus, pas seulement un bouton
Stripe Checkout permet de lancer rapidement un paiement récurrent. Mais un vrai SaaS, une bibliothèque de modèles, une formation payante ou une offre de conseil récurrente doit gérer bien plus que la redirection vers Stripe : échec de paiement, changement de formule, annulation en fin de période, facture, portail client, webhooks répétés, retrait des droits et traçabilité.
Claude Code est utile si vous lui donnez une base de travail claire. Au lieu de demander vaguement “intègre Stripe”, fournissez les documents officiels, le schéma de base de données, les règles d’accès, les commandes de test et les points de revue. L’agent produit alors du code plus proche d’une vraie mise en production.
La règle de séparation est simple. Stripe reste la source de vérité pour la facturation. Votre base locale conserve la décision d’accès : cet utilisateur peut-il lire le contenu premium, télécharger un modèle, créer des sièges d’équipe ou demander une revue ?
flowchart LR
A["Page de prix"] --> B["Checkout Session<br/>mode=subscription"]
B --> C["Abonnement Stripe"]
C --> D["Webhook signé"]
D --> E["billing_subscriptions"]
D --> F["entitlements"]
F --> G["Contrôle d'accès"]
G --> H["SaaS, formation ou bibliothèque"]
H --> I["Customer Portal"]
I --> C
Concevoir les prix et les droits avant le code
Une bonne intégration commence par une table de plans. Elle évite de disperser la logique commerciale dans des composants, des routes API et des emails de support.
| Plan | Cible | Mensuel exemple | Annuel exemple | Droits |
|---|---|---|---|---|
| Free | lecteurs qui testent | 0 € | 0 € | aperçus et exemples |
| Pro | indépendants, acheteurs de modèles | 19 € | 190 € | articles complets, modèles, bibliothèque de cours |
| Studio | équipes et clients récurrents | 99 € | 990 € | Pro, sièges d’équipe, demandes de revue, supports de formation |
Gardez au moins trois cas d’usage. Premier cas : un micro-SaaS qui débloque exports, automatisations et tableaux de bord. Deuxième cas : un produit d’information où l’article gratuit mène vers un modèle payant puis un abonnement. Troisième cas : une offre de formation ou de conseil avec sièges d’équipe, créneaux de revue et facturation récurrente. Ces cas forcent à traiter les états de paiement au lieu de montrer seulement un exemple Stripe minimal.
Un entitlement est un droit d’accès, par exemple templates:download. Le dunning désigne la récupération après échec de paiement : relances, règles de nouvelle tentative et lien vers la mise à jour du moyen de paiement. L’observabilité signifie que vous pouvez retrouver quel événement Stripe a changé le plan et les droits d’un utilisateur.
Prompt Claude Code avec les documents officiels
Les détails de Stripe évoluent. Les affirmations propres au fournisseur doivent donc venir des documents officiels.
Implémente des abonnements Stripe Billing dans une app Next.js App Router.
Utilise ces documents officiels comme référence :
- 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
Contraintes :
- TypeScript, Next.js App Router, Postgres
- Checkout en mode="subscription"
- Customer Portal pour moyens de paiement, factures, changement de plan et annulation
- Webhook avec vérification de signature et déduplication par event_id
- Table locale entitlements pour décider l'accès applicatif
- active/trialing accordent l'accès, past_due a 3 jours de grâce, unpaid/canceled/paused révoquent
- Inclure du code copiable et les commandes stripe listen
Schéma Postgres minimal
billing_subscriptions synchronise l’état Stripe. entitlements est la table consultée par l’application. webhook_events évite de traiter deux fois le même événement.
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 et portail client
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 et synchronisation des droits
L’URL de succès ne prouve pas le paiement. Elle sert à l’expérience utilisateur. Les droits doivent être ouverts à partir d’un webhook signé ou d’un état Stripe relu via l’API. Comme les événements peuvent arriver dans un ordre différent, le code récupère l’abonnement avant d’écrire en base.
// 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 });
}
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 {
return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
}
const rows = await sql`
insert into webhook_events (event_id, event_type, status)
values (${event.id}, ${event.type}, 'processing')
on conflict (event_id) do nothing
returning event_id
`;
if (rows.length === 0) return NextResponse.json({ received: true, duplicate: true });
if (event.type === "checkout.session.completed") {
const session = event.data.object as Stripe.Checkout.Session;
const subscriptionId = typeof session.subscription === "string" ? session.subscription : session.subscription?.id;
if (subscriptionId) await syncSubscriptionFromStripe(subscriptionId);
}
if (event.type === "customer.subscription.created" || event.type === "customer.subscription.updated" || event.type === "customer.subscription.deleted") {
const subscription = event.data.object as Stripe.Subscription;
await syncSubscriptionFromStripe(subscription.id);
}
if (event.type === "invoice.paid" || event.type === "invoice.payment_failed") {
const invoice = event.data.object as Stripe.Invoice;
const value = invoice.parent?.subscription_details?.subscription as string | { id: string } | null | undefined;
const subscriptionId = typeof value === "string" ? value : value?.id;
if (subscriptionId) await syncSubscriptionFromStripe(subscriptionId);
}
await sql`update webhook_events set status = 'processed', processed_at = now() where event_id = ${event.id}`;
return NextResponse.json({ received: true });
}
Tests locaux
Copiez la valeur whsec_... affichée par stripe listen dans 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
stripe trigger checkout.session.completed
stripe trigger invoice.payment_failed
Les événements déclenchés par CLI sont utiles pour un test rapide. La vraie validation consiste à créer Product et Price en mode test, passer par Checkout dans le navigateur, puis vérifier le portail client, le changement de plan et l’annulation.
Pièges fréquents
Ne débloquez pas l’accès payant uniquement parce que l’utilisateur revient sur l’URL de succès. Le paiement peut demander une authentification supplémentaire ou le webhook peut arriver plus tard.
Ne mettez pas userId seulement dans le metadata de Checkout. Pour synchroniser depuis la Subscription, ajoutez aussi subscription_data.metadata.
Ne traitez pas past_due, unpaid et canceled comme un seul état. past_due peut avoir une courte période de grâce ; unpaid et canceled révoquent généralement les droits payants.
Ne négligez pas la configuration du Customer Portal dans le Dashboard Stripe. L’API crée la session, mais le Dashboard décide les changements de plan, moyens de paiement et options d’annulation disponibles.
CTA et suite
Lors des essais de ClaudeCodeLab sur les contenus et modèles payants, le gain le plus concret est venu de la table d’états, pas du volume de code généré. Une fois les états fixés, les prompts Claude Code, les tests, les événements d’analyse et les messages support restent alignés.
Pour aller plus loin, lisez paiements Stripe avec Claude Code, authentification avec Claude Code et analytics avec Claude Code. Pour adapter Stripe Billing, les droits d’accès et la revue Claude Code à votre SaaS, formation ou funnel produit, consultez formation et conseil Claude Code ou la bibliothèque de produits et modèles.
Vérifiez toujours les sources officielles avant de livrer : Checkout Sessions API, Customer Portal, subscription webhooks, webhooks, subscription statuses et Claude Code setup.
PDF gratuit: cheatsheet Claude Code
Saisissez votre email et téléchargez une page avec commandes, habitudes de review et workflow sûr.
Nous protégeons vos données et n'envoyons pas de spam.
À propos de l'auteur
Masa
Ingénieur spécialisé dans les workflows pratiques avec Claude Code.
Articles liés
Workflow Obsidian vers CLAUDE.md avec Claude Code
Transformer des notes Obsidian en notes CLAUDE.md concises pour reprendre les sessions sans réexpliquer.
Claude Code Revenue CTA Routing : relier articles, PDF, Gumroad et consultation
Un workflow Claude Code pour orienter les lecteurs vers PDF gratuit, Gumroad ou consultation selon l'intention.
Règles de handoff Claude Code en équipe: preuves, permissions, rollback et revenus
Un format concret pour transmettre un travail Claude Code avec preuves, permissions, rollback, PDF gratuit, Gumroad et consultation.