tutoriales·16 min de lectura

Stripe con Next.js: Checkout, Webhooks y Suscripciones

Tutorial completo para integrar Stripe en tu app Next.js. Implementa checkout de un solo pago, suscripciones recurrentes, webhooks para eventos y portal de cliente.

Stripe con Next.js: Checkout, Webhooks y Suscripciones

Si estas construyendo un SaaS, un e-commerce o cualquier aplicacion que necesite cobrar dinero, Stripe con Next.js es la combinacion que vas a encontrar en la mayoria de proyectos serios. Este tutorial de Stripe con Next.js cubre desde un pago unico hasta suscripciones recurrentes, webhooks para sincronizar tu base de datos, y el portal de cliente para que tus usuarios gestionen su plan.

Todo el codigo funciona con el App Router de Next.js, TypeScript y la API mas reciente de Stripe. No vamos a hacer un formulario de tarjeta custom. Vamos a usar Stripe Checkout, que es la forma recomendada por Stripe porque ellos manejan PCI compliance, validacion de tarjetas y la interfaz de pago por ti.

Configurar Stripe y el proyecto

Antes de escribir codigo necesitas una cuenta de Stripe y las dependencias instaladas.

Crear cuenta en Stripe

1

Ve a dashboard.stripe.com y crea una cuenta

El registro es gratuito. No necesitas verificar identidad hasta que quieras cobrar en produccion.

2

Obtener tus API keys

En el dashboard, ve a Developers > API keys. Vas a ver dos pares de claves:

  • Publishable key (pk_test_...): segura para el frontend
  • Secret key (sk_test_...): solo para el servidor, nunca la expongas
3

Activar el modo de pruebas

Asegurate de que el toggle "Test mode" este activo en el dashboard. Todas las transacciones seran simuladas.

Instalar dependencias

Necesitas dos paquetes: stripe para el servidor y @stripe/stripe-js para cargar Stripe en el cliente.

bash
npm install stripe @stripe/stripe-js

Variables de entorno

Configura las claves en tu archivo .env.local:

bash
# .env.local
STRIPE_SECRET_KEY=sk_test_tu_clave_secreta_aqui
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_tu_clave_publica_aqui
STRIPE_WEBHOOK_SECRET=whsec_tu_webhook_secret_aqui

Nunca pongas tu clave secreta de Stripe en el codigo. Si sospechas que se filtro, herramientas como datahogo escanean tu repo automaticamente y te alertan si detectan credenciales expuestas. La STRIPE_SECRET_KEY debe existir solo en variables de entorno del servidor. Si necesitas repasar como funcionan, revisa la guia de variables de entorno en Next.js y Vercel.

⚠️
Claves publicas vs secretas

La publishable key (pk_) esta diseñada para el frontend y lleva el prefijo NEXT_PUBLIC_. La secret key (sk_) NUNCA debe tener ese prefijo. Si alguien obtiene tu secret key, puede crear cobros a nombre tuyo.

Inicializar Stripe en el servidor

Crea un archivo para instanciar el cliente de Stripe que usaras en todos los Route Handlers:

typescript
// lib/stripe.ts
import Stripe from "stripe"
 
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: "2025-12-18.acacia",
  typescript: true,
})

Cargar Stripe en el cliente

Para las redirecciones al checkout necesitas cargar stripe-js en el navegador:

typescript
// lib/stripe-client.ts
import { loadStripe } from "@stripe/stripe-js"
 
let stripePromise: ReturnType<typeof loadStripe>
 
export function getStripe() {
  if (!stripePromise) {
    stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!)
  }
  return stripePromise
}

La estructura del proyecto hasta ahora:

Estructura de archivos

mi-saas/ ├── .env.local ├── lib/ │ ├── stripe.ts (cliente Stripe para el servidor) │ └── stripe-client.ts (carga Stripe en el navegador) ├── app/ │ ├── api/ │ │ ├── checkout/route.ts (crear sesion de checkout) │ │ └── webhooks/route.ts (recibir eventos de Stripe) │ ├── checkout/ │ │ ├── success/page.tsx (pago exitoso) │ │ └── cancel/page.tsx (pago cancelado) │ └── page.tsx └── package.json

Pago unico con Stripe Checkout

El caso mas simple: un usuario hace click en "Comprar", Stripe lo redirige a una pagina de pago segura, y despues vuelve a tu aplicacion.

Crear el Route Handler para checkout

