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
Ve a dashboard.stripe.com y crea una cuenta
El registro es gratuito. No necesitas verificar identidad hasta que quieras cobrar en producción.
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
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.
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 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:
// 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 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:
// 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:
- 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 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:
// 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:
// 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
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 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
- 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 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:
// 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>
)
}// 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
- Ve a Settings > Billing > Customer portal en el dashboard de Stripe
- Configura que opciones pueden ver los usuarios (cancelar, cambiar plan, etc.)
- Guarda la configuración
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 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
// 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
// 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
}// 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.
// 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:
| 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 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:
# 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/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 ú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_porsk_live_en las variables de entorno de producción - 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 producción 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 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.deletedpara revocar acceso - Manejas
invoice.payment_failedpara notificar al usuario - El portal de cliente esta configurado y accesible
// 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.
// 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:
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:
- Checkout Session: crea una sesión de pago (único o suscripción) 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
- 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
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.
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. Verificación de firmas, tipado y manejo de errores.