Implementar suscripciones de Stripe con Claude Code
Implementa suscripciones Stripe con Claude Code: Checkout, Portal, webhooks, derechos y monetización SaaS.
Una suscripción es una operación de ingresos, no solo un botón
Crear una sesión de Stripe Checkout es la parte fácil. Lo difícil en un SaaS, una biblioteca de plantillas, una membresía de contenido o una oferta de consultoría recurrente es mantener el acceso correcto cuando cambian los estados: pago fallido, renovación, cancelación al final del periodo, factura, cambio de plan, portal de cliente y reintentos de Webhook.
Claude Code funciona mejor cuando lo usas con una estructura clara. En vez de pedir “integra Stripe”, dale documentación oficial, tablas de base de datos, reglas de acceso, comandos de prueba y criterios de revisión. Así el agente no improvisa el modelo de negocio y tú puedes revisar el resultado con menos ambigüedad.
La separación clave es esta: Stripe es la fuente de verdad de facturación; tu aplicación guarda el estado de acceso. Stripe sabe si la suscripción está active, trialing, past_due, unpaid o canceled. Tu app decide si el usuario puede descargar plantillas, leer contenido premium, usar asientos de equipo o pedir una revisión.
flowchart LR
A["Página de precios"] --> B["Checkout Session<br/>mode=subscription"]
B --> C["Suscripción en Stripe"]
C --> D["Webhook firmado"]
D --> E["billing_subscriptions"]
D --> F["entitlements"]
F --> G["Control de acceso"]
G --> H["SaaS, curso o biblioteca"]
H --> I["Customer Portal"]
I --> C
Diseña precios y permisos antes de escribir código
Una tabla de planes evita que la lógica de monetización quede repartida en componentes React, rutas API y correos de soporte.
| Plan | Usuario objetivo | Mensual ejemplo | Anual ejemplo | Permisos |
|---|---|---|---|---|
| Free | Visitantes que prueban contenido | 0 USD | 0 USD | vista previa y descargas de muestra |
| Pro | Creadores individuales y compradores de plantillas | 19 USD | 190 USD | artículos completos, plantillas, biblioteca del curso |
| Studio | equipos, empresas y clientes recurrentes | 99 USD | 990 USD | Pro, asientos de equipo, revisiones, material de formación |
Trabaja con al menos tres casos concretos. En un micro-SaaS, el plan de pago puede desbloquear exportaciones, automatizaciones y paneles. En un producto informativo, la ruta puede ir de artículo gratuito a plantilla pagada y luego a membresía. En una consultoría o formación, la suscripción puede cubrir materiales de equipo, sesiones mensuales y solicitudes de revisión. Estos casos revelan estados que un ejemplo genérico de Stripe suele olvidar.
Entitlement significa permiso de uso: por ejemplo templates:download o team:seats. Dunning es el proceso de recuperación cuando falla un pago. Observability significa poder ver qué evento de Stripe cambió el plan, el estado y los permisos de un usuario.
Prompt recomendado para Claude Code
Incluye enlaces oficiales porque los detalles de Stripe cambian y conviene que las afirmaciones específicas salgan de documentación actual.
Implementa suscripciones de Stripe Billing para una app Next.js App Router.
Usa estos documentos oficiales como fuente:
- Checkout Sessions API: https://docs.stripe.com/api/checkout/sessions/create
- Customer Portal: https://docs.stripe.com/customer-management
- Subscription webhooks: https://docs.stripe.com/billing/subscriptions/webhooks
- Webhooks and local testing: https://docs.stripe.com/webhooks
- Subscription object and statuses: https://docs.stripe.com/api/subscriptions/object
Requisitos:
- TypeScript, Next.js App Router y Postgres
- Checkout con mode="subscription"
- Customer Portal para métodos de pago, facturas, cambios de plan y cancelación
- Webhook con verificación de firma y deduplicación por event_id
- Tabla local entitlements para decidir acceso
- active/trialing conceden acceso, past_due tiene 3 días de gracia, unpaid/canceled/paused revocan
- Incluye código copiable y comandos stripe listen
Esquema Postgres
billing_subscriptions refleja el estado de Stripe. entitlements es lo que consulta la aplicación antes de mostrar una función premium. webhook_events evita procesar dos veces el mismo evento.
create table if not exists app_users (
id text primary key,
email text not null unique,
stripe_customer_id text unique,
created_at timestamptz not null default now()
);
create table if not exists billing_subscriptions (
user_id text primary key references app_users(id) on delete cascade,
stripe_customer_id text not null,
stripe_subscription_id text not null unique,
plan_key text not null,
status text not null check (
status in ('incomplete', 'incomplete_expired', 'trialing', 'active', 'past_due', 'canceled', 'unpaid', 'paused')
),
access_state text not null check (access_state in ('pending', 'granted', 'grace', 'revoked')),
current_period_end timestamptz,
cancel_at_period_end boolean not null default false,
grace_until timestamptz,
updated_at timestamptz not null default now()
);
create table if not exists entitlements (
user_id text not null references app_users(id) on delete cascade,
feature_key text not null,
active boolean not null default true,
expires_at timestamptz,
updated_at timestamptz not null default now(),
primary key (user_id, feature_key)
);
create table if not exists webhook_events (
event_id text primary key,
event_type text not null,
status text not null default 'processing',
attempts integer not null default 1,
processed_at timestamptz,
last_error text,
created_at timestamptz not null default now()
);
Checkout y Portal en Next.js
npm install stripe postgres
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_PRO_PRICE_ID=price_...
STRIPE_STUDIO_PRICE_ID=price_...
NEXT_PUBLIC_APP_URL=http://localhost:3000
DATABASE_URL=postgres://user:pass@localhost:5432/app
STRIPE_AUTOMATIC_TAX=false
// src/lib/db.ts
import postgres from "postgres";
export const sql = postgres(process.env.DATABASE_URL!, {
max: 5,
idle_timeout: 20,
});
// src/lib/billing.ts
import Stripe from "stripe";
import { sql } from "@/lib/db";
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const APP_URL = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";
export const PLANS = {
free: { priceId: null, features: ["article:preview"] },
pro: {
priceId: process.env.STRIPE_PRO_PRICE_ID!,
features: ["article:full", "templates:download", "course:library"],
},
studio: {
priceId: process.env.STRIPE_STUDIO_PRICE_ID!,
features: ["article:full", "templates:download", "course:library", "team:seats", "review:request"],
},
} as const;
export type PlanKey = keyof typeof PLANS;
export type PaidPlanKey = Exclude<PlanKey, "free">;
export function isPaidPlanKey(value: unknown): value is PaidPlanKey {
return value === "pro" || value === "studio";
}
export async function createCheckoutSession(userId: string, planKey: PaidPlanKey) {
const customerId = await findOrCreateCustomer(userId);
const plan = PLANS[planKey];
const session = await stripe.checkout.sessions.create({
customer: customerId,
mode: "subscription",
line_items: [{ price: plan.priceId, quantity: 1 }],
client_reference_id: userId,
allow_promotion_codes: true,
automatic_tax: { enabled: process.env.STRIPE_AUTOMATIC_TAX === "true" },
success_url: `${APP_URL}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${APP_URL}/pricing`,
subscription_data: { metadata: { userId, planKey } },
metadata: { userId, planKey },
});
if (!session.url) throw new Error("Stripe did not return a Checkout URL");
return session.url;
}
export async function createPortalSession(userId: string) {
const customerId = await getCustomerId(userId);
const session = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: `${APP_URL}/settings/billing`,
});
return session.url;
}
async function findOrCreateCustomer(userId: string) {
const users = await sql`select id, email, stripe_customer_id from app_users where id = ${userId}`;
const user = users[0] as { id: string; email: string; stripe_customer_id: string | null } | undefined;
if (!user) throw new Error("User not found");
if (user.stripe_customer_id) return user.stripe_customer_id;
const customer = await stripe.customers.create({ email: user.email, metadata: { userId } });
await sql`update app_users set stripe_customer_id = ${customer.id} where id = ${userId}`;
return customer.id;
}
async function getCustomerId(userId: string) {
const rows = await sql`select stripe_customer_id from app_users where id = ${userId}`;
const customerId = rows[0]?.stripe_customer_id as string | undefined;
if (!customerId) throw new Error("Stripe customer is not linked");
return customerId;
}
// src/app/api/billing/checkout/route.ts
import { NextRequest, NextResponse } from "next/server";
import { createCheckoutSession, isPaidPlanKey } from "@/lib/billing";
export async function POST(request: NextRequest) {
const userId = request.headers.get("x-demo-user-id");
if (!userId) return NextResponse.json({ error: "Missing x-demo-user-id" }, { status: 401 });
const { planKey } = await request.json();
if (!isPaidPlanKey(planKey)) return NextResponse.json({ error: "Invalid plan" }, { status: 400 });
const url = await createCheckoutSession(userId, planKey);
return NextResponse.json({ url });
}
// src/app/api/billing/portal/route.ts
import { NextRequest, NextResponse } from "next/server";
import { createPortalSession } from "@/lib/billing";
export async function POST(request: NextRequest) {
const userId = request.headers.get("x-demo-user-id");
if (!userId) return NextResponse.json({ error: "Missing x-demo-user-id" }, { status: 401 });
const url = await createPortalSession(userId);
return NextResponse.json({ url });
}
Webhook y sincronización de permisos
La URL de éxito no confirma la suscripción; solo mejora la experiencia del usuario. Abre el acceso desde Webhooks firmados o desde el estado más reciente consultado en la API de Stripe. Como los eventos pueden llegar fuera de orden, recuperamos la suscripción antes de actualizar la base de datos.
// src/lib/entitlements.ts
import Stripe from "stripe";
import { sql } from "@/lib/db";
import { PLANS, PlanKey, stripe } from "@/lib/billing";
type AccessState = "pending" | "granted" | "grace" | "revoked";
export async function syncSubscriptionFromStripe(subscriptionId: string) {
const subscription = await stripe.subscriptions.retrieve(subscriptionId, { expand: ["items.data.price"] });
const item = subscription.items.data[0];
const planKey = planKeyFromPrice(item?.price.id);
const userId = subscription.metadata.userId;
if (!userId) throw new Error(`Subscription ${subscription.id} has no userId metadata`);
const accessState = accessStateFor(subscription.status);
const currentPeriodEnd = item?.current_period_end ? new Date(item.current_period_end * 1000) : null;
const graceUntil = accessState === "grace" ? new Date(Date.now() + 3 * 24 * 60 * 60 * 1000) : null;
await sql.begin(async (tx) => {
await tx`
insert into billing_subscriptions (
user_id, stripe_customer_id, stripe_subscription_id, plan_key,
status, access_state, current_period_end, cancel_at_period_end, grace_until, updated_at
)
values (
${userId}, ${subscription.customer as string}, ${subscription.id}, ${planKey},
${subscription.status}, ${accessState}, ${currentPeriodEnd},
${subscription.cancel_at_period_end}, ${graceUntil}, now()
)
on conflict (user_id) do update set
stripe_customer_id = excluded.stripe_customer_id,
stripe_subscription_id = excluded.stripe_subscription_id,
plan_key = excluded.plan_key,
status = excluded.status,
access_state = excluded.access_state,
current_period_end = excluded.current_period_end,
cancel_at_period_end = excluded.cancel_at_period_end,
grace_until = excluded.grace_until,
updated_at = now()
`;
const features = accessState === "granted" || accessState === "grace" ? PLANS[planKey].features : PLANS.free.features;
await tx`delete from entitlements where user_id = ${userId}`;
for (const feature of features) {
await tx`insert into entitlements (user_id, feature_key, active, expires_at, updated_at) values (${userId}, ${feature}, true, ${graceUntil}, now())`;
}
});
console.info("stripe.subscription.synced", { userId, subscriptionId: subscription.id, status: subscription.status, accessState, planKey });
}
function planKeyFromPrice(priceId: string | undefined): PlanKey {
const entry = Object.entries(PLANS).find(([, plan]) => plan.priceId === priceId);
return entry ? (entry[0] as PlanKey) : "free";
}
function accessStateFor(status: Stripe.Subscription.Status): AccessState {
if (status === "active" || status === "trialing") return "granted";
if (status === "past_due") return "grace";
if (status === "incomplete") return "pending";
return "revoked";
}
// src/app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
import { sql } from "@/lib/db";
import { stripe } from "@/lib/billing";
import { syncSubscriptionFromStripe } from "@/lib/entitlements";
export async function POST(request: NextRequest) {
const payload = await request.text();
const signature = request.headers.get("stripe-signature");
if (!signature) return NextResponse.json({ error: "Missing 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 });
}
const rows = await sql`
insert into webhook_events (event_id, event_type, status)
values (${event.id}, ${event.type}, 'processing')
on conflict (event_id) do nothing
returning event_id
`;
if (rows.length === 0) return NextResponse.json({ received: true, duplicate: true });
if (event.type === "checkout.session.completed") {
const session = event.data.object as Stripe.Checkout.Session;
const subscriptionId = typeof session.subscription === "string" ? session.subscription : session.subscription?.id;
if (subscriptionId) await syncSubscriptionFromStripe(subscriptionId);
}
if (event.type === "customer.subscription.created" || event.type === "customer.subscription.updated" || event.type === "customer.subscription.deleted") {
const subscription = event.data.object as Stripe.Subscription;
await syncSubscriptionFromStripe(subscription.id);
}
if (event.type === "invoice.paid" || event.type === "invoice.payment_failed") {
const invoice = event.data.object as Stripe.Invoice;
const value = invoice.parent?.subscription_details?.subscription as string | { id: string } | null | undefined;
const subscriptionId = typeof value === "string" ? value : value?.id;
if (subscriptionId) await syncSubscriptionFromStripe(subscriptionId);
}
await sql`update webhook_events set status = 'processed', processed_at = now() where event_id = ${event.id}`;
return NextResponse.json({ received: true });
}
Pruebas locales
Copia el whsec_... que imprime stripe listen en STRIPE_WEBHOOK_SECRET.
stripe listen --events checkout.session.completed,customer.subscription.updated,customer.subscription.deleted,invoice.paid,invoice.payment_failed --forward-to localhost:3000/api/webhooks/stripe
stripe trigger checkout.session.completed
stripe trigger invoice.payment_failed
Los eventos disparados por CLI sirven para pruebas rápidas. Para una revisión real, crea Product y Price en modo test, completa Checkout en el navegador, abre el Customer Portal y prueba cambio de plan, actualización de tarjeta y cancelación.
Errores frecuentes
No desbloquees funciones pagadas solo porque el usuario volvió a la URL de éxito. Puede no volver, el pago puede requerir autenticación adicional o el Webhook puede llegar después.
No guardes userId solo en metadata de Checkout. Si sincronizas desde la suscripción o la factura, añade también subscription_data.metadata.
No mezcles past_due, unpaid y canceled. past_due puede merecer una gracia corta; unpaid y canceled normalmente revocan acceso.
No olvides configurar Customer Portal en el Dashboard de Stripe. La API crea la sesión, pero el Dashboard decide qué productos, cambios de plan, métodos de pago y cancelaciones están disponibles.
No dejes impuestos, moneda, reembolsos y descuentos anuales para la semana del lanzamiento. Stripe automatiza mucho, pero la política comercial sigue siendo tu responsabilidad.
Ruta de monetización
Cuando Masa probó este flujo para contenidos y plantillas de ClaudeCodeLab, lo más útil fue fijar la tabla de estados antes de pedir código. Con esa tabla, los prompts de Claude Code, las pruebas, los eventos de analítica y los mensajes de soporte apuntan al mismo criterio.
Para continuar, revisa pagos con Claude Code, autenticación con Claude Code y analítica con Claude Code. Si quieres adaptar Stripe Billing, la tabla de permisos y las revisiones de Claude Code a tu SaaS, curso o funnel de producto, consulta formación y consultoría Claude Code o la biblioteca de productos y plantillas.
Antes de implementar, verifica la documentación oficial: Checkout Sessions API, Customer Portal, subscription webhooks, webhooks, subscription statuses y Claude Code setup.
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.