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

Intégrer Stripe avec Claude Code : Checkout, Webhooks, remboursements et fiscalité

Implémentez Stripe avec Claude Code : tarifs, Checkout, Webhooks, idempotence, fulfillment, remboursements et fiscalité.

Intégrer Stripe avec Claude Code : Checkout, Webhooks, remboursements et fiscalité

Pour un petit SaaS, une offre de templates ou un produit de contenu, intégrer le paiement ne se résume pas à ajouter un bouton. Il faut une table de produits, une API de Checkout Session, une vérification de Webhook, un fulfillment idempotent, une logique de remboursement et une transmission propre des données fiscales. Sinon, Stripe peut afficher un paiement réussi tandis que votre application ne donne aucun accès, ou un remboursement peut être effectué sans retirer le droit payant.

Claude Code peut accélérer ce travail, à condition de lui donner des frontières strictes. Une demande vague comme “ajoute Stripe” produit souvent une belle carte tarifaire et une logique trop fragile dans la page de succès. Pour le code lié au revenu, demandez des lots vérifiables : prix décidés côté serveur, route Checkout, signature Webhook vérifiée sur le raw body, déduplication d’événements, fulfillment, remboursements et journaux sans données sensibles.

Ce guide utilise Next.js App Router, Node.js, TypeScript et Stripe Checkout. Un Webhook est une notification serveur à serveur envoyée par Stripe. Le fulfillment est l’action qui remet au client ce qu’il a payé : téléchargement, plan SaaS, place, réservation ou accès à une formation. L’idempotence signifie que la même opération peut être rejouée sans dupliquer le résultat.

Sources officielles à consulter

Pour les détails propres au fournisseur, utilisez la documentation officielle. La création d’une session est couverte par Checkout Session create API, la livraison après paiement par Checkout fulfillment, les Webhooks et signatures par Receive Stripe events, et les retries par Idempotent requests.

Les remboursements sont dans Create a refund, la collecte fiscale dans Stripe Tax for Checkout, et les tests locaux dans Stripe CLI. Côté framework, utilisez Next.js Route Handlers. Pour l’outil, consultez Claude Code overview.

Si vous remplacez Stripe par Paddle, Lemon Squeezy, PayPal ou un prestataire local, les règles restent valables : le serveur choisit le prix, le paiement est confirmé par Webhook, le fulfillment doit être idempotent, et le remboursement doit rester cohérent avec les accès applicatifs.

Cas de monétisation

CasModèleFulfillmentRisque concret
Pack de prompts, PDF, templatesPaiement uniqueDonner l’accès au téléchargement et envoyer un emailSi la page de succès est le seul canal, un client qui ferme son navigateur peut perdre l’accès
Petit SaaS en plan ProAbonnement mensuelActiver plan:pro et suivre les événements de facturationIgnorer les annulations ou échecs de paiement laisse des fonctionnalités ouvertes
Acompte pour atelier Claude CodePaiement unique ou factureRéserver une place et notifier l’équipeLe remboursement ne dit pas automatiquement si la place doit être libérée

Dans un prototype de produit de contenu, Masa a d’abord traité la page de succès comme source de vérité. Cela passait avec une carte de test, mais l’analyse échouait dès qu’on ajoutait retries de Webhook, fermeture du navigateur, paiement différé et remboursement. La meilleure consigne pour Claude Code a été : “la page de succès affiche seulement l’état ; l’accès est accordé par une fonction fulfillment commune appelée depuis le Webhook.”

Architecture

flowchart LR
  Buyer["Acheteur"] --> Pricing["Table de prix"]
  Pricing --> CheckoutApi["/api/checkout"]
  CheckoutApi --> Order["Table Order"]
  CheckoutApi --> Stripe["Stripe Checkout Session"]
  Stripe --> Hosted["Stripe hosted checkout"]
  Hosted --> Webhook["/api/webhooks/stripe"]
  Webhook --> Fulfill["fulfillCheckoutSession"]
  Fulfill --> Entitlement["Table Entitlement"]
  Hosted --> Success["Page de succès"]
  Success --> ReadOnly["Lire l'état de commande"]

