Use Cases (Mis à jour: 01/06/2026)

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.

Implémenter Stripe Checkout avec Claude Code : Sessions, webhooks et idempotence

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’usageCheckout modeFulfillmentRisque
PDF ou templatepaymentDonner l’accès au téléchargementNe pas livrer uniquement via la page de succès
Plan SaaS ProsubscriptionActiver quotas et siègesGérer la suppression de l’abonnement
Acompte de formationpaymentRéserver un créneauDé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.

#Claude Code #Stripe #paiements #Checkout #TypeScript
Gratuit

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.

Masa

À propos de l'auteur

Masa

Ingénieur spécialisé dans les workflows pratiques avec Claude Code.