Este endpoint crea una Checkout Session en Stripe y devuelve la URL de pago:

typescript
// app/api/checkout/route.ts
import { NextResponse } from "next/server"
import { stripe } from "@/lib/stripe"
 
export async function POST(request: Request) {
  try {
    const { priceId } = await request.json()
 
    // Crear la sesion de checkout
    const session = await stripe.checkout.sessions.create({
      mode: "payment", // Pago unico
      payment_method_types: ["card"],
      line_items: [
        {
          price: priceId, // ID del precio creado en Stripe Dashboard
          quantity: 1,
        },
      ],
      // URLs de redireccion despues del pago
      success_url: `${process.env.NEXT_PUBLIC_SITE_URL}/checkout/success?session_id={CHECKOUT_SESSION_ID}`,
      cancel_url: `${process.env.NEXT_PUBLIC_SITE_URL}/checkout/cancel`,
    })
 
    return NextResponse.json({ url: session.url })
  } catch (error) {
    console.error("Error creando checkout session:", error)
    return NextResponse.json(
      { error: "Error al crear la sesion de pago" },
      { status: 500 }
    )
  }
}

El {CHECKOUT_SESSION_ID} es un placeholder que Stripe reemplaza automaticamente con el ID real de la sesion cuando redirige al usuario.

Crear el precio en Stripe Dashboard

Antes de probar, necesitas un producto con un precio en Stripe:

  1. Ve a Products en el dashboard de Stripe
  2. Click en Add product
  3. Nombre: "Plan Pro" (o el nombre de tu producto)
  4. Precio: 29.00 USD (o la cantidad que quieras)
  5. Tipo de pago: One time
  6. Guarda y copia el Price ID (empieza con price_)

Boton de compra en el frontend

typescript
// components/BotonComprar.tsx
"use client"
 
import { useState } from "react"
import { getStripe } from "@/lib/stripe-client"
 
interface BotonComprarProps {
  priceId: string
  nombre: string
}
 
export function BotonComprar({ priceId, nombre }: BotonComprarProps) {
  const [cargando, setCargando] = useState(false)
 
  async function handleComprar() {
    setCargando(true)
 
    try {
      // Llamar a nuestro API Route para crear la sesion
      const response = await fetch("/api/checkout", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ priceId }),
      })
 
      const { url } = await response.json()
 
      // Redirigir a Stripe Checkout
      if (url) {
        window.location.href = url
      }
    } catch (error) {
      console.error("Error:", error)
    } finally {
      setCargando(false)
    }
  }
 
  return (
    <button
      onClick={handleComprar}
      disabled={cargando}
      className="bg-blue-600 text-white px-6 py-3 rounded-lg hover:bg-blue-700 disabled:opacity-50"
    >
      {cargando ? "Procesando..." : `Comprar ${nombre}`}
    </button>
  )
}

Paginas de resultado

Cuando el usuario completa o cancela el pago, Stripe lo redirige a las URLs que configuraste.

Pagina de pago exitoso:

typescript
// app/checkout/success/page.tsx
import { stripe } from "@/lib/stripe"
 
interface SuccessPageProps {
  searchParams: Promise<{ session_id?: string }>
}
 
export default async function SuccessPage({ searchParams }: SuccessPageProps) {
  const { session_id } = await searchParams
 
  if (!session_id) {
    return <p>Sesion no encontrada.</p>
  }
 
  // Obtener los detalles de la sesion desde Stripe
  const session = await stripe.checkout.sessions.retrieve(session_id)
 
  return (
    <div className="max-w-lg mx-auto mt-16 text-center">
      <h1 className="text-3xl font-bold mb-4">Pago exitoso</h1>
      <p className="text-gray-400 mb-2">
        Gracias por tu compra. Hemos enviado un recibo a {session.customer_details?.email}.
      </p>
      <p className="text-sm text-gray-500">
        ID de transaccion: {session.payment_intent as string}
      </p>
      <a href="/" className="mt-8 inline-block text-blue-500 hover:underline">
        Volver al inicio
      </a>
    </div>
  )
}

Pagina de pago cancelado:

typescript
// app/checkout/cancel/page.tsx
export default function CancelPage() {
  return (
    <div className="max-w-lg mx-auto mt-16 text-center">
      <h1 className="text-3xl font-bold mb-4">Pago cancelado</h1>
      <p className="text-gray-400 mb-4">
        No se realizo ningun cobro. Puedes intentar de nuevo cuando quieras.
      </p>
      <a href="/" className="text-blue-500 hover:underline">
        Volver al inicio
      </a>
    </div>
  )
}

