EmpiezaTuSaaS

Pagos con Stripe

Configura pagos únicos con Stripe Checkout

Pagos con Stripe

Configura pagos end-to-end con Stripe Checkout. Al terminar tendrás:

  • Productos y precios configurados en Stripe
  • Boton de checkout que redirige a Stripe
  • Webhook que procesa pagos completados
  • Registro de suscripcion en la base de datos

Prerequisito: haber completado Página privada (autenticación y rutas protegidas funcionando).


Paso 1: Crea tu cuenta de Stripe

Registrate en Stripe

  1. Ve a dashboard.stripe.com y crea una cuenta
  2. Activa el modo test (toggle arriba a la derecha)
  3. No necesitas verificar tu identidad para testear

Obten tus claves API

  1. Ve a Developers > API keys
  2. Copia la Publishable key (pk_test_...)
  3. Copia la Secret key (sk_test_...)

La Secret key solo se muestra una vez. Guardala en un lugar seguro.


Paso 2: Crea productos y precios

En el dashboard de Stripe, ve a Products > Add product.

Crea dos productos (o los que necesites):

Producto 1: Starter

  • Nombre: Starter
  • Precio: 19.00 EUR/mes (recurring monthly)

Producto 2: Pro

  • Nombre: Pro
  • Precio: 49.00 EUR/mes (recurring monthly)

Si prefieres pagos únicos (one-time) en lugar de suscripciones, selecciona One time al crear el precio. El boilerplate soporta ambos modos automáticamente.

Copia el Price ID de cada producto (empieza con price_). Lo encuentras en la página del producto, sección Pricing.


Paso 3: Configura las variables de entorno

Añade las claves y Price IDs a tu .env:

.env
# Stripe
STRIPE_SECRET_KEY=sk_test_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...

# Price IDs
STRIPE_PRICE_ID_STARTER_MONTHLY=price_...
STRIPE_PRICE_ID_STARTER_ANNUAL=price_...
STRIPE_PRICE_ID_PRO_MONTHLY=price_...
STRIPE_PRICE_ID_PRO_ANNUAL=price_...

Paso 4: Entiende la configuración de Stripe

Servidor: lib/stripe.ts

El cliente de Stripe en el servidor usa lazy initialization para evitar errores cuando las variables de entorno no estan configuradas:

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

let _stripe: Stripe | null = null;

export function getStripeServer(): Stripe {
  if (!process.env.STRIPE_SECRET_KEY) {
    throw new Error("STRIPE_SECRET_KEY is not set");
  }
  if (!_stripe) {
    _stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
  }
  return _stripe;
}

// Proxy para backward compatibility
export const stripe = new Proxy({} as Stripe, {
  get(_target, prop) {
    return getStripeServer()[prop as keyof Stripe];
  },
});

Cliente: lib/stripe-client.ts

El cliente de Stripe en el frontend carga Stripe.js para redirecciónes:

lib/stripe-client.ts
import { loadStripe } from "@stripe/stripe-js";

let stripePromise: ReturnType<typeof loadStripe> | null = null;

export function getStripe() {
  if (!stripePromise) {
    stripePromise = loadStripe(
      process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!
    );
  }
  return stripePromise;
}

Planes: config/stripe.ts

Los planes se definen en config/stripe.ts con sus Price IDs:

config/stripe.ts
export const pricingPlans: PricingPlan[] = [
  {
    id: "starter",
    name: "Starter",
    icon: Zap,
    description: "Para proyectos que estan empezando",
    pricing: {
      monthly: {
        price: 19,
        priceId: process.env.STRIPE_PRICE_ID_STARTER_MONTHLY || "",
      },
      annual: {
        price: 15,
        priceId: process.env.STRIPE_PRICE_ID_STARTER_ANNUAL || "",
      },
    },
    currency: "EUR",
    cta: "Empezar ahora",
    features: ["Hasta 1.000 usuarios", "Pagos con Stripe", /* ... */],
  },
  // ... plan Pro
];

