Un webhook es un aviso que llega por HTTP sin que lo pidas. Si el handler lo procesa antes de contestar, el pico no rompe la interfaz: rompe la tubería que la alimenta.
Un webhook es una petición HTTP que otro servicio te manda cuando algo pasa; no la pediste, llega. Del lado tuyo hay un handler, la función que atiende esa ruta, con dos trabajos que conviene separar: aceptar (verificar la firma y guardar el aviso tal cual) y procesar (interpretarlo, calcular, escribir en tus tablas).
Si hace las dos antes de responder, cada aviso ocupa un lugar mientras dura el trabajo. Con 5.000 avisos en un minuto (~83 por segundo) y ~40 ms por aviso hay ~4 en vuelo; un pico de diez veces pide ~33 y el servidor no los tiene. Los que no caben esperan: esa espera es la latencia del panel.
Aceptar significa que el aviso está en un lugar durable y alguien lo va a trabajar; procesar, que el efecto ya ocurrió. Un 200 sin procesar es una mentira que el emisor no distingue de la verdad, y por eso no reintenta.
Es como el mesero que clava la comanda en el riel y vuelve a la mesa siguiente. Si cocinara cada plato antes de tomar la siguiente comanda, la fila se saldría a la calle aunque la cocina estuviera libre. El riel es la cola (lista durable de trabajo pendiente), la cocina es el worker (proceso aparte que saca trabajo y lo hace) y el "quedó anotado" es el 202: aceptado, todavía no hecho.
Los webhooks se reintentan: el emisor no sabe si tu respuesta se perdió y, si tarda, asume lo peor. El reintento puede llegar después de que ya procesaste: la misma visita cuenta dos. La idempotencia evita eso: procesar dos veces el mismo event_id deja el mismo resultado que una. Se guardan los ids procesados (o una restricción única): el reintento se descarta.
Cuando llega más de lo que puedes procesar, algo cede: ese es el backpressure. Rechazar con 429 o 503, descartar lo menos valioso contando cuánto tiraste, o dejar crecer la cola. La tercera parece amable y es la peor: sin límite se come la memoria del servicio de colas y convierte el pico en una caída. Si entran 5.000 por minuto y drenan 800, la cola crece 4.200 cada minuto.
La otra decisión es cuándo sumas. Sumar 40 millones de filas en cada consulta paga el costo siempre; con un contador por minuto, el panel de 24 horas lee 1.440 filas. Eso es agregar al escribir. Pierdes libertad para rebanar después, así que se combina con retención por capas: crudo 7 días, agregados por minuto 90 días, por día 2 años.
El handler mínimo no procesa: firma, valida lo justo para encolar y contesta 202. El worker hace el resto, fuera del request.
// POST /webhooks/pedidos: acepta y encola. app.post('/webhooks/pedidos', async (req, res) => { // Firma: sin esto, cualquiera inventa avisos. if (!verificarFirma(req)) { return res.status(401).json({ error: 'firma' }); } // Validación mínima: lo justo para encolar. const { event_id, tipo, datos } = req.body || {}; if (!event_id || !tipo) { return res.status(400).json({ error: 'faltan datos' }); } // Clave de deduplicación: un reintento no duplica. await cola.enviar('pedidos', { event_id, tipo, datos }, { clave: event_id, }); // 202: aceptado, todavía no hecho. return res.status(202).json({ ok: true, event_id }); });
El contrato es lo que el frontend hereda: la cola, los workers y el agregado son backend y no corren en el navegador. Si la ingesta devuelve id y estado, la UI puede mostrar "en cola" en vez de mentir con un "listo"; si el agregado trae as_of y lag_ms, puede decir "actualizado hace 2 minutos". Un frontend senior lo necesita porque el contrato decide qué puede hacer la interfaz.
| Herramienta | Qué se mira | Cómo se ve el síntoma |
|---|---|---|
| Métricas del endpoint | p50 y p99 por separado. | p99 alto con p50 estable: cola delante. Si suben los dos, saturación. |
| Panel de la cola | Profundidad y edad del mensaje más viejo. | Sube con el pico y no baja; la edad pasa del minuto. |
| Logs y trazas | 429, 503 y 5xx propios. | 429 frena al emisor; 503 con cola vacía: sin capacidad. |
| Tabla de deduplicación | Conteo por event_id. |
El mismo id dos o tres veces: el efecto se aplicó varias. |
| Traza del request | Si el span de proceso vive en el handler. | Dura lo que el trabajo: no hay 202 real, hay un 200 lento. |
p50 en ~80 ms con p99 en 8 s no es código lento para todos: hay una cola delante.
“The handler checks the signature, validates the shape and enqueues; it returns 202 in milliseconds.”
“Every retry is deduplicated on event_id, so processing twice has the same effect as processing once.”
“Under backpressure we shed low-value events and return 429 with Retry-After instead of growing an unbounded queue.”
Comprueba que lo tienes
Cuatro preguntas. Al comprobar se marca la lección como completada.
Tu endpoint responde 202 y encola. ¿Qué significa ese 202 para el emisor?
Por qué: 202 dice "recibí la responsabilidad", no "terminé": el trabajo ya está durable y el emisor deja de reintentar. Un 200 sin procesar mentiría, y el reintento que necesitabas no llegaría.
El emisor reintenta por timeout, pero ya habías procesado. ¿Qué evita que la lectura se cuente dos veces?
Por qué: el timeout no distingue "no llegó" de "llegó y tardó en contestar", y el reintento ocurrirá igual. La defensa es que el efecto sea reconocible por id.
La cola crece más rápido de lo que drena y la memoria llega al límite. ¿Cuál es la peor salida?
Por qué: una cola sin límite no absorbe el pico: lo convierte en caída total y pierdes lo acumulado. Descartar con conteo o rechazar con 429 mantienen el sistema vivo y el daño medido.
El panel pide "15 minutos" y la tabla cruda tiene 40 millones de filas. ¿Qué cambia el contrato?
Por qué: sumar 40 millones en el cliente es la misma operación imposible, movida de sitio y con la red en medio. El agregado se lee barato; as_of evita mostrarlo como tiempo real.