Webhooks: sincronizar Stripe con tu base de datos

El redirect a la pagina de exito no es confiable para confirmar pagos. El usuario puede cerrar el navegador, perder conexion o simplemente no llegar a la URL. Los webhooks resuelven esto: Stripe le avisa a tu servidor directamente cuando ocurre un evento.

Como funcionan los webhooks

plaintext
1. Usuario completa el pago en Stripe Checkout
2. Stripe envia un POST a tu endpoint /api/webhooks
3. Tu servidor verifica la firma del evento
4. Procesas el evento (actualizar DB, enviar email, etc.)
5. Respondes con 200 OK

Crear el endpoint de webhooks

typescript
// app/api/webhooks/route.ts
import { NextResponse } from "next/server"
import { headers } from "next/headers"
import { stripe } from "@/lib/stripe"
import Stripe from "stripe"
 
export async function POST(request: Request) {
  const body = await request.text()
  const headersList = await headers()
  const signature = headersList.get("stripe-signature")
 
  if (!signature) {
    return NextResponse.json(
      { error: "Falta la firma de Stripe" },
      { status: 400 }
    )
  }
 
  let event: Stripe.Event
 
  try {
    // Verificar que el evento viene de Stripe
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!
    )
  } catch (error) {
    console.error("Error verificando webhook:", error)
    return NextResponse.json(
      { error: "Firma de webhook invalida" },
      { status: 400 }
    )
  }
 
  // Procesar segun el tipo de evento
  switch (event.type) {
    case "checkout.session.completed": {
      const session = event.data.object as Stripe.Checkout.Session
      // Pago exitoso: actualizar la base de datos
      await procesarPagoExitoso(session)
      break
    }
 
    case "invoice.payment_succeeded": {
      const invoice = event.data.object as Stripe.Invoice
      // Pago de suscripcion recurrente exitoso
      await procesarPagoRecurrente(invoice)
      break
    }
 
    case "customer.subscription.deleted": {
      const subscription = event.data.object as Stripe.Subscription
      // Suscripcion cancelada: revocar acceso
      await cancelarSuscripcion(subscription)
      break
    }
 
    default:
      console.log(`Evento no manejado: ${event.type}`)
  }
 
  // Siempre responder 200 para que Stripe no reintente
  return NextResponse.json({ received: true })
}
 
// Funciones auxiliares (conecta con tu base de datos)
async function procesarPagoExitoso(session: Stripe.Checkout.Session) {
  const customerEmail = session.customer_details?.email
  const customerId = session.customer as string
 
  // Aqui actualizas tu base de datos
  // Ejemplo con Prisma:
  // await prisma.user.update({
  //   where: { email: customerEmail },
  //   data: { stripeCustomerId: customerId, plan: "pro" },
  // })
 
  console.log(`Pago completado para ${customerEmail}`)
}
 
async function procesarPagoRecurrente(invoice: Stripe.Invoice) {
  const customerId = invoice.customer as string
 
  // Actualizar fecha de ultimo pago, extender acceso, etc.
  console.log(`Pago recurrente procesado para cliente ${customerId}`)
}
 
async function cancelarSuscripcion(subscription: Stripe.Subscription) {
  const customerId = subscription.customer as string
 
  // Revocar acceso premium
  // await prisma.user.update({
  //   where: { stripeCustomerId: customerId },
  //   data: { plan: "free" },
  // })
 
  console.log(`Suscripcion cancelada para cliente ${customerId}`)
}
No uses request.json() en webhooks

El webhook necesita el body como texto plano (request.text()) para verificar la firma. Si usas request.json(), la verificacion falla porque Stripe firma el string original, no el JSON parseado.

Desactivar el body parser

Next.js no necesita configuracion adicional para Route Handlers del App Router. El body se lee manualmente con request.text(), asi que no hay conflicto con el body parsing automatico.

Suscripciones recurrentes

Para SaaS el modelo mas comun son las suscripciones mensuales o anuales. La diferencia con un pago unico es minima: cambias mode: "payment" por mode: "subscription" y usas un precio recurrente.

Crear un precio recurrente en Stripe

  1. Ve a Products en el dashboard de Stripe
  2. Crea un producto o edita uno existente
  3. Agrega un precio con Recurring seleccionado
  4. Configura el intervalo (mensual, anual) y el monto
  5. Copia el Price ID (price_...)

