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 aplicación que necesite cobrar dinero, Stripe con Next.js es la combinación que vas a encontrar en la mayoria de proyectos serios. Este tutorial de Stripe con Next.js cubre desde un pago único hasta suscripciones recurrentes, webhooks para sincronizar tu base de datos, y el portal de cliente para que tus usuarios gestionen su plan.

Todo el código funciona con el App Router de Next.js, TypeScript y la API más reciente de Stripe. No vamos a hacer un formulario de tarjeta custom. Vamos a usar Stripe Checkout, qué es la forma recomendada por Stripe porque ellos manejan PCI compliance, validación de tarjetas y la interfaz de pago por ti.

Configurar Stripe y el proyecto

Antes de escribir código 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 producción.

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

Asegúrate 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 código. Si sospechas que se filtro, herramientas como datahogo escanean tu repo automáticamente 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 guía 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 sesión 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 único con Stripe Checkout

El caso más simple: un usuario hace click en "Comprar", Stripe lo redirige a una página de pago segura, y después vuelve a tu aplicación.

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 sesión de checkout
    const session = await stripe.checkout.sessions.create({
      mode: "payment", // Pago único
      payment_method_types: ["card"],
      line_items: [
        {
          price: priceId, // ID del precio creado en Stripe Dashboard
          quantity: 1,
        },
      ],
      // URLs de redirección después 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 sesión de pago" },
      { status: 500 }
    )
  }
}

El {CHECKOUT_SESSION_ID} es un placeholder que Stripe reemplaza automáticamente con el ID real de la sesión 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 sesión
      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>
  )
}

páginas de resultado

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

página 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>sesión no encontrada.</p>
  }
 
  // Obtener los detalles de la sesión 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 transacción: {session.payment_intent as string}
      </p>
      <a href="/" className="mt-8 inline-block text-blue-500 hover:underline">
        Volver al inicio
      </a>
    </div>
  )
}

página 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 ningún 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 página de éxito no es confiable para confirmar pagos. El usuario puede cerrar el navegador, perder conexión 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 según 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 suscripción recurrente exitoso
      await procesarPagoRecurrente(invoice)
      break
    }
 
    case "customer.subscription.deleted": {
      const subscription = event.data.object as Stripe.Subscription
      // Suscripción cancelada: revocar acceso
      await cancelarSuscripción(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
 
  // aquí 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 último pago, extender acceso, etc.
  console.log(`Pago recurrente procesado para cliente ${customerId}`)
}
 
async function cancelarSuscripción(subscription: Stripe.Subscription) {
  const customerId = subscription.customer as string
 
  // Revocar acceso premium
  // await prisma.user.update({
  //   where: { stripeCustomerId: customerId },
  //   data: { plan: "free" },
  // })
 
  console.log(`Suscripción 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 verificación falla porque Stripe firma el string original, no el JSON parseado.

Desactivar el body parser

Next.js no necesita configuración adicional para Route Handlers del App Router. El body se lee manualmente con request.text(), así que no hay conflicto con el body parsing automático.

Suscripciones recurrentes

Para SaaS el modelo más común son las suscripciones mensuales o anuales. La diferencia con un pago único es mínima: 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 suscripción:", error)
    return NextResponse.json(
      { error: "Error al crear la sesión" },
      { status: 500 }
    )
  }
}

Tabla de precios

Un componente común en cualquier SaaS es la página de pricing con diferentes planes:

typescript
// app/pricing/page.tsx
import { BotonSuscripción } from "@/components/BotonSuscripción"
 
const planes = [
  {
    nombre: "Free",
    precio: "$0",
    intervalo: "mes",
    caracteristicas: [
      "1 proyecto",
      "100 requests/día",
      "Soporte por email",
    ],
    priceId: null, // Plan gratuito, sin checkout
  },
  {
    nombre: "Pro",
    precio: "$29",
    intervalo: "mes",
    caracteristicas: [
      "Proyectos ilimitados",
      "10,000 requests/día",
      "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 ? (
                <BotonSuscripción 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/BotonSuscripción.tsx
"use client"
 
import { useState } from "react"
 
interface BotonSuscripcionProps {
  priceId: string
  nombre: string
}
 
export function BotonSuscripción({ 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 suscripción sin que tu construyas la interfaz: cambiar de plan, actualizar método 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 configuración

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 sesión 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 suscripción"}
    </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 suscripción

Una vez que sabes que usuarios tienen una suscripción activa, puedes proteger rutas y contenido.

Verificar suscripción en Server Components

typescript
// lib/subscription.ts
import { stripe } from "@/lib/stripe"
 
export async function obtenerSuscripción(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 suscripción = await obtenerSuscripción(customerId)
  return suscripción !== 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 tieneSuscripción = await estaActiva(customerId)
 
  if (!tieneSuscripción) {
    redirect("/pricing")
  }
 
  return (
    <div>
      <h1>Contenido Pro</h1>
      <p>Solo los usuarios con suscripción activa pueden ver esta página.</p>
    </div>
  )
}

Proteger con middleware

Si tienes muchas rutas protegidas, puedes verificar la suscripción en el middleware. Pero ten cuidado: llamar a la API de Stripe en cada request agrega latencia. Es mejor guardar el estado de la suscripción 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 suscripción
    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 autenticación como Auth.js, puedes combinar la verificación de sesión con el estado de la suscripción. Revisa la guía de autenticación 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 más rápido, 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 expiración y cualquier CVC de 3 digitos.

Stripe CLI para webhooks locales

En producción, Stripe envia webhooks a tu URL pública. Para desarrollo local, necesitas el Stripe CLI:

bash
# Instalar en macOS
brew install stripe/stripe-cli/stripe
 
# Iniciar sesión
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 útil para probar webhooks individuales.

Checklist para producción

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

Claves y configuración

  • Reemplazar sk_test_ por sk_live_ en las variables de entorno de producción
  • 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 producción 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 autenticación previa
  • Si manejas datos sensibles de usuarios, revisa la guía 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 días)
  • 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 suscripción
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 después 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. también puedes crear múltiples precios para el mismo producto en diferentes monedas.

¿Qué pasa si mi webhook falla?

Stripe reintenta los webhooks con backoff exponencial durante hasta 3 días. Si tu servidor estaba temporalmente caido, Stripe eventualmente entregara el evento. Para webhooks críticos, 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, así 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 períodos de prueba gratuitos?

Si. Agrega subscription_data.trial_period_days a la sesión 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 días de prueba
  },
  success_url: "...",
  cancel_url: "...",
})

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

Conclusion

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

  1. Checkout Session: crea una sesión de pago (único o suscripción) 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. Verificación 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 lógica de negocio en los webhooks.


Recursos adicionales

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

Preguntas frecuentes

¿Stripe cobra comision por transacción?

Si. Stripe cobra 2.9% + 30 centavos USD por transacción 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.

¿Qué es un webhook de Stripe y por qué lo necesito?

Un webhook es un endpoint en tu servidor que Stripe llama automáticamente cuando ocurre un evento (pago exitoso, suscripción 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 adopción en Argentina, Brasil y Mexico para consumidores locales. Stripe es mejor para SaaS y productos digitales con clientes internacionales. Muchos proyectos integran ambos.

¿Cómo 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 producción pero sin cobrar. Usa la clave sk_test_ para pruebas y sk_live_ para producción.