export const stripeConfig = {
  plans: pricingPlans,
  successUrl: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?success=true`,
  cancelUrl: `${process.env.NEXT_PUBLIC_APP_URL}/#pricing`,
  portalReturnUrl: `${process.env.NEXT_PUBLIC_APP_URL}/billing`,
};

Cambia los nombres, precios y features para adaptarlos a tu producto.


Paso 5: El endpoint de Checkout

El endpoint app/api/stripe/checkout/route.ts crea la sesión de checkout:

app/api/stripe/checkout/route.ts
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { stripe } from "@/lib/stripe";
import { db } from "@/lib/db";
import { stripeConfig } from "@/config/stripe";
import { resolveCheckoutMode } from "@/lib/stripe-events";

export async function POST(request: NextRequest) {
  // 1. Verificar autenticación
  const session = await auth.api.getSession({
    headers: request.headers,
  });

  if (!session?.user) {
    return NextResponse.json(
      { message: "No autorizado. Inicia sesión primero." },
      { status: 401 }
    );
  }

  const { priceId, planName, couponId } = await request.json();

  // 2. Obtener o crear Stripe Customer
  let stripeCustomerId: string | undefined;

  const subscription = await db.subscription.findUnique({
    where: { userId: session.user.id },
  });

  if (subscription?.stripeCustomerId) {
    stripeCustomerId = subscription.stripeCustomerId;
  } else {
    const customer = await stripe.customers.create({
      email: session.user.email,
      name: session.user.name,
      metadata: { userId: session.user.id },
    });
    stripeCustomerId = customer.id;

    await db.subscription.upsert({
      where: { userId: session.user.id },
      update: { stripeCustomerId: customer.id },
      create: {
        userId: session.user.id,
        stripeCustomerId: customer.id,
      },
    });
  }

  // 3. Determinar si es pago único o suscripcion
  const price = await stripe.prices.retrieve(priceId);
  const mode = resolveCheckoutMode(price.type);

  // 4. Crear sesión de checkout
  const checkoutSession = await stripe.checkout.sessions.create({
    customer: stripeCustomerId,
    mode,
    payment_method_types: ["card"],
    line_items: [{ price: priceId, quantity: 1 }],
    success_url: stripeConfig.successUrl,
    cancel_url: stripeConfig.cancelUrl,
    metadata: {
      userId: session.user.id,
      planName: planName || "",
    },
    tax_id_collection: { enabled: true },
    customer_update: { name: "auto", address: "auto" },
    ...(couponId
      ? { discounts: [{ coupon: couponId }] }
      : { allow_promotion_codes: true }),
  });

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

Flujo del checkout:

  1. Verifica que el usuario está autenticado
  2. Busca o crea un Stripe Customer vinculado al usuario
  3. Determina el modo (payment para pago unico, subscription para recurrente)
  4. Crea la sesión de Stripe Checkout con el Price ID
  5. Devuelve la URL de checkout para redirigir al usuario

Paso 6: Llama al checkout desde el frontend

Crea un boton que inicie el proceso de pago:

components/checkout-button.tsx
"use client";

import { useState } from "react";
import { Button } from "@/components/ui/button";

interface CheckoutButtonProps {
  priceId: string;
  planName: string;
  children: React.ReactNode;
}

export function CheckoutButton({ priceId, planName, children }: CheckoutButtonProps) {
  const [loading, setLoading] = useState(false);

  async function handleCheckout() {
    setLoading(true);

    try {
      const res = await fetch("/api/stripe/checkout", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ priceId, planName }),
      });

      const data = await res.json();

      if (data.url) {
        // Redirigir a Stripe Checkout
        window.location.href = data.url;
      } else {
        console.error("Error:", data.message);
      }
    } catch (error) {
      console.error("Error al crear checkout:", error);
    } finally {
      setLoading(false);
    }
  }

  return (
    <Button onClick={handleCheckout} disabled={loading} className="w-full">
      {loading ? "Redirigiendo..." : children}
    </Button>
  );
}

Usa el componente en tu página de pricing:

<CheckoutButton
  priceId={process.env.NEXT_PUBLIC_STRIPE_PRICE_ID_PRO || ""}
  planName="Pro"