La page de succès n’est pas l’autorité qui donne l’accès. Elle affiche un état. Le Webhook vérifie la signature Stripe, enregistre l’ID d’événement, puis appelle une seule fonction de fulfillment capable de traiter deux fois la même session sans créer deux accès.

Prompt conseillé pour Claude Code :

Implement Stripe Checkout in a Next.js App Router app.
Do not grant access from the success page alone.
Verify the Stripe webhook with the raw request body.
Make fulfillment safe if the same Checkout Session is processed twice.
Do not print secrets or read .env values into chat.
Before editing, explain the target files and design.

Table de produits et variables d’environnement

Le navigateur n’envoie que productKey. Les Price ID, le mode de paiement et la clé d’accès restent côté serveur.

// src/lib/billing/catalog.ts
export const BILLING_PRODUCTS = {
  promptPack: {
    label: "Claude Code Prompt Pack",
    mode: "payment",
    entitlement: "download:prompt-pack",
    priceEnv: "STRIPE_PRICE_PROMPT_PACK",
  },
  proMonthly: {
    label: "SaaS Pro Monthly",
    mode: "subscription",
    entitlement: "plan:pro",
    priceEnv: "STRIPE_PRICE_PRO_MONTHLY",
  },
  workshopDeposit: {
    label: "Claude Code Workshop Deposit",
    mode: "payment",
    entitlement: "booking:workshop-deposit",
    priceEnv: "STRIPE_PRICE_WORKSHOP_DEPOSIT",
  },
} as const;

export type ProductKey = keyof typeof BILLING_PRODUCTS;

export function getBillingProduct(productKey: string) {
  if (productKey in BILLING_PRODUCTS) {
    return {
      key: productKey as ProductKey,
      ...BILLING_PRODUCTS[productKey as ProductKey],
    };
  }

  throw new Error(`Unknown productKey: ${productKey}`);
}

export function getPriceId(priceEnv: string) {
  const priceId = process.env[priceEnv];
  if (!priceId) throw new Error(`Missing environment variable: ${priceEnv}`);
  return priceId;
}

Commencez en mode test. Ne collez pas de clés sk_test_, sk_live_ ou whsec_ dans une conversation.

APP_URL=http://localhost:3000
STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxx
STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxx
STRIPE_PRICE_PROMPT_PACK=price_xxxxxxxxxxxxxxxxx
STRIPE_PRICE_PRO_MONTHLY=price_xxxxxxxxxxxxxxxxx
STRIPE_PRICE_WORKSHOP_DEPOSIT=price_xxxxxxxxxxxxxxxxx

Modèle de données pour l’idempotence

L’état de paiement en production doit être stocké en base. StripeEvent.id évite de traiter deux fois le même événement, et Entitlement représente l’accès réellement consulté par l’application.

// prisma/schema.prisma
model Order {
  id                      String    @id @default(cuid())
  userId                  String
  productKey              String
  entitlement             String
  status                  String    @default("PENDING")
  stripeCheckoutSessionId String?   @unique
  stripePaymentIntentId   String?
  stripeSubscriptionId    String?
  stripeRefundId          String?
  fulfilledAt             DateTime?
  createdAt               DateTime  @default(now())
  updatedAt               DateTime  @updatedAt
}

model Entitlement {
  id        String   @id @default(cuid())
  userId    String
  key       String
  active    Boolean  @default(true)
  source    String
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  @@unique([userId, key])
}

model StripeEvent {
  id        String   @id
  type      String
  createdAt DateTime @default(now())
}
npx prisma db push

API Checkout

Le SDK Stripe et la vérification Webhook doivent tourner en runtime Node.js. La route authentifie, crée une commande interne, crée une Checkout Session, stocke son ID puis renvoie l’URL.

