Integrar pagos con Claude Code: Stripe Checkout, Webhooks, reembolsos e impuestos
Implementa pagos Stripe con Claude Code: precios, Checkout, Webhooks, idempotencia, fulfillment, reembolsos e impuestos.
En un SaaS pequeño, una academia online o un producto de contenido, integrar pagos no significa solo añadir un botón de compra. Necesitas una tabla de productos, una API para crear Checkout Sessions, verificación de Webhooks, fulfillment idempotente, manejo de reembolsos y una forma clara de pasar la información fiscal a contabilidad. Si falta una pieza, Stripe puede registrar el cobro mientras tu aplicación no entrega el acceso, o puedes reembolsar a un cliente y dejarle activo el plan de pago.
Claude Code ayuda mucho, pero solo si le das límites concretos. Un prompt como “añade pagos con Stripe” suele producir una interfaz bonita y una lógica frágil en la página de éxito. Para código de ingresos, pídele tareas pequeñas y revisables: tabla de precios en servidor, ruta de Checkout, verificación de firma con raw body, deduplicación de eventos, fulfillment, reembolsos y logs sin datos sensibles.
En esta guía usamos Next.js App Router, Node.js, TypeScript y Stripe Checkout. Webhook significa una notificación servidor a servidor que Stripe envía a tu app. Fulfillment significa entregar lo comprado: descarga, plan SaaS, plaza, reserva o acceso a una formación. Idempotencia significa que ejecutar la misma operación dos veces no duplica el resultado.
Documentación oficial que debes tener abierta
Para detalles específicos del proveedor, usa documentación oficial. La creación de sesiones está en Checkout Session create API, la entrega tras el pago en Checkout fulfillment, los Webhooks y la firma en Receive Stripe events, y los reintentos seguros en Idempotent requests.
Los reembolsos están en Create a refund, la parte fiscal en Stripe Tax for Checkout, y las pruebas locales en Stripe CLI. Para Next.js consulta Route Handlers. Para el uso de la herramienta, Anthropic mantiene Claude Code overview.
Si más adelante cambias a Paddle, Lemon Squeezy, PayPal u otro proveedor local, los principios siguen siendo los mismos: el precio lo decide el servidor, el pago se confirma por Webhook, el fulfillment debe ser idempotente y el reembolso debe sincronizarse con el acceso dentro de la aplicación.
Casos de monetización
| Caso | Modelo | Fulfillment | Error típico |
|---|---|---|---|
| Pack de prompts, PDF o plantillas | Pago único | Activar descarga y enviar correo al comprador | Si solo entregas en la página de éxito, el usuario que cierra el navegador puede perder el acceso |
| Plan Pro de un SaaS pequeño | Suscripción mensual | Activar plan:pro y actualizar estado con eventos posteriores | Ignorar cancelaciones o facturas fallidas deja funciones de pago abiertas |
| Reserva para consultoría o taller de Claude Code | Pago único o flujo con factura | Reservar plaza y avisar al equipo | El reembolso no define si se libera la plaza o queda pendiente de revisión |
En un prototipo de producto de contenido, Masa empezó tratando la página de éxito como prueba de pago. Funcionaba con una tarjeta de prueba, pero fallaba al revisar reintentos de Webhook, cierres del navegador, pagos diferidos y reembolsos. La instrucción más segura para Claude Code fue: “la página de éxito solo muestra estado; el acceso se concede desde un fulfillment común llamado por el Webhook”.
Arquitectura
flowchart LR
Buyer["Comprador"] --> Pricing["Tabla de precios"]
Pricing --> CheckoutApi["/api/checkout"]
CheckoutApi --> Order["Tabla Order"]
CheckoutApi --> Stripe["Stripe Checkout Session"]
Stripe --> Hosted["Stripe hosted checkout"]
Hosted --> Webhook["/api/webhooks/stripe"]
Webhook --> Fulfill["fulfillCheckoutSession"]
Fulfill --> Entitlement["Tabla Entitlement"]
Hosted --> Success["Página de éxito"]
Success --> ReadOnly["Leer estado del pedido"]
La página de éxito no debe abrir el acceso. Solo confirma y muestra estado. El Webhook verifica la firma del proveedor, registra el ID del evento y llama a una única función de fulfillment que puede ejecutarse varias veces para la misma sesión sin duplicar permisos.
Prompt recomendado para 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.
Tabla de productos y variables de entorno
El navegador debe enviar solo productKey. Los Price ID, el modo de cobro y la clave de acceso viven en el servidor.
// 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;
}
Empieza en modo de prueba. No pegues claves sk_test_, sk_live_ ni whsec_ dentro de prompts.
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
Base de datos para idempotencia
El estado de pago de producción debe vivir en la base de datos. StripeEvent.id bloquea eventos duplicados y Entitlement representa el acceso real que consulta la app.
// 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 de Checkout
Las llamadas al SDK de Stripe y la verificación de Webhook deben ejecutarse en runtime Node.js. La ruta crea el pedido interno, crea la Checkout Session, guarda el Session ID y devuelve la 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 y tax_id_collection ayudan a recopilar datos fiscales, pero no sustituyen una revisión contable. Registro fiscal, facturas, tasas y tratamiento de reembolsos dependen de tu caso.
Componente de precios
La interfaz no envía importes ni Price ID. El precio mostrado es texto; el precio real lo decide Stripe con el Price configurado.
// src/components/PricingTable.tsx
"use client";
import { useState } from "react";
const cards = [
{
productKey: "promptPack",
name: "Prompt Pack",
price: "39 €",
description: "Pago único por plantillas y material práctico",
},
{
productKey: "proMonthly",
name: "Pro",
price: "29 €/mes",
description: "Plan mensual para un SaaS pequeño",
},
{
productKey: "workshopDeposit",
name: "Workshop Deposit",
price: "100 €",
description: "Reserva para formación o consultoría",
},
];
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 ? "Redirigiendo..." : "Ir a Checkout"}
</button>
</section>
))}
</div>
);
}
Desactivar el botón mejora la experiencia, pero la seguridad real está en el servidor: tabla de productos, idempotency key de Stripe, ID único del evento y restricciones de base de datos.
Verificación del Webhook y fulfillment
No llames a await req.json() antes de verificar. Stripe necesita el raw body sin modificar.
// 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 };
});
}
Lo importante es que solo exista un lugar donde se concede acceso. Webhook, página de estado y tareas de reparación pueden llamar a la misma función sin duplicar permisos.
Reembolsos y acceso
Un reembolso no termina en Stripe. Tu producto debe decidir si se revoca el acceso, si un reembolso parcial lo mantiene, o si una reserva vuelve a estar 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 });
}
Pide a Claude Code que pregunte antes de inventar la política de reembolsos. “El reembolso parcial mantiene acceso” o “el reembolso total revoca acceso” son decisiones de negocio.
Pruebas locales
Usa Stripe CLI para reenviar eventos a tu ruta local.
npm run dev
stripe login
stripe listen --forward-to localhost:3000/api/webhooks/stripe
Copia el whsec_... mostrado en STRIPE_WEBHOOK_SECRET, reinicia Next.js y completa Checkout en modo de prueba. La tarjeta de prueba 4242 4242 4242 4242, una fecha futura, cualquier CVC y cualquier código postal cubren el caso exitoso básico. Para rechazos y autenticación, consulta Testing.
Revisa cuatro puntos: la API crea un pedido interno, el Webhook recibe checkout.session.completed, el pedido queda FULFILLED con un solo Entitlement, y repetir el evento no crea otro acceso.
Prompt de revisión para Claude Code
Después de implementar, usa Claude Code como revisor.
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 puede escribir el código, pero no es responsable final de facturación. Claves API, Dashboard, impuestos, términos, política de reembolso y privacidad de logs siguen siendo revisión humana.
Resumen y CTA
Diseña pagos desde el ciclo de ingresos, no desde el botón. La tabla envía productKey, el servidor crea Checkout, el Webhook verifica la firma y una función idempotente concede acceso. Reembolsos e impuestos deben estar en el primer diseño.
Para un tutorial más específico, lee Stripe Checkout con Claude Code. Para secretos, consulta gestión de variables de entorno, y para eventos, implementación de Webhooks.
ClaudeCodeLab ayuda a revisar integraciones de pago, formar equipos en Claude Code y diseñar rutas de monetización para SaaS y contenido. Puedes empezar en la consultoría en inglés o usar la biblioteca de productos con plantillas y checklists.
Probé este flujo en un proyecto local de Next.js con Stripe en modo de prueba, Webhook forwarding, evento duplicado y reembolso completo. Lo más importante fue modelar Order y Entitlement antes de pulir la UI. Con el estado claro, el diff de Claude Code y la revisión humana se vuelven mucho más fiables.
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.
Sobre el autor
Masa
Ingeniero enfocado en workflows prácticos con Claude Code.
Artículos relacionados
Workflow de Obsidian a CLAUDE.md con Claude Code
Convierte notas de trabajo de Obsidian en notas operativas de CLAUDE.md para no repetir contexto.
Claude Code Revenue CTA Routing: de artículos a PDF, Gumroad y consulta
Un flujo con Claude Code para dirigir lectores a PDF gratis, Gumroad o consulta según intención.
Reglas de handoff para equipos con Claude Code: evidencia, permisos, rollback e ingresos
Formato práctico para entregar trabajo de Claude Code con pruebas, permisos, rollback, PDF gratis, Gumroad y consulta.