>
  Obtener Pro
</CheckoutButton>

El priceId debe ser un Price ID valido de Stripe (empieza con price_). Si usas variables de entorno en el cliente, deben tener el prefijo NEXT_PUBLIC_.


Paso 7: Configura el Webhook

El webhook procesa los eventos de Stripe (pagos completados, suscripciones actualizadas, etc.).

Instala Stripe CLI para desarrollo local

# macOS
brew install stripe/stripe-cli/stripe

# O descarga desde https://stripe.com/docs/stripe-cli

Inicia sesión:

stripe login

Reenviar eventos a tu servidor local

stripe listen --forward-to localhost:3000/api/stripe/webhooks

Esto te dara un webhook secret (whsec_...). Copialo a tu .env:

.env
STRIPE_WEBHOOK_SECRET=whsec_...

Deja stripe listen corriendo en una terminal separada mientras desarrollas.

Que hace el webhook

El archivo app/api/stripe/webhooks/route.ts maneja estos eventos:

app/api/stripe/webhooks/route.ts
export async function POST(request: NextRequest) {
  const body = await request.text();
  const signature = request.headers.get("stripe-signature");

  // Verificar firma del webhook
  const event = stripe.webhooks.constructEvent(
    body, signature!, webhookSecret
  );

  switch (event.type) {
    case "checkout.session.completed":
      // Pago completado -> Activar plan del usuario
      await handleCheckoutCompleted(session);
      break;

    case "customer.subscription.updated":
      // Suscripcion actualizada -> Sincronizar estado
      await handleSubscriptionUpdate(subscription);
      break;

    case "customer.subscription.deleted":
      // Suscripcion cancelada -> Marcar como cancelado
      await handleSubscriptionDeleted(subscription);
      break;

    case "invoice.paid":
      // Factura pagada -> Reactivar si estaba pendiente
      await handleInvoicePaid(invoice);
      break;

    case "invoice.payment_failed":
      // Pago fallido -> Marcar como past_due
      await handlePaymentFailed(invoice);
      break;
  }

  return NextResponse.json({ received: true });
}

Cuando se completa un checkout, handleCheckoutCompleted guarda el plan en la tabla subscriptions:

async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
  const userId = session.metadata?.userId;
  const planName = session.metadata?.planName;

  await db.subscription.upsert({
    where: { userId },
    update: {
      plan: planName,
      status: "active",
      stripePriceId: priceId,
    },
    create: {
      userId,
      stripeCustomerId: session.customer as string,
      plan: planName,
      status: "active",
      stripePriceId: priceId,
    },
  });
}

Paso 8: Prueba el flujo completo

Arranca los servicios

En una terminal:

npm run dev

En otra terminal:

stripe listen --forward-to localhost:3000/api/stripe/webhooks

Realiza un pago de prueba

  1. Inicia sesión en tu app (Google OAuth o email/password)
  2. Como requirePayment está activado por defecto, verás la página de pricing en lugar del dashboard
  3. Elige un plan y haz click en el botón de checkout
  4. En la página de Stripe, usa la tarjeta de prueba:
Numero:    4242 4242 4242 4242
Fecha:     Cualquier fecha futura (ej: 12/34)
CVC:       Cualquier 3 digitos (ej: 123)
Nombre:    Cualquier nombre
  1. Completa el pago
  2. Deberías ser redirigido a /dashboard?success=true y ahora ver el dashboard completo con sidebar

Si requirePayment está en true (por defecto), el usuario ve pricing al hacer login y solo accede al dashboard después de pagar. Los admins se saltan el paywall. Puedes cambiar esto en config/site.ts.

Verifica en la base de datos

npx prisma studio

Navega a la tabla subscriptions. Deberías ver:

CampoValor esperado
userIdTu ID de usuario
stripeCustomerIdcus_...
planstarter o pro
statusactive
stripePriceIdprice_...

Verifica los logs de Stripe CLI

En la terminal donde corre stripe listen, deberías ver:

2024-01-01 12:00:00  --> checkout.session.completed [evt_...]
2024-01-01 12:00:00  <-- [200] POST http://localhost:3000/api/stripe/webhooks

