Skip to content
Search
See every article
Stripe

Webhooks de Stripe: el camino feliz y todo lo que se rompe después

Los webhooks son cómo Stripe te avisa que algo pasó: un pago, una renovación, una cancelación. Sirven para mantener sincronizado el acceso de tus usuarios con lo que realmente ocurre en la plata.

El camino feliz: un pago que sale bien

Cuando un cliente paga, Stripe no te devuelve nada útil en el checkout mismo: te avisa después, con un evento, a un endpoint que vos registraste. El flujo es simple en el caso ideal: el evento ocurre, Stripe arma un objeto Event con el tipo y los datos, te lo manda por POST, vos lo procesás y devolvés un 2xx. Para suscripciones, el evento que más te importa es invoice.paid: ahí es donde confirmás que la plata entró y le das acceso al producto. Todo esto es asíncrono a propósito, porque cobrar una tarjeta puede tardar, requerir autenticación adicional, o simplemente fallar más tarde de lo que esperás.

javascript
// Express.js
app.post('/webhooks/stripe', express.raw({type: 'application/json'}), (req, res) => {
const sig = req.headers['stripe-signature'];
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET;
let event;
​
try {
event = stripe.webhooks.constructEvent(req.body, sig, endpointSecret);
} catch (err) {
console.log(`Webhook signature verification failed: ${err.message}`);
return res.status(400).send(`Webhook Error: ${err.message}`);
}
​
switch (event.type) {
case 'invoice.paid':
const invoice = event.data.object;
handleInvoicePaid(invoice);
break;
case 'customer.subscription.created':
const subscription = event.data.object;
handleSubscriptionCreated(subscription);
break;
default:
console.log(`Unhandled event type: ${event.type}`);
}
​
res.json({received: true});
});

Lo primero que puede fallar: no verificar la firma

Tu endpoint es una URL pública. Cualquiera puede mandarle un POST con un JSON que diga invoice.paid y tu código, si no verifica nada, le va a dar acceso gratis al que lo mandó. Por eso el body crudo (sin parsear) se pasa a stripe.webhooks.constructEvent junto con el header stripe-signature y tu secreto de firma: si no coincide, es un 400 y listo. Este paso no es opcional ni es paranoia, es la diferencia entre un webhook y un formulario de auto-servicio para gente que quiere tu producto gratis.

Los estados de una suscripción no son un detalle, son el producto

Una suscripción no es 'activa' o 'no activa': pasa por trialing, active, past_due, canceled, unpaid, paused, y cada transición dispara customer.subscription.updated o un evento más específico. Si tu lógica de acceso solo mira si existe una suscripción y no en qué estado está, vas a tener usuarios con acceso después de que su tarjeta rebotó tres veces. La recomendación de Stripe es guardar en tu base una fecha de expiración de acceso por cliente: cuando llega invoice.paid, la actualizás hacia adelante; cuando el estado cambia a past_due, avisás al cliente; cuando cambia a canceled o unpaid, le revocás el acceso. Tres días antes de que termine un trial llega customer.subscription.trial_will_end, que es tu única oportunidad de chequear si el cliente tiene un método de pago cargado antes de que se le corte todo de sorpresa.

Cuando un pago falla no es el fin, es un evento más

invoice.payment_failed no significa que el cliente se fue: puede ser una tarjeta con fondos insuficientes en un mal día, y Stripe reintenta automáticamente si tenés Smart Retries activado. Lo que tenés que hacer en ese momento es notificar al cliente y, si estás usando PaymentIntents, dejar la puerta abierta para que confirme con un método de pago nuevo. El subscription.status pasa a incomplete solo en la primera factura fallida de una suscripción nueva; en renovaciones posteriores, el estado que te interesa vigilar es past_due. No revoques acceso apenas ves este evento: date el margen de los reintentos antes de asumir que perdiste al cliente.

Reembolsos: la plata vuelve pero el evento no te dice a qué suscripción pertenece

Cuando Stripe procesa un reembolso, el evento que recibís es charge.refunded, y el objeto Charge no tiene una referencia directa a la suscripción. Tenés que hacer el camino inverso: de la Charge sacás el payment_intent, con eso listás los InvoicePayment de esa factura, de ahí sacás el invoice, y del invoice sacás el subscription. Es un poco de trabajo para algo que debería ser obvio, pero es el precio de que Stripe modele charges, invoices y subscriptions como objetos separados en vez de uno solo con todo adentro.

Duplicados y reintentos: procesá como si todo pudiera llegar dos veces

Si tu endpoint no responde 2xx en cinco segundos, o responde con error, Stripe reintenta el mismo evento más tarde. Esto es bueno para vos porque no perdés eventos, pero significa que tu código tiene que ser idempotente: si invoice.paid llega dos veces por la misma factura, no le sumes dos meses de acceso al cliente, sumale uno. Guardar el id del evento procesado y chequearlo antes de aplicar cambios es la forma más simple de evitar este problema sin complicarte la vida.

What you can build with this

Three projects you can finish in an afternoon, each ending in something that runs.

  1. Guardián de acceso por estado de suscripción

    Un endpoint de webhooks que escucha customer.subscription.updated e invoice.paid y actualiza una fecha de expiración de acceso en tu base de datos.

    Why it's worth it — Resuelve el problema real de usuarios que siguen teniendo acceso premium después de que su tarjeta dejó de funcionar.

  2. Avisador de trial por vencer

    Un handler para customer.subscription.trial_will_end que chequea si el cliente tiene método de pago y le manda un email de aviso tres días antes del cobro.

    Why it's worth it — Evita el reclamo de 'me cobraron sin avisar' y baja las disputas de cargo al mínimo.

  3. Rastreador de reembolsos hasta la suscripción

    Una función que toma un evento charge.refunded y camina la cadena Charge → PaymentIntent → InvoicePayment → Invoice → Subscription para saber a quién afectó.

    Why it's worth it — Sin este cruce no podés saber qué cliente reclamó plata de vuelta ni ajustar su acceso en consecuencia.

What you take away

  • Un webhook sin verificación de firma es una puerta abierta: siempre usá el body crudo y el secreto para confirmar que el evento viene de Stripe.

  • El estado de la suscripción (trialing, active, past_due, canceled) es la fuente de verdad del acceso, no la existencia de la suscripción.

  • Un pago fallido no es un cliente perdido: dale margen a los reintentos antes de revocar acceso.

  • Diseñá tu handler para que procesar el mismo evento dos veces no rompa nada, porque Stripe va a reintentar tarde o temprano.

This article was written from the official documentation linked below. The code comes from there, not from a model's memory.

Where this came from