Implémenter Stripe Checkout avec Claude Code : Sessions, webhooks et idempotence
Implémentez Stripe Checkout avec Claude Code : hosted checkout, webhooks, metadata, idempotence, mode test et reprise.
Stripe Checkout évite de développer soi-même le formulaire de carte bancaire, mais il ne règle pas toute la logique métier. Une intégration sérieuse doit couvrir la Checkout Sessions API, le hosted checkout, le webhook fulfillment, metadata.order_id, l’idempotence, le mode test, les remboursements et les annulations.
Claude Code est utile si vous lui donnez des tâches petites et vérifiables. Demandez d’abord le schéma de commande, puis la validation des variables d’environnement, puis la route Checkout, puis le webhook avec vérification de signature, puis une revue critique. C’est beaucoup plus sûr qu’une demande vague du type “ajoute Stripe”.
L’exemple ci-dessous utilise Next.js App Router et Node TypeScript. Les clés secrètes restent dans l’environnement, la commande interne est créée avant la Checkout Session, et la livraison du produit passe par une fonction serveur idempotente.
Ce que dit la documentation Stripe
La Checkout Sessions API est la base de hosted Checkout, embedded Checkout et des flux personnalisés. Pour la plupart des produits numériques, SaaS et formations, hosted checkout est le point de départ le plus prudent parce que Stripe gère l’interface de paiement, l’authentification, la validation et les messages d’erreur.
Le guide How Checkout works décrit le cycle : votre application crée une Checkout Session, le client paie sur la page hébergée par Stripe, puis un événement webhook comme checkout.session.completed lance le fulfillment. Le fulfillment correspond à l’action métier après paiement : donner un accès, activer un abonnement, réserver une place, préparer une livraison.
La page Fulfill orders insiste sur un point essentiel : les webhooks sont obligatoires et la fonction de fulfillment doit supporter plusieurs appels pour la même Session. La référence Checkout Sessions précise aussi qu’une Session représente une tentative de paiement et référence ensuite Customer, PaymentIntent ou Subscription.
Pour compléter l’architecture, consultez aussi l’intégration de paiement avec Claude Code, les bonnes pratiques de sécurité et la gestion des variables d’environnement.
Architecture cible
flowchart LR
Buyer["Acheteur"] --> App["Application Next.js"]
App --> CheckoutRoute["/api/checkout"]
CheckoutRoute --> Orders["Table Order"]
CheckoutRoute --> Stripe["Stripe Checkout Session"]
Stripe --> Hosted["Hosted Checkout"]
Hosted --> Webhook["/api/stripe/webhook"]
Webhook --> Fulfill["fulfillCheckout"]
Hosted --> Success["/checkout/success"]
Success --> Fulfill
Fulfill --> Orders
La route Checkout crée ou réutilise une commande interne, crée la Session Stripe et renvoie session.url. Le webhook vérifie la signature avec le raw body et appelle la même fonction de fulfillment que la page de succès. La page de succès améliore l’expérience utilisateur, mais elle ne remplace jamais le webhook.
Environnement et base de données
npm install stripe zod @prisma/client
npm install --save-dev prisma
npx prisma init
En local, utilisez uniquement le mode test. Ne collez pas de vraies valeurs sk_test_, sk_live_ ou whsec_ dans un prompt Claude Code.
APP_URL=http://localhost:3000
DATABASE_URL=postgresql://user:password@localhost:5432/checkout_dev
STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxxxxxx
STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxx
STRIPE_PRICE_EBOOK=price_xxxxxxxxxxxxxxxxxxxxx
STRIPE_PRICE_PRO=price_xxxxxxxxxxxxxxxxxxxxx
// prisma/schema.prisma
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
model Order {
id String @id @default(cuid())
cartId String @unique
userId String
email String?
productKey String
quantity Int @default(1)
status OrderStatus @default(PENDING)
stripeCheckoutSessionId String? @unique
stripePaymentIntentId String? @unique
stripeSubscriptionId String? @unique
amountTotal Int?
currency String?
fulfilledAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
logs FulfillmentLog[]
}
model FulfillmentLog {
id String @id @default(cuid())
orderId String
stripeCheckoutSessionId String
event String
createdAt DateTime @default(now())
order Order @relation(fields: [orderId], references: [id])
@@unique([orderId, event])
@@index([stripeCheckoutSessionId])
}
enum OrderStatus {
PENDING
PAID
FULFILLED
CANCELED
REFUNDED
FAILED
}
Utilitaires serveur
// src/lib/env.ts
import { z } from "zod";
const envSchema = z.object({
APP_URL: z.string().url(),
DATABASE_URL: z.string().min(1),
STRIPE_SECRET_KEY: z.string().regex(/^sk_(test|live)_/),
STRIPE_WEBHOOK_SECRET: z.string().regex(/^whsec_/),
STRIPE_PRICE_EBOOK: z.string().startsWith("price_"),
STRIPE_PRICE_PRO: z.string().startsWith("price_"),
});
export const env = envSchema.parse(process.env);
// src/lib/prisma.ts
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}
// src/lib/stripe.ts
import Stripe from "stripe";
import { env } from "@/lib/env";
export const stripe = new Stripe(env.STRIPE_SECRET_KEY);
Créer la Checkout Session
// app/api/checkout/route.ts
import Stripe from "stripe";
import { NextResponse } from "next/server";
import { z } from "zod";
import { env } from "@/lib/env";
import { prisma } from "@/lib/prisma";
import { stripe } from "@/lib/stripe";
const checkoutRequestSchema = z.object({
cartId: z.string().min(8),
userId: z.string().min(1),
email: z.string().email().optional(),
productKey: z.enum(["ebook", "pro"]),
});
const priceByProduct = {
ebook: env.STRIPE_PRICE_EBOOK,
pro: env.STRIPE_PRICE_PRO,
} as const;
export async function POST(request: Request) {
const body = await request.json().catch(() => null);
const parsed = checkoutRequestSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Invalid checkout request" }, { status: 400 });
}
const { cartId, userId, email, productKey } = parsed.data;
const mode: Stripe.Checkout.SessionCreateParams.Mode =
productKey === "pro" ? "subscription" : "payment";
const order = await prisma.order.upsert({
where: { cartId },
update: {},
create: { cartId, userId, email, productKey, quantity: 1, status: "PENDING" },
});
if (order.stripeCheckoutSessionId) {
const existing = await stripe.checkout.sessions.retrieve(order.stripeCheckoutSessionId);
if (existing.status === "open" && existing.url) {
return NextResponse.json({ url: existing.url, orderId: order.id });
}
return NextResponse.json(
{ error: "Checkout session is not open. Create a new cart." },
{ status: 409 },
);
}
const metadata = {
order_id: order.id,
cart_id: cartId,
product_key: productKey,
user_id: userId,
};
const session = await stripe.checkout.sessions.create(
{
mode,
line_items: [{ price: priceByProduct[productKey], quantity: 1 }],
customer_email: email,
success_url: `${env.APP_URL}/checkout/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${env.APP_URL}/pricing?checkout=cancelled&order_id=${order.id}`,
client_reference_id: order.id,
metadata,
allow_promotion_codes: true,
payment_intent_data: mode === "payment" ? { metadata } : undefined,
subscription_data: mode === "subscription" ? { metadata } : undefined,
},
{ idempotencyKey: `checkout:${order.id}:v1` },
);
await prisma.order.update({
where: { id: order.id },
data: { stripeCheckoutSessionId: session.id },
});
return NextResponse.json({ url: session.url, orderId: order.id });
}
La metadata ne contient que des identifiants internes. Évitez les emails, adresses et informations de carte. idempotencyKey protège les retries de création de Session, mais pas les doubles exécutions du webhook.
Fulfillment idempotent et webhook
// src/lib/checkout-fulfillment.ts
import Stripe from "stripe";
import { prisma } from "@/lib/prisma";
import { stripe } from "@/lib/stripe";
function stripeObjectId(
value: string | Stripe.PaymentIntent | Stripe.Subscription | null,
) {
if (!value) return null;
return typeof value === "string" ? value : value.id;
}
export async function fulfillCheckout(sessionId: string) {
const session = await stripe.checkout.sessions.retrieve(sessionId, {
expand: ["line_items", "payment_intent", "subscription"],
});
if (session.payment_status === "unpaid") {
return { status: "waiting_for_payment" as const, sessionId };
}
const orderId = session.metadata?.order_id ?? session.client_reference_id;
if (!orderId) throw new Error(`Checkout Session ${session.id} has no order_id`);
return prisma.$transaction(async (tx) => {
const updated = await tx.order.updateMany({
where: { id: orderId, fulfilledAt: null },
data: {
status: "FULFILLED",
stripeCheckoutSessionId: session.id,
stripePaymentIntentId: stripeObjectId(session.payment_intent),
stripeSubscriptionId: stripeObjectId(session.subscription),
amountTotal: session.amount_total,
currency: session.currency,
fulfilledAt: new Date(),
},
});
if (updated.count === 0) return { status: "already_fulfilled" as const, orderId };
await tx.fulfillmentLog.create({
data: { orderId, stripeCheckoutSessionId: session.id, event: "checkout.fulfilled" },
});
return { status: "fulfilled" as const, orderId };
});
}
// app/api/stripe/webhook/route.ts
import Stripe from "stripe";
import { NextResponse } from "next/server";
import { env } from "@/lib/env";
import { prisma } from "@/lib/prisma";
import { stripe } from "@/lib/stripe";
import { fulfillCheckout } from "@/lib/checkout-fulfillment";
export const runtime = "nodejs";
function idFromStripeObject(value: string | { id: string } | null | undefined) {
if (!value) return null;
return typeof value === "string" ? value : value.id;
}
async function markSession(sessionId: string, status: "CANCELED" | "FAILED") {
await prisma.order.updateMany({
where: { stripeCheckoutSessionId: sessionId, fulfilledAt: null },
data: { status },
});
}
export async function POST(request: Request) {
const signature = request.headers.get("stripe-signature");
if (!signature) return NextResponse.json({ error: "Missing signature" }, { status: 400 });
const rawBody = await request.text();
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(rawBody, signature, env.STRIPE_WEBHOOK_SECRET);
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
return NextResponse.json(
{ error: `Webhook signature verification failed: ${message}` },
{ status: 400 },
);
}
switch (event.type) {
case "checkout.session.completed":
case "checkout.session.async_payment_succeeded":
await fulfillCheckout((event.data.object as Stripe.Checkout.Session).id);
break;
case "checkout.session.async_payment_failed":
await markSession((event.data.object as Stripe.Checkout.Session).id, "FAILED");
break;
case "checkout.session.expired":
await markSession((event.data.object as Stripe.Checkout.Session).id, "CANCELED");
break;
case "charge.refunded": {
const charge = event.data.object as Stripe.Charge;
const paymentIntentId = idFromStripeObject(charge.payment_intent);
if (paymentIntentId) {
await prisma.order.updateMany({
where: { stripePaymentIntentId: paymentIntentId },
data: { status: "REFUNDED" },
});
}
break;
}
case "customer.subscription.deleted": {
const subscription = event.data.object as Stripe.Subscription;
await prisma.order.updateMany({
where: { stripeSubscriptionId: subscription.id },
data: { status: "CANCELED" },
});
break;
}
}
return NextResponse.json({ received: true });
}
Le point critique est le raw body. Si Claude Code remplace cela par une lecture JSON avant la vérification, la signature Stripe ne protège plus correctement l’entrée.
Tests, cas réels et erreurs fréquentes
stripe login
stripe listen --forward-to localhost:3000/api/stripe/webhook
Utilisez le whsec_ affiché par Stripe CLI, redémarrez Next.js, puis payez avec la carte de test 4242 4242 4242 4242. Vérifiez ensuite la base : Order.status doit être FULFILLED, les identifiants Stripe doivent être présents, et un rechargement de la page de succès ne doit pas créer un deuxième log.
| Cas d’usage | Checkout mode | Fulfillment | Risque |
|---|---|---|---|
| PDF ou template | payment | Donner l’accès au téléchargement | Ne pas livrer uniquement via la page de succès |
| Plan SaaS Pro | subscription | Activer quotas et sièges | Gérer la suppression de l’abonnement |
| Acompte de formation | payment | Réserver un créneau | Définir l’effet d’un remboursement |
Les erreurs concrètes à chercher sont toujours les mêmes : accès donné seulement sur success_url, absence de metadata.order_id, confiance excessive dans idempotencyKey, mélange de clés test et live, remboursement dans Stripe sans mise à jour des droits dans l’application.
Demandez à Claude Code une revue étroite.
Review only the Stripe Checkout implementation.
Check env-only secrets, metadata order_id without PII, raw-body webhook signature verification,
idempotent fulfillment, test-mode setup, refund and cancellation handling.
Return concrete file and line references.
Points de vérification avant essai réel
Avant publication, faites un paiement en mode test, observez le webhook, inspectez la table de commandes, rechargez la page de succès, expirez une Session, remboursez un paiement et annulez un abonnement de test. Si la même Checkout Session peut être traitée plusieurs fois sans double accès, vous avez une base solide pour une revue de production.
ClaudeCodeLab peut relire votre intégration Stripe Checkout, former votre équipe aux workflows Claude Code pour les paiements, ou vous accompagner sur l’implémentation avec vos règles de commandes, remboursements et abonnements.
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.