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
Ve a dashboard.stripe.com y crea una cuenta
El registro es gratuito. No necesitas verificar identidad hasta que quieras cobrar en produccion.
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
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.
npm install stripe @stripe/stripe-jsVariables de entorno
Configura las claves en tu archivo .env.local:
# .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_aquiNunca 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:
// 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:
// 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:
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:
// 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:
- Ve a Products en el dashboard de Stripe
- Click en Add product
- Nombre: "Plan Pro" (o el nombre de tu producto)
- Precio: 29.00 USD (o la cantidad que quieras)
- Tipo de pago: One time
- Guarda y copia el Price ID (empieza con
price_)
Boton de compra en el frontend
// 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:
// 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:
// 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
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 OKCrear el endpoint de webhooks
// 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
- Ve a Products en el dashboard de Stripe
- Crea un producto o edita uno existente
- Agrega un precio con Recurring seleccionado
- Configura el intervalo (mensual, anual) y el monto
- Copia el Price ID (
price_...)
Route Handler para suscripciones
// 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:
// 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>
)
}// 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
- Ve a Settings > Billing > Customer portal en el dashboard de Stripe
- Configura que opciones pueden ver los usuarios (cancelar, cambiar plan, etc.)
- Guarda la configuracion
Crear el Route Handler
// 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
// 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
// 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
}// 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.
// 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:
| Tarjeta | Resultado |
|---|---|
4242 4242 4242 4242 | Pago exitoso |
4000 0000 0000 3220 | Requiere 3D Secure |
4000 0000 0000 9995 | Pago rechazado (fondos insuficientes) |
4000 0000 0000 0002 | Tarjeta 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:
# 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/webhooksEl CLI te muestra un webhook signing secret temporal (whsec_...). Usalo como STRIPE_WEBHOOK_SECRET en tu .env.local.
Probar un flujo completo
- Levanta tu servidor con
npm run dev - En otra terminal, ejecuta
stripe listen --forward-to localhost:3000/api/webhooks - Haz click en el boton de compra en tu app
- Usa la tarjeta
4242 4242 4242 4242en Stripe Checkout - Verifica que el webhook llego en la terminal del Stripe CLI
- 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_porsk_live_en las variables de entorno de produccion - Reemplazar
pk_test_porpk_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_KEYNO tiene el prefijoNEXT_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.deletedpara revocar acceso - Manejas
invoice.payment_failedpara notificar al usuario - El portal de cliente esta configurado y accesible
// 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.
// 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:
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:
- Checkout Session: crea una sesion de pago (unico o suscripcion) y redirige al usuario
- Webhooks: recibe notificaciones de Stripe para actualizar tu base de datos
- Portal de cliente: deja que Stripe maneje la interfaz de gestion de suscripciones
- 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
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.
Articulos relacionados
Zod Avanzado: Discriminated Unions, Transforms y Pipes
Patrones avanzados de Zod: discriminated unions, transforms, pipes, preprocess, y como validar datos complejos en TypeScript con schemas reutilizables.
tRPC + Next.js: APIs Type-Safe sin REST
Implementa tRPC en Next.js para APIs 100% type-safe. Sin schemas de API, sin fetch manual, sin types duplicados. End-to-end type safety con TypeScript.
Webhooks en Next.js: Recibe y Procesa Eventos
Implementa webhooks en Next.js para recibir eventos de Stripe, GitHub, Clerk y otros servicios. Verificacion de firmas, tipado y manejo de errores.