Una visita a /dashboard?checkout=success no demuestra que un pago se haya completado: cualquiera puede escribir esa URL. En una integración de suscripciones con Stripe, el cambio de plan debe llegar al servidor mediante un webhook verificado; después, la app consulta su base de datos para decidir qué funciones puede usar la persona.
El flujo de este ejemplo usa TypeScript, Route Handlers de Next.js, stripe-node, Prisma con Postgres y una función de autenticación del lado servidor llamada auth(). El SDK oficial de Stripe para Node.js se instala con npm y necesita una clave secreta; Next.js permite recibir estas notificaciones con Route Handlers públicos. El código supone que ya existen @/auth y @/lib/db en el proyecto.
flowchart TD A["Usuario inicia Checkout"] --> B["Servidor crea sesión"] B --> C["Stripe completa el flujo"] C --> D["Webhook verifica evento"] D --> E["Base de datos sincroniza plan"] E --> F["Servidor comprueba acceso"]
Preparar Stripe y el modelo de datos
Instala el SDK y el paquete server-only para marcar el módulo que contiene la clave secreta. En Stripe, crea un producto con un precio recurrente y guarda su identificador price_... en el entorno del servidor. El navegador no elegirá el precio: el Route Handler lo obtiene de una variable de entorno.
npm install stripe server-only
// lib/stripe.ts
import "server-only";
import Stripe from "stripe";
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
El ejemplo registra en el usuario el cliente de Stripe, la suscripción, el precio y su estado. Una segunda tabla guarda los identificadores de eventos ya recibidos, con una clave única que permite detectar entregas repetidas.
model User {
id String @id @default(cuid())
email String @unique
stripeCustomerId String? @unique
stripeSubscriptionId String? @unique
stripePriceId String?
subscriptionStatus String?
}
model StripeEvent {
id String @id
type String
createdAt DateTime @default(now())
}
Configura en el entorno del servidor STRIPE_SECRET_KEY, STRIPE_PRICE_PRO y APP_URL. El secreto de firma del webhook se añadirá cuando se configure el endpoint. El precio lo decide el servidor: no aceptes del cliente un importe o identificador de precio que determine cuánto se cobra.
Crear la sesión de Checkout
El navegador solo necesita llamar a este endpoint y redirigir a la URL devuelta. El handler comprueba la sesión autenticada, reutiliza el cliente de Stripe asociado al usuario o crea uno nuevo, y construye una sesión en modo suscripción. Añadir el ID del usuario a los metadatos de la suscripción permite relacionar después los eventos con la cuenta.
// app/api/checkout/route.ts
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { stripe } from "@/lib/stripe";
export async function POST() {
const session = await auth();
const userId = session?.user?.id;
if (!userId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const user = await db.user.findUniqueOrThrow({ where: { id: userId } });
let customerId = user.stripeCustomerId;
if (!customerId) {
const customer = await stripe.customers.create({
email: user.email,
metadata: { userId: user.id },
});
customerId = customer.id;
await db.user.update({
where: { id: user.id },
data: { stripeCustomerId: customerId },
});
}
const checkout = await stripe.checkout.sessions.create({
mode: "subscription",
customer: customerId,
client_reference_id: user.id,
line_items: [{ price: process.env.STRIPE_PRICE_PRO!, quantity: 1 }],
subscription_data: { metadata: { userId: user.id } },
success_url: `${process.env.APP_URL}/dashboard?checkout=success`,
cancel_url: `${process.env.APP_URL}/pricing`,
});
return NextResponse.json({ url: checkout.url });
}
En la interfaz, un botón puede enviar un POST a /api/checkout y navegar a url. La pantalla de éxito sirve para mostrar una confirmación de navegación, no para conceder acceso: esa autorización depende del estado que actualice el webhook.
Verificar eventos y sincronizar la suscripción
Stripe firma los bytes exactos del cuerpo de la petición. Lee el cuerpo con req.text() y pásalo a constructEvent antes de tratar el contenido como confiable. Verifica el cuerpo sin parsearlo: leerlo como JSON y serializarlo de nuevo puede cambiar los bytes y hacer fallar la firma. Esta ruta usa el runtime de Node.js y responde con error si la firma es inválida.
// app/api/stripe/webhook/route.ts
import { Prisma } from "@prisma/client";
import Stripe from "stripe";
import { db } from "@/lib/db";
import { stripe } from "@/lib/stripe";
import { syncSubscription } from "@/lib/billing";
export const runtime = "nodejs";
export async function POST(req: Request) {
const body = await req.text();
const signature = req.headers.get("stripe-signature");
if (!signature) return new Response("Missing signature", { status: 400 });
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch {
return new Response("Invalid signature", { status: 400 });
}
try {
await db.stripeEvent.create({ data: { id: event.id, type: event.type } });
} catch (error) {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === "P2002"
) {
return new Response("Already processed", { status: 200 });
}
throw error;
}
try {
if (event.type === "checkout.session.completed") {
const checkout = event.data.object as Stripe.Checkout.Session;
if (checkout.mode === "subscription" && checkout.subscription) {
const subscriptionId = typeof checkout.subscription === "string"
? checkout.subscription
: checkout.subscription.id;
await syncSubscription(subscriptionId);
}
}
if (
event.type === "customer.subscription.created" ||
event.type === "customer.subscription.updated" ||
event.type === "customer.subscription.deleted"
) {
const subscription = event.data.object as Stripe.Subscription;
await syncSubscription(subscription.id);
}
} catch {
await db.stripeEvent.delete({ where: { id: event.id } }).catch(() => {});
return new Response("Handler failed", { status: 500 });
}
return new Response("ok", { status: 200 });
}
La tabla de eventos evita procesar dos veces el mismo ID. Si la sincronización falla, el handler borra el registro de deduplicación y responde con un error para que Stripe pueda reintentar. Para sincronizar, consulta la suscripción actual en Stripe en vez de reconstruir el estado aplicando eventos uno a uno: pueden llegar fuera de orden.
// lib/billing.ts
import "server-only";
import { db } from "@/lib/db";
import { stripe } from "@/lib/stripe";
export async function syncSubscription(subscriptionId: string) {
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
const item = subscription.items.data[0];
const customerId = typeof subscription.customer === "string"
? subscription.customer
: subscription.customer.id;
await db.user.update({
where: { stripeCustomerId: customerId },
data: {
stripeSubscriptionId: subscription.id,
stripePriceId: item?.price.id ?? null,
subscriptionStatus: subscription.status,
},
});
}
Esta sincronización guarda el estado y el precio del primer elemento de la suscripción. Los eventos de Checkout y de suscripción mantienen esos datos al día cuando se crea, actualiza o elimina una suscripción. Los eventos repetidos no duplican cambios porque la tabla usa el ID del evento como clave primaria.
Restringir funciones Pro y añadir el portal
Centraliza la regla de acceso. Aquí solo se consideran Pro las suscripciones con estado active o trialing y cuyo precio coincide con el configurado. El tratamiento de past_due queda fuera de esa regla; si la aplicación debe conceder un periodo de gracia, el criterio se cambia aquí, no en componentes aislados.
// lib/plan.ts
const proPrices = new Set([process.env.STRIPE_PRICE_PRO!]);
type PlanUser = {
subscriptionStatus: string | null;
stripePriceId: string | null;
};
export function isPro(user: PlanUser) {
return (
(user.subscriptionStatus === "active" ||
user.subscriptionStatus === "trialing") &&
!!user.stripePriceId &&
proPrices.has(user.stripePriceId)
);
}
Haz la comprobación en el servidor, junto al recurso protegido. Ocultar un botón en un componente cliente no bloquea una petición directa a la página o a una API.
// app/dashboard/reports/page.tsx
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { isPro } from "@/lib/plan";
export default async function ReportsPage() {
const session = await auth();
const userId = session?.user?.id;
if (!userId) redirect("/login");
const user = await db.user.findUniqueOrThrow({ where: { id: userId } });
if (!isPro(user)) redirect("/pricing");
return <main>Informes para {user.email}</main>;
}
Aplica la misma condición a las Server Actions y los Route Handlers que realicen trabajo de pago. El middleware puede ayudar con redirecciones, pero no debe ser la única comprobación de acceso: verifica permisos donde se lee o modifica el recurso.
Para que cada cliente gestione su suscripción, crea una sesión del portal desde el servidor. Activa las funciones necesarias en la configuración del Customer Portal de Stripe; las modificaciones del cliente volverán a sincronizarse mediante los eventos que ya procesa el webhook.
// app/api/portal/route.ts
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { stripe } from "@/lib/stripe";
export async function POST() {
const session = await auth();
const userId = session?.user?.id;
if (!userId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const user = await db.user.findUniqueOrThrow({ where: { id: userId } });
if (!user.stripeCustomerId) {
return NextResponse.json({ error: "No customer" }, { status: 400 });
}
const portal = await stripe.billingPortal.sessions.create({
customer: user.stripeCustomerId,
return_url: `${process.env.APP_URL}/dashboard/billing`,
});
return NextResponse.json({ url: portal.url });
}
Prueba local y errores habituales
Para probar los webhooks en local, inicia sesión con Stripe CLI y reenvía los eventos a la ruta. El CLI proporciona un secreto de firma propio: usa ese valor localmente, no el del endpoint de pruebas o producción. Cada endpoint tiene su secreto y las variables de entorno de Vercel para Preview y Production deben mantenerse separadas.
stripe login
stripe listen --forward-to localhost:3000/api/stripe/webhook
# En otra terminal
stripe trigger customer.subscription.updated
Un recorrido de prueba puede completar Checkout en modo de pruebas, comprobar que cambió la fila del usuario, abrir el portal y cancelar. También conviene repetir un evento desde el Dashboard y comprobar que su ID no se procesa por segunda vez. Stripe recomienda el cuerpo sin parsear para verificar firmas; Next.js documenta que el cuerpo de una petición solo puede leerse una vez.
Si el webhook no llega, revisa que la autenticación global no redirija esa ruta a la página de inicio de sesión y que no haya redirecciones de URL: Stripe no sigue redirecciones de webhook. Si la firma falla, comprueba que el secreto corresponda al entorno. Y si el acceso se concede desde la URL de éxito o solo se oculta en el cliente, traslada la decisión al webhook y a la comprobación del servidor.
Con estos componentes, Stripe recibe la solicitud de suscripción, el webhook confirma y sincroniza el estado, y Next.js aplica el permiso al servir la función protegida. La pantalla de éxito informa al usuario; la base de datos y la comprobación del servidor determinan el acceso.
Fuentes: DEV Community, Vercel Academy, GitHub, Next.js