Route Handler para suscripciones

typescript
// app/api/checkout/subscription/route.ts
import { NextResponse } from "next/server"
import { stripe } from "@/lib/stripe"
 
export async function POST(request: Request) {
  try {
    const { priceId, customerEmail } = await request.json()
 
    const session = await stripe.checkout.sessions.create({
      mode: "subscription", // Cambia de "payment" a "subscription"
      payment_method_types: ["card"],
      customer_email: customerEmail, // Pre-llenar el email del usuario
      line_items: [
        {
          price: priceId,
          quantity: 1,
        },
      ],
      success_url: `${process.env.NEXT_PUBLIC_SITE_URL}/checkout/success?session_id={CHECKOUT_SESSION_ID}`,
      cancel_url: `${process.env.NEXT_PUBLIC_SITE_URL}/pricing`,
      // Permitir que el usuario use codigos de descuento
      allow_promotion_codes: true,
      // Metadata para identificar al usuario en el webhook
      metadata: {
        userId: "user_123", // El ID de tu base de datos
      },
    })
 
    return NextResponse.json({ url: session.url })
  } catch (error) {
    console.error("Error creando checkout de suscripcion:", error)
    return NextResponse.json(
      { error: "Error al crear la sesion" },
      { status: 500 }
    )
  }
}

Tabla de precios

Un componente comun en cualquier SaaS es la pagina de pricing con diferentes planes:

typescript
// app/pricing/page.tsx
import { BotonSuscripcion } from "@/components/BotonSuscripcion"
 
const planes = [
  {
    nombre: "Free",
    precio: "$0",
    intervalo: "mes",
    caracteristicas: [
      "1 proyecto",
      "100 requests/dia",
      "Soporte por email",
    ],
    priceId: null, // Plan gratuito, sin checkout
  },
  {
    nombre: "Pro",
    precio: "$29",
    intervalo: "mes",
    caracteristicas: [
      "Proyectos ilimitados",
      "10,000 requests/dia",
      "Soporte prioritario",
      "API access",
    ],
    priceId: "price_pro_mensual_id",
    popular: true,
  },
  {
    nombre: "Enterprise",
    precio: "$99",
    intervalo: "mes",
    caracteristicas: [
      "Todo de Pro",
      "Requests ilimitados",
      "Soporte dedicado",
      "SLA 99.9%",
      "SSO / SAML",
    ],
    priceId: "price_enterprise_mensual_id",
  },
]
 
export default function PricingPage() {
  return (
    <div className="max-w-5xl mx-auto py-16 px-4">
      <h1 className="text-4xl font-bold text-center mb-12">Planes y precios</h1>
 
      <div className="grid md:grid-cols-3 gap-8">
        {planes.map((plan) => (
          <div
            key={plan.nombre}
            className={`border rounded-xl p-8 ${
              plan.popular ? "border-blue-500 ring-2 ring-blue-500" : "border-gray-700"
            }`}
          >
            {plan.popular && (
              <span className="text-blue-500 text-sm font-medium">Mas popular</span>
            )}
            <h2 className="text-2xl font-bold mt-2">{plan.nombre}</h2>
            <p className="mt-4">
              <span className="text-4xl font-bold">{plan.precio}</span>
              <span className="text-gray-400">/{plan.intervalo}</span>
            </p>
 
            <ul className="mt-8 space-y-3">
              {plan.caracteristicas.map((feat) => (
                <li key={feat} className="text-gray-300">{feat}</li>
              ))}
            </ul>
 
            <div className="mt-8">
              {plan.priceId ? (
                <BotonSuscripcion priceId={plan.priceId} nombre={plan.nombre} />
              ) : (
                <button className="w-full py-3 border border-gray-600 rounded-lg">
                  Plan actual
                </button>
              )}
            </div>
          </div>
        ))}
      </div>
    </div>
  )
}
typescript
// components/BotonSuscripcion.tsx
"use client"
 
import { useState } from "react"
 
interface BotonSuscripcionProps {
  priceId: string
  nombre: string
}
 