// src/lib/billing/stripe.ts
import Stripe from "stripe";

if (!process.env.STRIPE_SECRET_KEY) {
  throw new Error("STRIPE_SECRET_KEY is required");
}

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

export function appUrl(path: string) {
  const baseUrl = process.env.APP_URL ?? "http://localhost:3000";
  return `${baseUrl.replace(/\/$/, "")}${path}`;
}
// src/app/api/checkout/route.ts
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/db";
import { getBillingProduct, getPriceId } from "@/lib/billing/catalog";
import { appUrl, stripe } from "@/lib/billing/stripe";
import { requireUser } from "@/lib/auth";

export const runtime = "nodejs";

const checkoutSchema = z.object({
  productKey: z.string().min(1),
});

export async function POST(req: NextRequest) {
  const user = await requireUser();
  const { productKey } = checkoutSchema.parse(await req.json());
  const product = getBillingProduct(productKey);

  const order = await prisma.order.create({
    data: {
      userId: user.id,
      productKey: product.key,
      entitlement: product.entitlement,
    },
  });

  const session = await stripe.checkout.sessions.create(
    {
      mode: product.mode,
      customer_email: user.email,
      line_items: [{ price: getPriceId(product.priceEnv), quantity: 1 }],
      client_reference_id: order.id,
      metadata: {
        order_id: order.id,
        product_key: product.key,
      },
      automatic_tax: { enabled: true },
      tax_id_collection: { enabled: true },
      success_url: appUrl("/checkout/success?session_id={CHECKOUT_SESSION_ID}"),
      cancel_url: appUrl("/pricing"),
    },
    {
      idempotencyKey: `checkout:${order.id}`,
    }
  );

  await prisma.order.update({
    where: { id: order.id },
    data: { stripeCheckoutSessionId: session.id },
  });

  if (!session.url) {
    return NextResponse.json({ error: "Checkout URL was not created" }, { status: 500 });
  }

  return NextResponse.json({ url: session.url });
}

automatic_tax et tax_id_collection facilitent la collecte de données fiscales, mais ne remplacent pas une validation comptable. Les obligations de déclaration, factures et remboursements dépendent du contexte.

Composant de tarifs

L’interface n’envoie jamais de montant ni de Price ID. Le prix affiché est informatif ; le prix réel vient de Stripe.

// src/components/PricingTable.tsx
"use client";

import { useState } from "react";

const cards = [
  {
    productKey: "promptPack",
    name: "Prompt Pack",
    price: "39 €",
    description: "Paiement unique pour templates et supports pratiques",
  },
  {
    productKey: "proMonthly",
    name: "Pro",
    price: "29 €/mois",
    description: "Plan mensuel pour un petit SaaS",
  },
  {
    productKey: "workshopDeposit",
    name: "Workshop Deposit",
    price: "100 €",
    description: "Acompte pour formation ou conseil",
  },
];

export function PricingTable() {
  const [loadingKey, setLoadingKey] = useState<string | null>(null);

  async function startCheckout(productKey: string) {
    setLoadingKey(productKey);
    const res = await fetch("/api/checkout", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ productKey }),
    });

    const data = await res.json();
    if (!res.ok) {
      setLoadingKey(null);
      alert(data.error ?? "Checkout failed");
      return;
    }

    window.location.assign(data.url);
  }

  return (
    <div className="grid gap-4 md:grid-cols-3">
      {cards.map((card) => (
        <section key={card.productKey} className="rounded-lg border p-5">
          <h3 className="text-lg font-semibold">{card.name}</h3>
          <p className="mt-2 text-2xl font-bold">{card.price}</p>
          <p className="mt-2 text-sm text-gray-600">{card.description}</p>
          <button
            className="mt-4 rounded bg-black px-4 py-2 text-white disabled:opacity-60"
            disabled={loadingKey === card.productKey}
            onClick={() => startCheckout(card.productKey)}
          >
            {loadingKey === card.productKey ? "Redirection..." : "Continuer vers Checkout"}
          </button>
        </section>
      ))}
    </div>
  );
}

