
Webhooks de Stripe desordenados: cómo arreglar la suscripción "activa pero incompleta"
Stripe no garantiza el orden de entrega de los webhooks: customer.subscription.updated puede llegar antes que .created y dejar tu base atorada en "incomplete" aunque Stripe diga "active". No confíes en el payload ni intentes ordenar los eventos. En cada evento: verifica la firma, deduplica por event.id, re-consulta la Subscription viva desde la API, descarta eventos más viejos que tu event.created guardado, haz upsert idempotente y reconcilia con un cron.
El bug: una suscripción atorada en “incomplete” aunque Stripe diga “active”
Te cuento el síntoma exacto, porque seguro ya lo viste: un cliente paga, en el dashboard de Stripe la suscripción aparece como active, le llegó su cobro, todo bien del lado de Stripe. Pero tu app lo sigue tratando como si no hubiera pagado: le bloquea las features de pago, le muestra el banner de “completa tu pago”, lo manda al checkout otra vez. Abres tu base de datos y el renglón de esa suscripción dice incomplete o past_due. La verdad de Stripe y la verdad de tu DB no coinciden.
Lo peor es que es intermitente. Le das “guardar” otra vez, re-disparas el evento, o el cliente recarga, y de repente se arregla solo. Eso manda a medio equipo a buscar en el lugar equivocado: revisan el checkout, revisan la lógica de gating, revisan el caché del frontend. Y no es nada de eso.
La causa es una sola, y la digo de frente: tu handler confió en el orden en que llegaron los webhooks, y Stripe nunca prometió un orden. Recibiste customer.subscription.updated antes que customer.subscription.created, o un updated viejo llegó al final y sobrescribió el estado bueno.
Esto no es un caso raro de orilla. Está documentado y le pasa a proyectos grandes: el issue laravel/cashier-stripe #1201 lo dice literal — “el orden de los webhooks puede no ser consistente, causando que la suscripción quede en un estado incorrecto” — y hay reportes equivalentes en el stripe-cli. Es un bug de sincronización de estado, y se arregla en el diseño de tu handler, no parcheando síntomas.
Si vienes de cero con suscripciones, primero arma la base de suscripciones de Stripe; esto que sigue es la capa que la hace confiable en producción.
¿Por qué pasa? Stripe nunca promete el orden de los eventos
El mecanismo es simple cuando lo ves bien. Cada evento de webhook es un HTTP POST independiente a tu endpoint. Stripe emite customer.subscription.created y, un instante después, customer.subscription.updated. Son dos POSTs distintos, viajando por la red por separado. Y dos peticiones independientes pueden competir: la que salió segunda puede llegar primera.
Stripe lo documenta explícitamente: no garantiza que los eventos lleguen en el orden en que se generaron. Lo puedes leer en Receive events in your webhook endpoint y, específico para suscripciones, en Using webhooks with subscriptions.
Hay dos formas concretas en que esto te rompe:
- Update antes de create: llega
customer.subscription.updatedantes quecustomer.subscription.created. TuUPDATEapunta a un renglón que todavía no existe, así que no hace nada (o truena) en silencio. Cuando llega elcreated, inserta el estado inicial — y ahí se queda, sin la actualización que ya descartaste. - El evento viejo al final: un
customer.subscription.updatedviejo (stale) se procesa al último y sobrescribe estado más nuevo, dejando el renglón en un status viejo comoincomplete.
Encima, Stripe reintenta las entregas fallidas (hasta 3 días en modo live con backoff exponencial, según sus docs — confirma la ventana vigente) y puede mandarte el mismo evento más de una vez. O sea: además del desorden, tienes duplicados. Los dos problemas se combinan.
La trampa en la que cae casi todo el mundo: intentar “ordenar” los webhooks de vuelta. No se puede. No puedes reconstruir el orden real desde el orden de llegada, y los reintentos llegan tarde por diseño. La salida no es ordenar — es diseñar el handler para que sea correcto bajo cualquier orden.
¿Cómo lo reproduzco en local con el Stripe CLI?
Para convencerte de verdad, fuérzalo en tu máquina. Con el Stripe CLI conectas los eventos vivos a tu handler local:
# Reenvía los eventos en vivo a tu endpoint local
stripe listen --forward-to localhost:4242/webhooks/stripe
# En otra terminal, dispara el ciclo de vida de una suscripción
stripe trigger customer.subscription.created
stripe trigger customer.subscription.updated
Para provocar el update-before-create, captura el evt_id de un customer.subscription.updated y reenvíalo a tu endpoint antes del .created, de modo que tu handler vea el update primero. Ten en cuenta que stripe events resend apunta a un webhook endpoint registrado (necesita --webhook-endpoint=we_...), no al listener local por sí solo. Marco esto como ilustrativo: los subcomandos y flags exactos son herramienta del proveedor, confírmalos en los docs del CLI.
Mete logging temporal al entrar al handler — event.type, event.id, event.data.object.id y event.created — y vas a ver con tus ojos cómo los timestamps no coinciden con el orden de creación:
console.log({
type: event.type,
id: event.id,
object: event.data.object.id,
created: event.created, // epoch en segundos; compáralo entre eventos
});
Observa la falla: el UPDATE no encuentra renglón (update-before-create), o un updated que llega tarde pero es viejo pisa el estado bueno. Tu renglón termina en incomplete mientras la API de Stripe, si la consultas, dice active. Esa consulta a la API — stripe.subscriptions.retrieve(...) — que confirma la divergencia, es justo la semilla del arreglo.
El arreglo en una sola caja: trata a Stripe como la fuente de verdad
El principio es uno solo: nunca confíes en el payload del evento ni en su orden de llegada — re-consulta el objeto vivo desde Stripe y actúa sobre eso. Stripe es la fuente de verdad; tu DB es un caché que debe converger hacia ella. Todo lo demás son guardas (guard clauses) que sostienen ese principio.
La verificación de firma merece su propio repaso si no la tienes sólida: verifica el evento antes de confiar en él. Y la guarda 6 existe porque webhooks solo no bastan — por eso combinas webhooks con conciliación.
Construyendo el handler paso a paso
Vamos al código real. Esto es Node con Express y stripe-node; marco como ilustrativo los nombres de método del SDK (precisos al 2026, confirma firmas vigentes en los docs). La idea es que cada paso del handler sostenga una de las guardas.
Paso 1 — Verifica la firma sobre el body crudo. constructEvent necesita el cuerpo sin parsear; si dejas que un middleware lo convierta a JSON, la firma no cuadra. Rechaza con 400 si falla.
import express from "express";
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET;
const app = express();
// OJO: body crudo, no express.json()
app.post(
"/webhooks/stripe",
express.raw({ type: "application/json" }),
async (req, res) => {
let event;
try {
const sig = req.headers["stripe-signature"];
event = stripe.webhooks.constructEvent(req.body, sig, endpointSecret);
} catch (err) {
console.error("Firma invalida:", err.message);
return res.status(400).send(`Webhook Error: ${err.message}`);
}
// ...sigue el pipeline
}
);
Paso 2 — Deduplica por event.id. Stripe reintenta y puede reenviar, así que registra los ids procesados con una restricción única. Si ya existe, regresas 200 y paras.
// processed_events(event_id TEXT PRIMARY KEY, processed_at TIMESTAMPTZ)
const inserted = await db.query(
`INSERT INTO processed_events (event_id)
VALUES ($1) ON CONFLICT (event_id) DO NOTHING
RETURNING event_id`,
[event.id]
);
if (inserted.rowCount === 0) {
// Ya lo procesamos: exito silencioso, no reintentar
return res.status(200).json({ received: true, duplicate: true });
}
Ojo: Stripe también puede emitir dos Event distintos para el mismo cambio; ésos no comparten event.id, así que la dedup por id no los atrapa — se detectan por la combinación data.object.id + event.type. No te obsesiones con cubrir ese caso en la dedup: la guarda de tiempo (Paso 4) y el upsert idempotente (Paso 5) los neutralizan de todos modos, porque ambos convergen al mismo estado vivo.
Paso 3 — Re-consulta el objeto vivo. Para eventos de suscripción, no confíes en el payload: pide la Subscription actual a la API.
const subId = event.data.object.id;
const sub = await stripe.subscriptions.retrieve(subId); // estado VIVO
Paso 4 — Guarda por tiempo. Compara event.created contra el last_event_at que guardaste para esa suscripción. Si el que llega es más viejo, lo registras y lo descartas (200), para que un evento stale no regrese el estado.
const row = await db.query(
`SELECT last_event_at FROM subscriptions WHERE stripe_sub_id = $1`,
[subId]
);
const lastEventAt = row.rows[0]?.last_event_at ?? 0;
if (event.created < lastEventAt) {
console.log(`Evento viejo descartado para ${subId}`);
return res.status(200).json({ received: true, stale: true });
}
Paso 5 — UPSERT idempotente. Escribe con INSERT ... ON CONFLICT DO UPDATE, usando el status re-consultado, y guarda el nuevo last_event_at. Así un created que llega DESPUÉS de un updated igual converge, porque no asumes que el renglón existe.
Cuidado con un cambio de forma del objeto que rompe en silencio: desde la API 2025-03-31 (Basil), current_period_start y current_period_end ya no viven en el objeto Subscription — se movieron a nivel de subscription item, en sub.items.data[].current_period_end. En una cuenta o SDK con versión >= 2025-03-31, sub.current_period_end es undefined y tu INSERT escribiría null sin avisar — justo la clase de bug sutil que este post promete evitar. Lee el periodo desde el item, y confirma tu API version. Referencia: Deprecate Subscription current_period_start and current_period_end.
// Desde la API 2025-03-31 (Basil) el periodo vive en el item, no en la Subscription.
// En versiones previas sigue en sub.current_period_end: confirma tu API version.
const periodEnd = sub.items.data[0]?.current_period_end;
await db.query(
`INSERT INTO subscriptions (stripe_sub_id, status, current_period_end, last_event_at)
VALUES ($1, $2, $3, $4)
ON CONFLICT (stripe_sub_id) DO UPDATE SET
status = EXCLUDED.status,
current_period_end = EXCLUDED.current_period_end,
last_event_at = EXCLUDED.last_event_at
WHERE subscriptions.last_event_at <= EXCLUDED.last_event_at`,
[sub.id, sub.status, periodEnd, event.created]
);
// Paso 6: responde 2xx pronto para que Stripe no reintente un exito
return res.status(200).json({ received: true });
El WHERE del ON CONFLICT es un cinturón-y-tirantes: re-afirma la guarda de tiempo a nivel SQL por si dos eventos corren en paralelo y ambos pasan el chequeo del Paso 4 antes de escribir. Se ve redundante con el Paso 4, y a propósito: la concurrencia real lo justifica.
Paso 6 — Responde 2xx rápido después de confirmar/commitear. Si hay trabajo pesado, encólalo y hazlo async; el endpoint solo confirma que recibió.
Nota honesta de costo: el re-fetch suma una llamada a la API por evento crítico. Para volumen alto, aplica la guarda de tiempo (que es baratísima) a todos los eventos y re-consulta solo en los que mutan estado crítico — created, updated, deleted.
- 1. Verifica la firma sobre el body crudoconstructEvent con el cuerpo sin parsear; 400 si falla.
- 2. Deduplica por event.idINSERT con restriccion unica; si ya existe, 200 y paras.
- 3. Re-consulta la Subscription por idsubscriptions.retrieve para actuar sobre el estado vivo.
- 4. Guarda por event.createdSi es mas viejo que last_event_at guardado, descarta (200).
- 5. UPSERT idempotenteON CONFLICT DO UPDATE con el status re-consultado; converge en cualquier orden.
- 6. Reconcilia con un cronBarrido periodico que re-consulta y re-afirma la verdad.
Confiar en el orden vs. diseñar para el desorden
Pon los dos enfoques lado a lado y la decisión se vuelve obvia.
El enfoque ingenuo procesa el payload en orden de llegada y hace UPDATE con los campos del evento. Truena en update-before-create (no hay renglón) y deja la suscripción atorada en incomplete cuando un updated viejo llega al final. Además, en reintentos y duplicados, re-aplica efectos o vuelve a pisar el estado.
El enfoque robusto — re-consultar + guarda de tiempo + upsert idempotente — converge a la verdad de Stripe sin importar el orden ni los reintentos. Y es auto-sanable: aunque se pierda un evento, lo atrapa el re-fetch del siguiente evento o el barrido de conciliación.
El cambio mental es ese: deja de preguntarte “¿en qué orden llegaron estos?” y empieza a preguntar “¿cuál es la verdad viva ahora mismo, y este evento es más nuevo que el último que vi?”.
Confiar en el orden
- Procesa el payload en orden de llegada
- Truena en update-before-create: no hay renglon
- Se queda en incomplete si un updated viejo llega al final
- Re-aplica efectos en reintentos y duplicados
- Ahorra llamadas a la API, pero esconde un bug de correctitud
Disenar para el desorden
- Re-consulta el objeto vivo: Stripe = fuente de verdad
- Guarda por event.created: descarta lo mas viejo
- UPSERT idempotente: converge en cualquier orden
- Auto-sanable via re-fetch y conciliación
- Cuesta un retrieve por evento critico: seguro barato
Preguntas frecuentes
¿No puedo ordenar los eventos por event.created antes de procesarlos? No. Los recibes como POSTs independientes a lo largo del tiempo; no puedes bufferear “el conjunto completo” para ordenarlo, y los reintentos llegan tarde. El patrón correcto es la guarda de tiempo más el re-fetch, no ordenar.
¿El re-fetch siempre cuesta una llamada extra? Sí, un retrieve por evento re-consultado. Mitígalo re-consultando solo en eventos que mutan estado crítico y aplicando la guarda de tiempo (barata) a todo lo demás.
¿Cuánto reintenta Stripe un webhook fallido? Según sus docs, hasta 3 días en modo live con backoff exponencial, y tres reintentos en el transcurso de unas pocas horas en modo test. Es time-sensitive: confirma las ventanas vigentes en la documentación de Stripe.
¿Qué debe regresar mi endpoint para que deje de reintentar? Un 2xx, rápido, una vez que registraste el evento de forma segura. Regresa non-2xx solo cuando de verdad quieras que reintente.
¿Dónde guardo last_event_at y los ids procesados? En tu propia DB: una columna last_event_at por suscripción y una tabla processed_events con restricción única sobre event.id.
¿Esto es solo un problema de Stripe? No. La realidad del desorden aplica a casi cualquier proveedor de webhooks. El mismo patrón — re-fetch + idempotencia + guarda de tiempo — se generaliza a Mercado Pago y Conekta; confirma en sus docs los nombres de evento y el comportamiento de reintentos de cada uno. Y para los estados de suscripción que todo esto mantiene correctos, revisa recuperar pagos fallidos con dunning.
Cierre: diseña para el caos, no para el orden
La lección que te vas a llevar a cada integración: los webhooks son desordenados y reintentados por diseño. No pelees con esa realidad — haz tu handler correcto bajo cualquier orden. Stripe es la fuente de verdad; tu DB es un caché que debe converger hacia ella vía re-fetch, upsert idempotente y conciliación.
Esto no es un truco exclusivo de Stripe. Es un patrón de sincronización de estado, y te va a ahorrar la misma clase de bug fantasma en cada proveedor de pagos que integres. Si quieres seguir afinando esta parte del stack, tengo más guías de Stripe y cobros SaaS.