export function BotonSuscripcion({ priceId, nombre }: BotonSuscripcionProps) {
  const [cargando, setCargando] = useState(false)
 
  async function handleSuscribirse() {
    setCargando(true)
 
    try {
      const response = await fetch("/api/checkout/subscription", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          priceId,
          customerEmail: "usuario@ejemplo.com", // Obtener del contexto de auth
        }),
      })
 
      const { url } = await response.json()
      if (url) window.location.href = url
    } catch (error) {
      console.error("Error:", error)
    } finally {
      setCargando(false)
    }
  }
 
  return (
    <button
      onClick={handleSuscribirse}
      disabled={cargando}
      className="w-full py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
    >
      {cargando ? "Procesando..." : `Suscribirse a ${nombre}`}
    </button>
  )
}

Portal de cliente

Stripe tiene un portal donde tus usuarios pueden gestionar su suscripcion sin que tu construyas la interfaz: cambiar de plan, actualizar metodo de pago, cancelar, ver historial de facturas. Tu solo los rediriges al portal.

Activar el portal en Stripe

  1. Ve a Settings > Billing > Customer portal en el dashboard de Stripe
  2. Configura que opciones pueden ver los usuarios (cancelar, cambiar plan, etc.)
  3. Guarda la configuracion

Crear el Route Handler

typescript
// app/api/portal/route.ts
import { NextResponse } from "next/server"
import { stripe } from "@/lib/stripe"
 
export async function POST(request: Request) {
  try {
    const { customerId } = await request.json()
 
    // Crear una sesion del portal de cliente
    const portalSession = await stripe.billingPortal.sessions.create({
      customer: customerId, // El Stripe Customer ID del usuario
      return_url: `${process.env.NEXT_PUBLIC_SITE_URL}/dashboard`,
    })
 
    return NextResponse.json({ url: portalSession.url })
  } catch (error) {
    console.error("Error creando portal session:", error)
    return NextResponse.json(
      { error: "Error al abrir el portal" },
      { status: 500 }
    )
  }
}

Boton para acceder al portal

typescript
// components/BotonPortal.tsx
"use client"
 
import { useState } from "react"
 
export function BotonPortal({ customerId }: { customerId: string }) {
  const [cargando, setCargando] = useState(false)
 
  async function abrirPortal() {
    setCargando(true)
 
    const response = await fetch("/api/portal", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ customerId }),
    })
 
    const { url } = await response.json()
    if (url) window.location.href = url
 
    setCargando(false)
  }
 
  return (
    <button
      onClick={abrirPortal}
      disabled={cargando}
      className="text-blue-500 hover:underline"
    >
      {cargando ? "Abriendo portal..." : "Gestionar suscripcion"}
    </button>
  )
}
💡
Stripe Customer ID

El customerId es el ID que Stripe le asigna a cada cliente (empieza con cus_). Lo obtienes del webhook checkout.session.completed y debes guardarlo en tu base de datos asociado al usuario.

Proteger contenido por suscripcion

Una vez que sabes que usuarios tienen una suscripcion activa, puedes proteger rutas y contenido.

Verificar suscripcion en Server Components

typescript
// lib/subscription.ts
import { stripe } from "@/lib/stripe"
 
export async function obtenerSuscripcion(customerId: string) {
  if (!customerId) return null
 
  const subscriptions = await stripe.subscriptions.list({
    customer: customerId,
    status: "active",
    limit: 1,
  })
 
  return subscriptions.data[0] || null
}
 
export async function estaActiva(customerId: string): Promise<boolean> {
  const suscripcion = await obtenerSuscripcion(customerId)
  return suscripcion !== null
}
typescript
// app/dashboard/pro/page.tsx
import { redirect } from "next/navigation"
import { estaActiva } from "@/lib/subscription"
 
export default async function ProPage() {
  // Obtener el customerId del usuario autenticado
  // (depende de tu sistema de auth)
  const customerId = "cus_..." // Obtener de tu DB
 
  const tieneSuscripcion = await estaActiva(customerId)
 
  if (!tieneSuscripcion) {
    redirect("/pricing")
  }
 
  return (
    <div>
      <h1>Contenido Pro</h1>
      <p>Solo los usuarios con suscripcion activa pueden ver esta pagina.</p>
    </div>
  )
}

Proteger con middleware

Si tienes muchas rutas protegidas, puedes verificar la suscripcion en el middleware. Pero ten cuidado: llamar a la API de Stripe en cada request agrega latencia. Es mejor guardar el estado de la suscripcion en tu base de datos (actualizada via webhooks) y consultarla desde el middleware.

typescript
// middleware.ts
import { NextResponse } from "next/server"
import type { NextRequest } from "next/server"
 