Si ves [200], el webhook se proceso correctamente.


Paso 9: Webhook en producción

Cuando despliegues a producción, configura el webhook en el dashboard de Stripe:

Crea el endpoint en Stripe

  1. Ve a Developers > Webhooks > Add endpoint
  2. URL: https://tu-dominio.com/api/stripe/webhooks
  3. Eventos a escuchar:
    • checkout.session.completed
    • customer.subscription.created
    • customer.subscription.updated
    • customer.subscription.deleted
    • invoice.paid
    • invoice.payment_failed
  4. Click Add endpoint

Copia el signing secret

En la página del endpoint, click en Reveal para ver el signing secret. Actualizalo en tus variables de entorno de producción (Vercel):

STRIPE_WEBHOOK_SECRET=whsec_...  # El nuevo secret de producción

El STRIPE_WEBHOOK_SECRET de desarrollo (Stripe CLI) y el de producción (Stripe Dashboard) son diferentes. Asegúrate de usar el correcto en cada entorno.


Modelo de datos: Subscription

La tabla subscriptions en tu base de datos tiene está estructura:

prisma/schema.prisma
model Subscription {
  id                     String    @id @default(cuid())
  userId                 String    @unique
  user                   User      @relation(fields: [userId], references: [id], onDelete: Cascade)
  stripeCustomerId       String?   @unique
  stripeSubscriptionId   String?   @unique
  stripePriceId          String?
  stripeCurrentPeriodEnd DateTime?
  plan                   String?   // "starter" | "pro"
  status                 String    @default("inactive")
  createdAt              DateTime  @default(now())
  updatedAt              DateTime  @updatedAt

  @@map("subscriptions")
}

Los estados posibles del campo status:

StatusSignificado
activePago completado, acceso completo
inactiveSin suscripcion o pago pendiente
canceledSuscripcion cancelada
past_duePago fallido, pendiente de reintento

Verificar el plan del usuario

Para comprobar si un usuario tiene un plan activo, consulta la tabla subscriptions:

Ejemplo: verificar plan activo
import { db } from "@/lib/db";

async function hasActivePlan(userId: string): boolean {
  const subscription = await db.subscription.findUnique({
    where: { userId },
  });

  return subscription?.status === "active";
}

async function getUserPlan(userId: string): string | null {
  const subscription = await db.subscription.findUnique({
    where: { userId },
    select: { plan: true, status: true },
  });

  if (subscription?.status !== "active") return null;
  return subscription.plan;
}

Usa esto en tus páginas o API routes para controlar el acceso por plan.


Errores comunes

"No signatures found matching the expected signature"

  • El STRIPE_WEBHOOK_SECRET no coincide con el entorno
  • En desarrollo, usa el secret que te da stripe listen
  • En producción, usa el secret del dashboard de Stripe
  • Reinicia el servidor despues de cambiar el secret

Se crean multiples Stripe Customers para el mismo usuario

  • El endpoint verifica primero si ya existe un stripeCustomerId en la tabla subscriptions
  • Si hay registros huerfanos, limpia la tabla con Prisma Studio
  • Verifica que el userId es consistente

"No such price: price_xxx"

  • Verifica que el Price ID existe en tu dashboard de Stripe
  • Asegúrate de que usas claves del mismo entorno (test con test, live con live)
  • Los Price IDs de test y live son diferentes

El pago se completo en Stripe pero no se refleja en la base de datos

  • Verifica que stripe listen está corriendo (desarrollo)
  • Revisa los logs del webhook en Stripe Dashboard > Webhooks > Events
  • Confirma que userId está en el metadata del checkout session
  • Revisa los logs del servidor para errores en el handler del webhook

Resumen

Has configurado:

  • Cuenta de Stripe con productos y precios
  • Variables de entorno para claves API y Price IDs
  • Endpoint de checkout que crea sesiónes de Stripe
  • Boton de checkout en el frontend
  • Webhook que procesa pagos y actualiza la base de datos
  • Testing con Stripe CLI y tarjeta de prueba

Siguientes pasos

On this page