Use Cases (Actualizado: 1/6/2026)

Implementar Stripe Checkout con Claude Code: Sessions, webhooks e idempotencia

Implementa Stripe Checkout con Claude Code: hosted checkout, webhooks, metadata, idempotencia, modo test y recuperación.

Implementar Stripe Checkout con Claude Code: Sessions, webhooks e idempotencia

Stripe Checkout reduce mucho el código de pagos porque no tienes que construir el formulario de tarjeta. Aun así, una integración lista para producción no termina en crear una URL. Necesitas Checkout Sessions API, hosted checkout, webhook fulfillment, metadata.order_id, idempotencia, modo test, recuperación ante fallos y revisión de reembolsos o cancelaciones.

Claude Code ayuda si lo usas con tareas pequeñas y revisables. No le pidas “crea pagos con Stripe” de una sola vez. Pídele primero el esquema de pedidos, luego validación de variables de entorno, después la route de Checkout, después el webhook con firma, y al final una revisión crítica de seguridad.

Este artículo usa Next.js App Router y Node TypeScript. El ejemplo no escribe claves secretas en el código, crea primero el pedido interno, redirige al Checkout alojado por Stripe y entrega el producto mediante una función idempotente en el servidor.

Flujo oficial de Stripe

La documentación de Checkout Sessions API explica que la Session es la base de hosted Checkout, embedded Checkout y flujos custom. Para la mayoría de SaaS, cursos y productos digitales, hosted checkout es el punto de partida más seguro porque Stripe gestiona el formulario, la autenticación y los errores de pago.

En How Checkout works, el ciclo es claro: tu app crea una Checkout Session, el cliente paga en la página alojada por Stripe, y un evento como checkout.session.completed dispara el fulfillment. Fulfillment significa entregar lo comprado: acceso a una descarga, plan SaaS, plaza de taller, envío o reserva.

La guía oficial de fulfillment insiste en dos reglas: usa webhooks y haz que fulfillment sea seguro aunque se ejecute varias veces para la misma Session. La referencia de la API también deja claro que una Session representa un intento de pago y luego referencia Customer, PaymentIntent o Subscription.

Para ampliar el diseño, revisa integración de pagos con Claude Code, mejores prácticas de seguridad y gestión de variables de entorno.

Arquitectura recomendada

flowchart LR
  Buyer["Comprador"] --> App["App Next.js"]
  App --> CheckoutRoute["/api/checkout"]
  CheckoutRoute --> Orders["Tabla 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 de Checkout crea o reutiliza un pedido interno y devuelve session.url. El webhook verifica la firma con el raw body y llama a fulfillment. La página de éxito también puede llamar a fulfillment para mejorar la experiencia, pero nunca sustituye al webhook.

Entorno, base de datos y cliente Stripe

npm install stripe zod @prisma/client
npm install --save-dev prisma
npx prisma init

Usa valores de test en local. No pegues claves sk_test_, sk_live_ ni whsec_ reales en prompts de 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
}
// 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);

Crear 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 contiene identificadores internos, no datos personales. El email va en customer_email, no en metadata. El idempotencyKey protege reintentos al crear la Session, pero el webhook necesita su propia protección en la base de datos.

Fulfillment idempotente y 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 });
}

El raw body es obligatorio para verificar la firma. Si Claude Code cambia esto por request.json() antes de verificar, es un bug de seguridad.

Pruebas, casos reales y errores comunes

stripe login
stripe listen --forward-to localhost:3000/api/stripe/webhook

Usa el whsec_ mostrado por la CLI, reinicia Next.js y paga con la tarjeta de test 4242 4242 4242 4242. Comprueba la base de datos: Order.status debe quedar en FULFILLED, los IDs de Stripe deben guardarse y recargar la página de éxito no debe crear un segundo log.

Caso de usoCheckout modeFulfillmentRiesgo
PDF o plantilla digitalpaymentDar acceso de descargaNo entregar solo desde la página de éxito
Plan SaaS ProsubscriptionActivar límites y plazasManejar cancelación de suscripción
Depósito para consultoríapaymentReservar horario y notificarDefinir qué hace un reembolso

Errores frecuentes: dar acceso solo en success_url, olvidar metadata.order_id, confiar solo en idempotencyKey, mezclar claves test con price IDs live, y marcar reembolsos en Stripe sin actualizar permisos en tu app.

Para que Claude Code revise la implementación, usa una petición concreta.

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.

Confirmación antes de usarlo

Antes de publicar, completa un pago en modo test, mira el webhook reenviado, revisa la tabla de pedidos, recarga la página de éxito, expira una Session, reembolsa un cargo y cancela una suscripción de prueba. Si la misma Checkout Session puede procesarse varias veces sin duplicar permisos, ya tienes una base seria para review de producción.

ClaudeCodeLab puede ayudarte a revisar una integración Stripe Checkout, formar al equipo en flujos de Claude Code para pagos o implementar la primera versión con tus reglas de pedidos, reembolsos y suscripciones.

#Claude Code #Stripe #pagos #Checkout #TypeScript
Gratis

PDF gratis: cheatsheet de Claude Code

Introduce tu email y descarga una hoja con comandos, hábitos de revisión y flujos seguros.

Cuidamos tus datos y no enviamos spam.

Masa

Sobre el autor

Masa

Ingeniero enfocado en workflows prácticos con Claude Code.