Webhooks de suscripción de Stripe que sobreviven a los casos límite
Los webhooks de suscripción de Stripe solo se mantienen correctos si tratas a Stripe como la fuente de verdad, verificas la cabecera Stripe-Signature, deduplicas por event id y vuelves a consultar los objetos en lugar de confiar en el orden de los eventos. Aquí tienes el mapa del ciclo de vida y los modos de fallo que nos mordieron.
Los webhooks de suscripción de Stripe solo se mantienen correctos si tratas a Stripe como la fuente de verdad, verificas la cabecera Stripe-Signature en cada petición, deduplicas por el event id porque Stripe reintenta, y vuelves a consultar el objeto en vivo en lugar de confiar en el orden en que llegan los eventos. Todo lo demás — la fila en la base de datos, el flag "active", el control de acceso — es una proyección de esa verdad, no la verdad en sí. Es la lección que pagamos caro al construir la suite de facturación para Delicious Diamonds, una chocolatería de lujo en Leiria, y es el principio que mantiene el sistema honesto meses después.
Cómo manejar los webhooks de suscripción de Stripe sin corromper el estado de facturación
El modelo mental ingenuo es: un cliente se suscribe, Stripe envía un webhook, activas un booleano, listo. Ese modelo sobrevive a la demo y muere en producción. La facturación de suscripciones real es una máquina de estados de larga vida que Stripe ejecuta en su propio reloj — renovaciones a fin de mes, reintentos en tarjetas fallidas, prorrateo en cambios de nivel, dunning, churn involuntario. Tu servidor se entera de todo esto a través de una manguera de eventos que llegan tarde, duplicados y desordenados. El trabajo de tu handler de webhooks no es "procesar pagos". Es ser un proyector fiel de una máquina de estados que vive enteramente dentro de Stripe.
En Amplified Creations construimos PHP 8.3 renderizado en servidor sobre alojamiento compartido OVH detrás de Cloudflare, con Cockpit CMS para contenidos y sin paso de build. Para Delicious Diamonds entregamos una suite Stripe completa: pedidos sueltos, encargos a medida, ediciones limitadas numeradas a mano, y suscripciones mensuales de chocolate con pausa, cancelación y cambio de nivel — todo movido por webhooks de ciclo de vida. La regla de diseño a la que llegamos tras la primera ronda de bugs fue tajante: guardar el mínimo estado local necesario para renderizar una página rápido, y volver a derivar todo lo importante desde Stripe cada vez que un webhook nos dice que algo se movió.
Stripe es la fuente de verdad, no tu base de datos
Tu base de datos es una caché. Dilo en voz alta antes de escribir un solo handler, porque cada bug doloroso de suscripción se remonta a olvidarlo. Si tu columna status local y Stripe discrepan, Stripe tiene razón y tú estás desactualizado. Lo imponemos no calculando nunca el estado de la suscripción nosotros mismos — nunca decidimos "este pago falló, así que pon status en past_due". En vez de eso esperamos a que Stripe nos diga que la suscripción está ahora past_due y lo copiamos hacia abajo. La única lógica propia del handler es mapear la realidad de Stripe a nuestras reglas de acceso (¿puede este cliente entrar y ver su precio de suscriptor?), nunca inventar realidad de facturación propia.
El beneficio práctico: cuando algo parece mal, la pregunta de depuración es siempre "¿qué piensa Stripe?", respondida en una consulta al dashboard o una llamada a la API. No hay una segunda opinión que reconciliar.
Qué eventos de ciclo de vida realmente importan
Stripe emite docenas de tipos de evento. Para suscripciones puedes ignorar la mayoría. Estos son los que efectivamente conectamos, y qué significa cada uno.
checkout.session.completed
Se dispara cuando el cliente termina Stripe Checkout. Es tu señal de que una intención de suscripción tuvo éxito y puedes enlazar los ids customer y subscription de Stripe con tu usuario local. No lo trates como "pago confirmado para siempre" — es el inicio de la relación, no una garantía de cada renovación futura. Aquí leemos el client_reference_id para casar la sesión con el usuario que la inició.
customer.subscription.created / customer.subscription.updated / customer.subscription.deleted
Son la columna vertebral de la máquina de estados. created te dice que la suscripción existe; updated se dispara en casi todos los cambios relevantes — transiciones de estado (active, past_due, canceled, paused), cambios de nivel, cancelaciones programadas, pausa/reanudación. deleted significa que se acabó y el acceso debe terminar. Tratamos customer.subscription.updated como el evento más importante: cada vez que llega copiamos el status actual de la suscripción, los items (el nivel) y el current_period_end a nuestra fila. El noventa por ciento de "mantener la vista local correcta" vive en este handler.
invoice.paid
Se dispara cuando una factura — incluida cada factura de renovación mensual — se paga con éxito. Es el evento que dice "han pagado el siguiente periodo". Lo usamos para extender el acceso y registrar el pago en el historial de pedidos del cliente. Nota que es distinto de checkout.session.completed: Checkout cubre el primer pago, invoice.paid cubre cada renovación después.
invoice.payment_failed
Se dispara cuando un cargo de renovación falla — tarjeta caducada, fondos insuficientes, un banco rechazando una compra de lujo transfronteriza (pasa más de lo que crees). Es la puerta de entrada al dunning. Por sí solo no significa que la suscripción se canceló; Stripe reintentará según el calendario que configures. Lo usamos para marcar la cuenta y, cuando procede, animar al cliente a actualizar su tarjeta.
invoice.payment_action_required
Se dispara cuando el pago necesita Autenticación Reforzada de Cliente — el cliente debe completar un reto 3D Secure antes de que el cargo se confirme. Bajo las reglas europeas de SCA esto es común, no exótico. La respuesta correcta es notificar al cliente que se necesita acción; el dinero no se cobra hasta que autentique.
La cabecera Stripe-Signature y el webhook signing secret
Antes de confiar en un solo byte de un webhook, verifica que vino de Stripe. El endpoint es una URL pública; cualquiera puede hacer POST. Stripe firma cada entrega con la cabecera Stripe-Signature, y tu handler verifica esa firma contra un webhook signing secret por endpoint (el valor whsec_... del dashboard, fuera del repositorio). En PHP:
$payload = file_get_contents('php://input');
$sig = $_SERVER['HTTP_STRIPE_SIGNATURE'] ?? '';
try {
$event = \Stripe\Webhook::constructEvent(
$payload, $sig, $webhookSecret
);
} catch (\Stripe\Exception\SignatureVerificationException $e) {
http_response_code(400);
exit;
}
Dos trampas nos mordieron. Primera: debes verificar contra el cuerpo de la petición crudo. Si algún middleware recodifica o formatea el JSON antes de firmarlo, la verificación falla para eventos legítimos — lee php://input directamente, no recurras a un array ya parseado. Segunda: el signing secret de stripe listen en pruebas locales es distinto del secret de tu endpoint de producción. Confundirlos produce un flujo de 400s que parecen un ataque y son en realidad un error de configuración.
Idempotencia: deduplicar por el event id porque Stripe reintenta
Stripe garantiza entrega al-menos-una-vez, no exactamente-una-vez. Si tu endpoint es lento, da timeout o devuelve un no-2xx, Stripe reintenta — y también puede legítimamente entregar el mismo evento dos veces. Si tu handler no es idempotente, un invoice.paid duplicado acredita un pedido dos veces, y un evento reintentado reenvía un correo de "tu suscripción está activa" que el cliente ya recibió. La solución es simple e innegociable: cada evento tiene un id estable (el valor evt_...). Registra los ids procesados y salta todo lo que ya hayas visto.
// Insertar el event id primero; la restricción UNIQUE es el lock.
$ok = $db->insertEventIfNew($event->id); // false si ya existe
if (!$ok) {
http_response_code(200); // ya tratado, confirmar y seguir
exit;
}
// ... hacer el trabajo real ...
Dejamos que la restricción UNIQUE de la base de datos sobre el event id haga la deduplicación — insertar primero, y si colisiona ya tratamos este evento y devolvemos 200 de inmediato. Devolver 200 en un duplicado importa: si devuelves un error, Stripe sigue reintentando para siempre. Confirma rápido, procesa de forma idempotente.
Eventos desordenados: usa el objeto del evento, luego vuelve a consultar
Este es el que más nos sorprendió. Los webhooks no tienen garantía de llegar en el orden en que ocurrieron los cambios subyacentes. Puedes recibir un customer.subscription.updated de una cancelación antes del invoice.paid de la renovación que la precedió, porque las dos entregas HTTP corrieron una carrera. Si tu handler aplica ciegamente lo último que recibió, un evento desactualizado que llega tarde pisa el estado fresco y un cliente que paga pierde el acceso.
Dos defensas, usadas juntas. Primera, nunca confíes en el orden de los webhooks para nada que importe — cuando un evento te dice que una suscripción cambió, vuelve a consultar el objeto en vivo de la API de Stripe y proyecta ese, no el snapshot posiblemente obsoleto embebido en el evento. El objeto embebido sirve para enrutado y logging; la consulta en vivo es lo que escribes. Segunda, cuando uses el objeto embebido, apóyate en los campos monótonos de Stripe — el current_period_end y el status de la suscripción en una consulta fresca reflejan el verdadero estado actual sin importar qué evento te despertó. Lo aprendimos cuando un cliente que subió de nivel apareció brevemente en el nivel viejo porque dos eventos updated llegaron invertidos; volver a consultar en cada handler hizo desaparecer el síntoma.
Dunning y pagos fallidos: past_due y canceled
Cuando una renovación falla, Stripe no cancela de inmediato — ejecuta el dunning: un calendario de reintentos configurable (Smart Retries o fijo) con correos recordatorios opcionales. Durante esta ventana la suscripción queda en past_due. Tu trabajo es decidir qué significa past_due para el acceso. Mantenemos el precio de suscriptor y la cuenta activos durante la ventana de gracia — una tarjeta rechazada en una suscripción de chocolate suele ser un problema de tarjeta, no una señal de churn — y solo revocamos cuando Stripe finalmente mueve la suscripción a canceled tras agotar los reintentos. Los estados a manejar: active (todo bien), past_due (pagó antes, fallando ahora, en gracia), canceled (terminó, revocar acceso), y paused si usas pause collection. Crucialmente, no escribimos ninguna de estas transiciones — Stripe es dueño de la política de reintentos, nosotros solo leemos el status resultante.
Implementar pausa, cancelación y cambio de nivel
Los tres son escrituras a la API de Stripe; el webhook customer.subscription.updated resultante es lo que actualiza nuestro lado. Nunca mutamos el estado local en el momento del clic esperando que Stripe esté de acuerdo — hacemos la llamada a la API, y el webhook cierra el ciclo.
- Cancelar: poner
cancel_at_period_end = truepara un fin elegante (el cliente mantiene el acceso hasta que se agote el periodo que pagó), o cancelar de inmediato si lo exige. El camino elegante es casi siempre el default correcto y evita discusiones de reembolso. - Pausa: usar el
pause_collectionde Stripe en la suscripción para que las renovaciones paren sin perder el registro de la suscripción y su historial. Reanudar lo limpia. Es mucho más limpio que cancelar-y-recrear, que pierde el historial de derechos de la edición numerada que nos importa. - Cambio de nivel: actualizar el item de la suscripción al nuevo precio. Stripe maneja el prorrateo; el webhook
updatedtrae el nuevo nivel hasta nosotros. Dejamos que Stripe calcule el dinero y nunca intentamos pre-calcular el prorrateo nosotros.
Probar con la Stripe CLI: stripe listen y stripe trigger
No puedes probar esto contra pagos de producción, y no deberías probarlo haciendo clic por Checkout cincuenta veces. La Stripe CLI es la herramienta. stripe listen --forward-to localhost:8000/webhook abre un túnel que reenvía eventos reales de modo-de-prueba a tu handler local e imprime el signing secret a usar. stripe trigger invoice.payment_failed (y los demás nombres de evento de arriba) dispara un evento sintético-pero-realista para ejercitar el camino del dunning sin tener una tarjeta que rechaza bajo demanda. Escribimos un script de secuencia — crear, renovar, fallar, reintentar, cancelar — y verificamos nuestro estado proyectado tras cada paso, que es la única forma de cazar los bugs de desorden antes que los clientes.
La versión corta
- Stripe es la fuente de verdad; tu base de datos es una caché que re-derivas de los webhooks.
- Verifica la cabecera
Stripe-Signaturecontra el secretwhsec_por endpoint, usando el cuerpo crudo de la petición. - Deduplica por el
iddel evento — Stripe entrega al-menos-una-vez y reintenta en cualquier no-2xx. - No confíes en el orden de los eventos: vuelve a consultar la suscripción/factura en vivo y proyecta eso.
- Conecta solo los eventos que importan:
checkout.session.completed,customer.subscription.created/updated/deleted,invoice.paid,invoice.payment_failed,invoice.payment_action_required. - Deja que Stripe sea dueño del dunning y de la transición
past_due→canceled; tú solo lees. - Implementa pausa/cancelación/cambio-de-nivel como escrituras a la API de Stripe y deja que el webhook
updatedreconcilie el estado local. - Prueba con
stripe listenystripe triggerantes de publicar.
Preguntas frecuentes
¿Necesito manejar todos los eventos de webhook de Stripe?
No. Para facturación de suscripciones puedes ignorar la mayoría de los tipos de evento y conectar un pequeño conjunto: checkout.session.completed, los tres eventos customer.subscription.*, invoice.paid, invoice.payment_failed e invoice.payment_action_required. Suscríbete solo a lo que actúas; el resto es ruido.
¿Cómo hago idempotente un handler de webhook de Stripe?
Deduplica por el id del evento. Cada evento de Stripe tiene un id estable evt_...; registra los ids procesados (una restricción UNIQUE funciona bien) y salta cualquier id ya tratado, devolviendo 200. Es necesario porque Stripe entrega al-menos-una-vez y reintenta entregas fallidas, así que el mismo evento puede llegar dos veces.
¿Por qué mis eventos de webhook de Stripe llegan desordenados?
Porque Stripe no garantiza el orden de entrega — entregas HTTP separadas pueden correr una carrera, así que un evento más nuevo puede llegar antes de uno más antiguo. No apliques eventos a ciegas. Cuando un evento señala un cambio, vuelve a consultar el objeto en vivo de la API de Stripe y proyecta ese estado actual, en vez de confiar en el snapshot embebido en el evento.
¿Cuál es la diferencia entre past_due y canceled en Stripe?
past_due significa que un pago de renovación falló y Stripe lo está reintentando en tu calendario de dunning — la suscripción sigue viva y normalmente vale la pena mantener el acceso abierto durante la ventana de gracia. canceled significa que Stripe se rindió tras agotar los reintentos (o tú la cancelaste); ahí es cuando revocas el acceso. Deja que Stripe conduzca la transición en vez de calcularla tú.
¿Cómo pruebo webhooks de suscripción de Stripe localmente?
Usa la Stripe CLI. Ejecuta stripe listen --forward-to localhost:8000/webhook para tunelizar eventos reales de modo-de-prueba a tu endpoint local (imprime el signing secret a usar), y stripe trigger invoice.payment_failed o cualquier otro nombre de evento para disparar un evento sintético realista. Escribe un script del ciclo de vida completo — crear, renovar, fallar, cancelar — y verifica tu estado proyectado tras cada paso.