export function middleware(request: NextRequest) {
  const rutasProtegidas = ["/dashboard/pro", "/dashboard/analytics"]
  const ruta = request.nextUrl.pathname
 
  if (rutasProtegidas.some((r) => ruta.startsWith(r))) {
    // Verificar la cookie o token de suscripcion
    const plan = request.cookies.get("user_plan")?.value
 
    if (plan !== "pro" && plan !== "enterprise") {
      return NextResponse.redirect(new URL("/pricing", request.url))
    }
  }
 
  return NextResponse.next()
}
 
export const config = {
  matcher: ["/dashboard/:path*"],
}

Si usas un sistema de autenticacion como Auth.js, puedes combinar la verificacion de sesion con el estado de la suscripcion. Revisa la guia de autenticacion con Auth.js v5 para ver como integrar ambos flujos.

ℹ️
Base de datos como fuente de verdad

No consultes la API de Stripe en cada request para verificar suscripciones. Usa los webhooks para mantener tu base de datos actualizada y consulta tu DB directamente. Es mas rapido, confiable y no dependes de la disponibilidad de Stripe.

Testing: probar pagos en local

Stripe tiene herramientas para probar todo sin cobrar dinero real.

Tarjetas de prueba

Usa estas tarjetas en el formulario de Stripe Checkout durante las pruebas:

TarjetaResultado
4242 4242 4242 4242Pago exitoso
4000 0000 0000 3220Requiere 3D Secure
4000 0000 0000 9995Pago rechazado (fondos insuficientes)
4000 0000 0000 0002Tarjeta rechazada

Para todos los casos usa cualquier fecha futura como expiracion y cualquier CVC de 3 digitos.

Stripe CLI para webhooks locales

En produccion, Stripe envia webhooks a tu URL publica. Para desarrollo local, necesitas el Stripe CLI:

bash
# Instalar en macOS
brew install stripe/stripe-cli/stripe
 
# Iniciar sesion
stripe login
 
# Reenviar webhooks a tu servidor local
stripe listen --forward-to localhost:3000/api/webhooks

El CLI te muestra un webhook signing secret temporal (whsec_...). Usalo como STRIPE_WEBHOOK_SECRET en tu .env.local.

bash

Probar un flujo completo

  1. Levanta tu servidor con npm run dev
  2. En otra terminal, ejecuta stripe listen --forward-to localhost:3000/api/webhooks
  3. Haz click en el boton de compra en tu app
  4. Usa la tarjeta 4242 4242 4242 4242 en Stripe Checkout
  5. Verifica que el webhook llego en la terminal del Stripe CLI
  6. Confirma que tu base de datos se actualizo
💡
Disparar eventos manualmente

Puedes disparar eventos de prueba desde la CLI sin hacer el flujo completo: stripe trigger checkout.session.completed. Esto es util para probar webhooks individuales.

Checklist para produccion

Antes de hacer deploy a Vercel con pagos reales, revisa esta lista:

Claves y configuracion

  • Reemplazar sk_test_ por sk_live_ en las variables de entorno de produccion
  • Reemplazar pk_test_ por pk_live_
  • Configurar el webhook endpoint en Stripe Dashboard (Developers > Webhooks > Add endpoint)
  • URL del webhook: https://tudominio.com/api/webhooks
  • Seleccionar los eventos que quieres recibir (checkout.session.completed, invoice.payment_succeeded, customer.subscription.deleted)
  • Copiar el webhook signing secret de produccion a las variables de entorno de Vercel

Seguridad

  • Verificar que STRIPE_SECRET_KEY NO tiene el prefijo NEXT_PUBLIC_
  • El endpoint de webhooks verifica la firma con constructEvent()
  • Los Route Handlers validan los datos de entrada
  • El portal de cliente requiere autenticacion previa
  • Si manejas datos sensibles de usuarios, revisa la guia de seguridad para aplicaciones Next.js

Manejo de errores

  • Los Route Handlers tienen bloques try/catch
  • El webhook siempre responde 200 (si respondes con error, Stripe reintenta hasta 3 dias)
  • Los errores se loguean para debugging
  • El usuario ve mensajes claros si algo falla

Suscripciones

  • Los webhooks actualizan la base de datos (no dependes solo del redirect)
  • Manejas el evento customer.subscription.deleted para revocar acceso
  • Manejas invoice.payment_failed para notificar al usuario
  • El portal de cliente esta configurado y accesible
