How do I handle Stripe and Lemon Squeezy webhooks idempotently in Next.js?
It depends on your billing providers, but the core architecture is identical: verify the raw payload signature before parsing JSON, normalize provider events into a shared internal contract, and enforce idempotency at the database boundary using the provider's event or order ID. Never grant product entitlements from a client-side checkout redirect; let the verified webhook transactionally update subscription state and entitlements once. supastack prewires this pipeline for Stripe, Lemon Squeezy, and mobile RevenueCat, though single-plan apps can start with a simpler handler.
Verifica el payload en bruto antes de tocar el JSON
Cada proveedor de pagos firma la secuencia exacta de bytes que envía por la red. Si un controlador de ruta analiza await request.json() y vuelve a serializar el objeto antes de comprobar la firma, las diferencias de orden de claves o espacios invalidarán webhooks legítimos o tentarán al equipo a desactivar la verificación en staging.
Lee el cuerpo en bruto una sola vez con await request.text() en tu controlador POST del App Router de Next.js, verifica la firma criptográfica con el secreto de servidor y rechaza cualquier fallo con HTTP 400 antes de ejecutar una sola consulta. La guía del módulo de facturación muestra cómo supastack aísla la verificación de firmas detrás de un único punto de entrada BFF.
export async function POST(request: Request) {
const rawBody = await request.text();
const signature = request.headers.get("stripe-signature");
if (!signature) return new Response("Missing signature", { status: 400 });
const event = stripe.webhooks.constructEvent(
rawBody,
signature,
env.STRIPE_WEBHOOK_SECRET,
);
await handleNormalizedBillingEvent(event);
return Response.json({ received: true });
}- Lee el cuerpo de la petición con
request.text(), nunca vuelvas a serializar un JSON ya analizado. - Mantén
STRIPE_WEBHOOK_SECRETy los secretos de firma de Lemon Squeezy exclusivamente en la configuración del servidor. - Usa comparación en tiempo constante (
crypto.timingSafeEqual) al verificar firmas HMAC manualmente.
Deduplica en la frontera de la base de datos, no en memoria
Stripe y Lemon Squeezy reintentan los webhooks cada vez que un despliegue se reinicia, una función serverless agota el tiempo o un salto de red pierde la respuesta. Los conjuntos en memoria o las claves de caché efímeras desaparecen entre instancias y no impiden que dos reintentos simultáneos otorguen licencias duplicadas o envíen dos correos de recibo.
Registra el ID de evento del proveedor o el estado de liquidación del pedido dentro de la misma transacción de Postgres que modifica el acceso de la suscripción. Un INSERT ... ON CONFLICT DO NOTHING atómico o una actualización condicional sobre status != 'paid' convierte una repetición concurrente en una operación sin efecto. Al diseñar los planes que esos webhooks activan, el generador de páginas de precios SaaS ayuda a modelar niveles y reglas por asiento desde el principio.
const settled = await db.transaction(async (tx) => {
const [inserted] = await tx
.insert(billingWebhookEvents)
.values({ providerEventId: event.id, provider: "stripe" })
.onConflictDoNothing({ target: billingWebhookEvents.providerEventId })
.returning({ id: billingWebhookEvents.id });
if (!inserted) return false; // Already processed
await applyWorkspaceEntitlement(tx, event);
return true;
});Gestiona eventos fuera de orden y suscripciones solapadas
No hay garantía de que los webhooks lleguen en el orden en que el usuario hizo clic. customer.subscription.updated puede aterrizar antes que checkout.session.completed, y un cliente que abre dos pestañas de pago por error puede tener brevemente dos suscripciones en el mismo workspace.
Vincula las cancelaciones y los fallos de pago al ID de suscripción específico que originó el evento. Antes de revocar el derecho de pago de un workspace, comprueba si otra suscripción activa sigue cubriendo ese workspace; de lo contrario, cancelar una suscripción duplicada elimina en silencio el acceso por el que el cliente sigue pagando.
- Indexa el estado de la suscripción por
providerSubscriptionId, no solo porworkspaceId. - Mantén activo el derecho de pago del workspace mientras al menos una suscripción esté
activeotrialing. - Verifica que el importe pagado y la divisa coincidan con el SKU esperado antes de completar pedidos de pago único.
Preguntas frecuentes sobre webhooks de facturación SaaS
Estas preguntas aparecen cada vez que un equipo pasa la facturación de las pruebas en sandbox al tráfico real de producción.
- ¿Puedo desbloquear funciones de pago en la redirección de éxito del checkout? — No. Las URLs de redirección pueden guardarse en favoritos, compartirse o visitarse antes de que se liquiden métodos de pago asíncronos. Usa la página de éxito para consultar el estado del servidor mientras el webhook verificado otorga el acceso.
- ¿Qué código de estado debe devolver un webhook duplicado? — Devuelve
200 OKen cuanto tu base de datos confirme que el evento ya fue procesado. Devolver4xxo5xxle indica al proveedor que siga reintentando. - ¿Cómo pruebo los reintentos de webhooks en local? — Escribe pruebas unitarias deterministas que envíen exactamente el mismo payload firmado dos veces y comprueba que solo se genere una licencia, un derecho o un correo transaccional.
- ¿Cómo encajan las compras integradas móviles en el mismo modelo? — Dirige las notificaciones de servidor de RevenueCat hacia la misma tabla idempotente de derechos para que las compras web y móviles nunca se sobrescriban entre sí; consulta la lista de verificación de lanzamiento para las comprobaciones previas al estreno.