Le bouton désactivé limite les doubles clics, mais la vraie sécurité vient du serveur : table de produits, idempotency key, événement Webhook unique et contraintes de base de données.

Vérification Webhook et fulfillment

N’appelez pas await req.json() avant la vérification. Stripe doit recevoir le raw body non modifié.

// src/app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
import { prisma } from "@/lib/db";
import { stripe } from "@/lib/billing/stripe";
import { fulfillCheckoutSession } from "@/lib/billing/fulfillment";

export const runtime = "nodejs";

export async function POST(req: NextRequest) {
  const payload = await req.text();
  const signature = req.headers.get("stripe-signature");

  if (!signature || !process.env.STRIPE_WEBHOOK_SECRET) {
    return NextResponse.json({ error: "Missing webhook 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 });
  }

  try {
    await prisma.stripeEvent.create({ data: { id: event.id, type: event.type } });
  } catch {
    return NextResponse.json({ received: true, duplicate: true });
  }

  if (
    event.type === "checkout.session.completed" ||
    event.type === "checkout.session.async_payment_succeeded"
  ) {
    const session = event.data.object as Stripe.Checkout.Session;
    await fulfillCheckoutSession(session.id);
  }

  if (event.type === "checkout.session.async_payment_failed") {
    const session = event.data.object as Stripe.Checkout.Session;
    await prisma.order.updateMany({
      where: { stripeCheckoutSessionId: session.id, status: "PENDING" },
      data: { status: "CANCELED" },
    });
  }

  return NextResponse.json({ received: true });
}
// src/lib/billing/fulfillment.ts
import { prisma } from "@/lib/db";
import { stripe } from "@/lib/billing/stripe";

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

export async function fulfillCheckoutSession(sessionId: string) {
  const session = await stripe.checkout.sessions.retrieve(sessionId, {
    expand: ["payment_intent", "subscription"],
  });

  if (session.payment_status !== "paid") {
    return { status: "waiting_for_payment" as const };
  }

  const orderId = session.metadata?.order_id;
  if (!orderId) throw new Error(`Missing order_id metadata for ${session.id}`);

  return prisma.$transaction(async (tx) => {
    const order = await tx.order.findUnique({ where: { id: orderId } });
    if (!order) throw new Error(`Order not found: ${orderId}`);

    if (order.status === "FULFILLED") {
      return { status: "already_fulfilled" as const, order };
    }

    const updatedOrder = await tx.order.update({
      where: { id: order.id },
      data: {
        status: "FULFILLED",
        stripeCheckoutSessionId: session.id,
        stripePaymentIntentId: stripeObjectId(session.payment_intent),
        stripeSubscriptionId: stripeObjectId(session.subscription),
        fulfilledAt: new Date(),
      },
    });

    await tx.entitlement.upsert({
      where: {
        userId_key: {
          userId: order.userId,
          key: order.entitlement,
        },
      },
      update: { active: true, source: `stripe:${session.id}` },
      create: {
        userId: order.userId,
        key: order.entitlement,
        active: true,
        source: `stripe:${session.id}`,
      },
    });

    return { status: "fulfilled" as const, order: updatedOrder };
  });
}

Le point clé est de n’avoir qu’un endroit où l’accès payant est accordé. La même fonction peut être appelée par un Webhook, une tâche de réparation ou une page de statut.

Remboursements et accès

Un remboursement n’est pas seulement un appel Stripe. Votre produit doit décider si l’accès est révoqué, conservé en cas de remboursement partiel, ou si une réservation redevient disponible.

// src/app/api/admin/refunds/route.ts
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/db";
import { stripe } from "@/lib/billing/stripe";
import { requireAdmin } from "@/lib/auth";

export const runtime = "nodejs";

const refundSchema = z.object({
  orderId: z.string().min(1),
  amount: z.number().int().positive().optional(),
});

export async function POST(req: NextRequest) {
  await requireAdmin();
  const { orderId, amount } = refundSchema.parse(await req.json());

  const order = await prisma.order.findUnique({ where: { id: orderId } });
  if (!order?.stripePaymentIntentId) {
    return NextResponse.json({ error: "Refundable payment not found" }, { status: 404 });
  }

  const refund = await stripe.refunds.create(
    {
      payment_intent: order.stripePaymentIntentId,
      amount,
      metadata: { order_id: order.id },
    },
    {
      idempotencyKey: `refund:${order.id}:${amount ?? "full"}`,
    }
  );

  await prisma.$transaction([
    prisma.order.update({
      where: { id: order.id },
      data: { status: "REFUNDED", stripeRefundId: refund.id },
    }),
    prisma.entitlement.updateMany({
      where: { userId: order.userId, key: order.entitlement },
      data: { active: false },
    }),
  ]);

  return NextResponse.json({ refundId: refund.id, status: refund.status });
}

Demandez à Claude Code de poser une question si la règle de remboursement est floue. “Le remboursement partiel conserve l’accès” ou “le remboursement total révoque l’accès” relève du produit.

Tests locaux

Utilisez Stripe CLI pour transférer les événements vers votre route locale.

npm run dev
stripe login
stripe listen --forward-to localhost:3000/api/webhooks/stripe

Copiez le whsec_... affiché dans STRIPE_WEBHOOK_SECRET, redémarrez Next.js, puis testez Checkout. La carte de test 4242 4242 4242 4242, une date future, n’importe quel CVC et un code postal quelconque couvrent le succès de base. Pour refus et authentification, consultez Testing.

Vérifiez quatre choses : la route crée une commande interne, le Webhook reçoit checkout.session.completed, la commande devient FULFILLED avec un seul Entitlement, et rejouer le même événement ne crée pas un second accès.

Prompt de revue Claude Code

Après l’implémentation, passez Claude Code en mode revue.

Review this payment diff critically.
Check these risks:
- Access is not granted from the success page alone.
- The webhook verifies the raw body before JSON parsing.
- Stripe Event ID and Checkout Session ID prevent duplicate processing.
- The browser cannot choose price IDs or amounts.
- Metadata does not contain email, address, card data, or other sensitive data.
- Refunds keep Order and Entitlement state consistent.
- Tax, receipts, and cancellation policy are handed off to operations.

Claude Code peut écrire le code, mais la responsabilité finale reste humaine : clés API, Dashboard, fiscalité, CGV, politique de remboursement et confidentialité des logs.

Conclusion et CTA

Concevez l’intégration paiement à partir du cycle de revenu, pas du bouton. La table de prix envoie productKey, le serveur crée Checkout, le Webhook vérifie la signature, et une fonction fulfillment idempotente accorde l’accès. Remboursement et fiscalité doivent être pensés dès le début.

Pour un tutoriel Checkout plus ciblé, lisez Stripe Checkout avec Claude Code. Pour les secrets, consultez gestion des variables d’environnement, et pour les événements, implémentation des Webhooks.

ClaudeCodeLab aide à auditer des intégrations de paiement, former les équipes à Claude Code et concevoir des parcours de monétisation pour SaaS et contenu. La page de consulting en anglais est le point de départ, et la bibliothèque de produits contient des templates et checklists.

J’ai testé ce flux dans un projet Next.js local avec Stripe en mode test, transfert Webhook, événement dupliqué et remboursement complet. Le meilleur investissement a été de modéliser Order et Entitlement avant l’interface. Une fois l’état clair, le diff de Claude Code et la revue humaine deviennent beaucoup plus fiables.

#Claude Code #Stripe #paiement #Webhook #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.