typescript
// Ejemplo: manejar pago fallido de suscripcion
case "invoice.payment_failed": {
  const invoice = event.data.object as Stripe.Invoice
  const customerId = invoice.customer as string
 
  // Notificar al usuario que su pago fallo
  // await enviarEmailPagoFallido(customerId)
 
  // Opcional: degradar el plan despues de X intentos
  console.log(`Pago fallido para cliente ${customerId}`)
  break
}

Preguntas frecuentes

Puedo cobrar en pesos mexicanos o moneda local?

Si. Al crear el precio en Stripe Dashboard, selecciona la moneda que quieras (MXN, COP, BRL, etc.). Stripe maneja la conversion si tu cuenta esta en otra moneda. Tambien puedes crear multiples precios para el mismo producto en diferentes monedas.

Que pasa si mi webhook falla?

Stripe reintenta los webhooks con backoff exponencial durante hasta 3 dias. Si tu servidor estaba temporalmente caido, Stripe eventualmente entregara el evento. Para webhooks criticos, implementa idempotencia: verifica que no proceses el mismo evento dos veces.

typescript
// Verificar idempotencia con el ID del evento
const eventoYaProcesado = await db.stripeEvent.findUnique({
  where: { stripeEventId: event.id },
})
 
if (eventoYaProcesado) {
  return NextResponse.json({ received: true }) // Ya se proceso, ignorar
}
 
// Procesar y guardar el ID
await db.stripeEvent.create({
  data: { stripeEventId: event.id, type: event.type },
})

Necesito PCI compliance para usar Stripe Checkout?

No. Stripe Checkout se aloja en los servidores de Stripe, asi que tu nunca manejas datos de tarjetas directamente. Stripe se encarga del PCI compliance. Esa es una de las principales ventajas de usar Checkout en lugar de un formulario custom con Stripe Elements.

Puedo ofrecer periodos de prueba gratuitos?

Si. Agrega subscription_data.trial_period_days a la sesion de checkout:

typescript
const session = await stripe.checkout.sessions.create({
  mode: "subscription",
  line_items: [{ price: priceId, quantity: 1 }],
  subscription_data: {
    trial_period_days: 14, // 14 dias de prueba
  },
  success_url: "...",
  cancel_url: "...",
})

El usuario ingresa su tarjeta pero no se le cobra hasta que termine el periodo de prueba.

Conclusion

Integrar Stripe con Next.js se reduce a cuatro piezas fundamentales:

  1. Checkout Session: crea una sesion de pago (unico o suscripcion) y redirige al usuario
  2. Webhooks: recibe notificaciones de Stripe para actualizar tu base de datos
  3. Portal de cliente: deja que Stripe maneje la interfaz de gestion de suscripciones
  4. Verificacion de acceso: consulta tu base de datos (actualizada via webhooks) para proteger contenido

La arquitectura es la misma para un producto de $5 que para un SaaS enterprise. Lo que cambia es la complejidad de la logica de negocio en los webhooks.


Recursos adicionales

#stripe#nextjs#pagos#suscripciones#webhooks#typescript

Preguntas frecuentes

Stripe cobra comision por transaccion?

Si. Stripe cobra 2.9% + 30 centavos USD por transaccion exitosa en la mayoria de paises de LATAM. No hay costo mensual fijo, solo pagas por las transacciones que procesas.

Puedo usar Stripe en LATAM?

Si. Stripe opera en Mexico, Brasil, Colombia y otros paises de LATAM. Los cobros pueden ser en moneda local o USD. Consulta stripe.com/global para ver disponibilidad en tu pais.

Que es un webhook de Stripe y por que lo necesito?

Un webhook es un endpoint en tu servidor que Stripe llama automaticamente cuando ocurre un evento (pago exitoso, suscripcion cancelada, etc.). Lo necesitas para actualizar tu base de datos y mantener sincronizado el estado de pagos.

Stripe o MercadoPago para LATAM?

Depende de tu audiencia. MercadoPago tiene mejor adopcion en Argentina, Brasil y Mexico para consumidores locales. Stripe es mejor para SaaS y productos digitales con clientes internacionales. Muchos proyectos integran ambos.

Como pruebo pagos sin usar dinero real?

Stripe tiene un modo de pruebas con tarjetas de test (4242 4242 4242 4242). Todo funciona igual que en produccion pero sin cobrar. Usa la clave sk_test_ para pruebas y sk_live_ para produccion.