Use Cases (Actualizado: 2/6/2026)

Implementar suscripciones de Stripe con Claude Code

Implementa suscripciones Stripe con Claude Code: Checkout, Portal, webhooks, derechos y monetización SaaS.

Implementar suscripciones de Stripe con Claude Code

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.

PlanUsuario objetivoMensual ejemploAnual ejemploPermisos
FreeVisitantes que prueban contenido0 USD0 USDvista previa y descargas de muestra
ProCreadores individuales y compradores de plantillas19 USD190 USDartículos completos, plantillas, biblioteca del curso
Studioequipos, empresas y clientes recurrentes99 USD990 USDPro, 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.

#Claude Code #Stripe #suscripciones #SaaS #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.