Cuatro formas en que un webhook te puede engañar
Un webhook es un POST a una URL pública. Cualquiera puede enviar uno, y el propio Stripe no promete cuántas veces llega ni en qué orden. Su documentación de webhooks (en inglés) lo dice claro: un endpoint puede recibir el mismo evento más de una vez, y los eventos no llegan necesariamente en el orden en que se crearon. En modo live, Stripe reintenta una entrega fallida durante hasta tres días.
| Problema | Qué hacemos |
|---|---|
| Alguien falsifica un evento | Verificamos la firma de Stripe sobre el cuerpo crudo, con una ventana de cinco minutos |
| El mismo evento llega dos veces | Lo guardamos una sola vez con su id; la segunda copia no cambia nada |
| Los eventos llegan desordenados | Cada registro guarda la hora del último evento aplicado; uno más viejo pierde |
| Un evento nunca llega | Un backfill lista los eventos recientes en la API de Stripe y guarda los que faltan |
Debajo de las cuatro hay una regla: el dinero se recibe cuando Stripe lo dice, nunca porque nuestro código lo supuso.
Dónde corre este código hoy
Este código de cobros está escrito y probado, y está apagado. Los clientes de nuestros servicios pagan por transferencia las facturas que enviamos desde Mercury. La parte de Stripe espera a las suscripciones de nuestros productos, y se enciende solo cuando tenga sus llaves.
Revisa la firma sobre el cuerpo crudo
Stripe firma cada entrega con un HMAC (SHA-256) sobre una marca de tiempo y el cuerpo de la petición, y lo envía en el encabezado Stripe-Signature. Dos detalles deciden si la revisión sirve de algo.
- Verifica el cuerpo crudo. La documentación de Stripe advierte que cualquier cambio en el cuerpo, como un framework que lee el JSON y lo vuelve a escribir, hace fallar la verificación. Leemos los bytes primero y los convertimos a JSON solo después de revisar la firma.
- Revisa también la hora. Una firma válida sobre una petición vieja es una repetición (un replay). Las librerías de Stripe usan cinco minutos de tolerancia por defecto, pero la función de bajo nivel
verify_headeren Python no revisa la hora si no le pasas una. Nosotros se la pasamos.
payload = (await request.body()).decode()
try:
# Checks the HMAC over the raw body and rejects timestamps older than five minutes (replays).
stripe.WebhookSignature.verify_header(
payload,
stripe_signature or "",
settings.stripe_webhook_secret,
# verify_header skips the timestamp check unless given a tolerance.
tolerance=stripe.Webhook.DEFAULT_TOLERANCE,
)
except stripe.SignatureVerificationError:
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="invalid_signature") from NoneSi no hay un secreto de webhook configurado, la ruta responde 503 stripe_not_configured en vez de aceptar cualquier cosa. Es la única ruta de la API que no llama el servidor de Next.js: la llama Stripe directamente, así que su firma es la única autenticación que tiene.
Guarda cada evento una vez y responde
Stripe recomienda responder 2xx rápido, antes de cualquier lógica que pueda tardar, y procesar los eventos desde una cola. Por eso la ruta casi no hace nada: guarda el evento con su id de Stripe como _id del documento, encola un job para procesarlo y responde. Las dos escrituras van en una sola transacción de MongoDB, así que nunca queda un evento guardado sin su job, ni un job sin su evento.
async def accept(db, event, *, now, source="webhook") -> bool:
"""Store the event and queue its projection, once. False if we already had it."""
async def work(session) -> bool:
if not await store.record_event(db, event, now=now, source=source, session=session):
return False
await queue.enqueue(db, JOB, {"event_id": str(event["id"])}, now=now, session=session)
return True
try:
return await in_transaction(db, work)
except DuplicateKeyError: # the same event arrived twice at once; the other one stored it
return False
Una entrega repetida recibe un 200 con duplicate: true, y Stripe deja de reintentarla. Si dos copias llegan al mismo tiempo, las dos intentan insertar el mismo _id; la base deja ganar a una y la otra cuenta como duplicada. Si guardar falla por cualquier otra razón, la ruta responde 500 y Stripe vuelve a enviar el evento más tarde. Ese es el reintento que queremos.
Proyecta el estado y deja que pierdan los eventos viejos
El worker convierte los eventos en tres colecciones que lee el admin: clientes, suscripciones y facturas. Cada documento tiene como llave su id de Stripe con un índice único, así que un evento repetido sobrescribe el mismo registro en vez de crear otro. Cada uno guarda también el created del último evento aplicado, y la actualización solo aplica si el evento que llega es igual de nuevo o más.
newer_or_new = {
"$or": [{"lastEventCreated": {"$lte": event_created}}, {"lastEventCreated": {"$exists": False}}]
}
try:
await db[collection].update_one(
{key: stripe_id, **newer_or_new},
{"$set": {**fields, "lastEventCreated": event_created, "updatedAt": now}},
upsert=True,
)
except DuplicateKeyError:
# The document exists with a newer event, so the filter missed and the upsert collided: stale.
return FalseUn límite, dicho con honestidad. La documentación de Stripe aclara que created está en segundos, así que dos eventos pueden compartirlo, y recomienda no usarlo para ordenar eventos ni para detectar duplicados. Detectamos duplicados solo por el id del evento. Usamos created únicamente para saltar fotos del objeto que son claramente más viejas; en un empate, escribe el evento que se procesa último. En facturas enviadas por email y pagadas días después, un empate entre dos estados de la misma factura es poco probable, pero no imposible, y es una razón más para tener el backfill.
Las formas también cambian. En la versión 2025-03-31.basil de la API, el current_period_end de una suscripción pasó a sus items, y la suscripción de una factura pasó a estar bajo parent. Nuestro código acepta las dos formas, porque cada evento conserva la versión de la API con la que se creó.
Recupera lo que el webhook no recibió
Los reintentos cubren un servidor que estuvo caído una hora. No cubren un endpoint mal configurado durante una semana, ni una entrega que Stripe dio por perdida. Por eso el worker también le pregunta a Stripe: en su ciclo de mantenimiento, cada seis horas por defecto, lista los eventos recientes con la API de eventos (en inglés), que llega hasta 30 días atrás.
- la firma más vieja que aceptamos
- 5 min
- lo más atrás que Stripe lista eventos
- 30 días
- de solapamiento que cada backfill vuelve a leer
- 1 hora
- Cada corrida empieza una hora antes del evento más nuevo que vio la última corrida completa, porque Stripe puede listar un evento con retraso.
- Salta los tipos de evento a los que el webhook no está suscrito, y manda el resto por el mismo
accept()del webhook, marcadosource: "backfill". Lo que ya teníamos no cambia nada. - Lee como máximo 50 páginas de 100 eventos. Si se corta antes o Stripe falla, el cursor no avanza, y la siguiente corrida vuelve a leer la misma ventana en vez de saltarla.
- Si una proyección falla, la cola de jobs la reintenta con una espera creciente (10 segundos, luego 20, 40 y así, hasta una hora), cinco intentos por defecto. Después aparece como fallida en el registro de webhooks del admin, con un botón para reintentarla.
Nunca marques dinero como recibido desde tu lado
El dinero se recibe cuando Stripe lo dice, nunca porque nuestro código lo supuso.
Cuando el worker envía la factura de un hito de proyecto, crea el cliente, la factura y su ítem en la API de Stripe, cada uno con una llave de idempotencia armada con el hito y el intento de envío. La documentación de idempotencia de Stripe (en inglés) explica por qué: una petición repetida con la misma llave devuelve el resultado guardado de la primera, durante al menos 24 horas, en vez de hacer el trabajo dos veces. Un job reintentado nunca envía una segunda factura. Stripe también recomienda no poner datos personales en las llaves, así que la del cliente lleva un hash del email de facturación, no el email.
Ahí el código se detiene. No hay un botón de "marcar como pagado". Un hito aparece pagado solo cuando la factura que proyectó el webhook dice paid, y anulado solo cuando Stripe lo dice. Hoy, con los servicios facturados desde Mercury, el admin tampoco marca hitos como pagados a mano: eso espera a una integración con la API de Mercury, que dirá qué se pagó, como lo haría el webhook de Stripe.
Una lista para tu propio webhook
- 01Verifica la firma sobre el cuerpo crudo, y asegúrate de que también se revise la hora.
- 02Guarda cada evento una vez, con su id como llave, y encola el trabajo en la misma transacción.
- 03Responde rápido; procesa en un worker que reintenta.
- 04Guarda el último estado de cada objeto, y no dejes que un evento viejo pise uno nuevo.
- 05Lista los eventos recientes en la API del proveedor cada cierto tiempo, y repite lo que falte por el mismo camino.
- 06Deja que el proveedor te diga cuándo llegó el dinero. Tu código envía facturas; nunca decide que se pagaron.
Así construimos los cobros en las plataformas web que hacemos para clientes. Si tienes una en mente, mira qué te sirve o envíanos un brief.


