10 dic 2025·7 min de lectura

Protección contra replays de webhooks: evita duplicados con confianza

La protección contra replays en webhooks evita cargos y acciones duplicadas verificando firmas, aplicando ventanas de tiempo y almacenando claves de idempotencia de forma segura.

Protección contra replays de webhooks: evita duplicados con confianza

Por qué ocurren webhooks duplicados y por qué importa

Un webhook es un mensaje que un servicio envía a tu app (normalmente una petición HTTP) para decir que ocurrió un evento, como que un pago se completó o que una suscripción se canceló.

El problema es que “enviado” no significa “procesado una sola vez”. La mayoría de proveedores entregan webhooks con una garantía de “al menos una vez”. Si tu endpoint agota el tiempo, devuelve un 5xx o la respuesta se pierde al volver, el proveedor reintenta. Esos reintentos son duplicados accidentales: el mismo evento del mundo real entregado más de una vez.

Los replays son distintos. Un ataque de replay ocurre cuando alguien captura una petición webhook válida y la envía de nuevo más tarde para provocar el mismo efecto dos veces. La petición puede parecer legítima, así que sin protección contra replays puedes aceptar un evento antiguo como si fuera nuevo.

Si los duplicados o replays se tratan como eventos nuevos, los resultados son dolorosamente reales: cobros o reembolsos dobles, correos duplicados, inventario descontado dos veces, suscripciones o facturas duplicadas y analíticas que ya no reflejan la realidad.

El objetivo es sencillo de decir y fácil de fallar: aceptar cada evento válido una vez y ignorar las repeticiones de forma segura. “De forma segura” importa porque los reintentos legítimos son normales, mientras que las peticiones manipuladas y los replays caducados deberían rechazarse.

Un buen manejador trata cada webhook entrante como no confiable hasta que se demuestre lo contrario. Verifica quién lo envió, comprueba que es lo bastante reciente para ser creíble y registra un marcador estable de “ya procesado” para que la segunda entrega sea un no-op.

Fuentes comunes de duplicados y replays

Las entregas duplicadas son comportamiento esperado. Incluso cuando todo funciona, el mismo evento puede llegar otra vez.

La causa más común son los reintentos tras un timeout o una respuesta 5xx. Puede que hayas terminado de procesar, pero el proveedor no recibió una señal clara de éxito. Fallos de red crean el mismo resultado: la petición tiene éxito, pero la respuesta se pierde o un proxy reinicia la conexión.

Los duplicados también pueden venir de tu propio sistema y seguir pareciendo “problemas de webhook” cuando revisas resultados. Un usuario hace doble clic en un botón, una automatización entra en bucle o una cola reentrega tras el fallo de un worker.

Fuentes típicas incluyen reintentos del proveedor, reenvíos manuales desde dashboards, pérdida de respuesta tras un procesamiento exitoso, reentrega de colas y bucles de integración.

Los replays son la prima más preocupante. Un atacante (o un cliente con bugs) puede reenviar una solicitud antigua que era válida antes. Sin protección contra replays, eso puede repetir acciones como otorgar acceso, cambiar el estado de una cuenta o generar facturas.

Un caso simple de fallo: tu manejador crea una factura en payment_succeeded. Si el evento se reintenta tres veces, o se reejecuta un día después, puedes acabar con múltiples facturas a menos que verifiques firmas, apliques una ventana de tiempo y deduplices por una clave de idempotencia.

Validación de firmas: tu primera línea de defensa

La validación de firmas bloquea la mayoría de webhooks suplantados antes de que toquen tu sistema. Una firma válida prueba que la carga no fue modificada en tránsito (integridad) y que fue generada por alguien que conoce el secreto compartido (autenticidad).

Esto es una parte importante de la protección contra replays porque impide que terceros inventen eventos. No impide por sí sola que un remitente legítimo entregue el mismo evento dos veces. Solo asegura que manejes mensajes reales.

Donde los equipos se queman es en confiar en la presencia de un encabezado en vez de verificarlo. Una petición con X-Signature: abc123... no significa nada a menos que recalcules la firma en tu lado usando el cuerpo crudo de la petición y tu secreto.

Un flujo de verificación sólido es:

  • Rechazar inmediatamente si falta el encabezado de firma.
  • Leer los bytes crudos del body (no un objeto JSON parseado).
  • Calcular el HMAC esperado (o lo que especifique el proveedor) sobre los bytes exactos.
  • Comparar usando una función de tiempo constante.
  • Solo entonces parsear el JSON y hacer trabajo en la base de datos.

La comparación en tiempo constante merece la pena porque las comparaciones normales de cadenas pueden filtrar información por diferencias de tiempo.

Además, falla rápido. Si validas la firma después de parsear cargas grandes, le das a los atacantes una forma fácil de quemar CPU. Trata las firmas faltantes o inválidas como una puerta cerrada: para pronto, registra detalles mínimos y devuelve un error sin hacer trabajo extra.

Ventanas de timestamp: hacer que los replays expiren

Una ventana de timestamp limita cuánto tiempo una solicitud webhook capturada sigue siendo útil. Funciona mejor cuando el remitente incluye una marca de tiempo y la firma junto con el cuerpo.

El flujo es sencillo: verifica la firma y luego comprueba que la marca de tiempo sea lo bastante reciente. Si alguien reenvía la misma petición horas después, fallará la comprobación de frescura.

Cómo elegir la ventana

Muchos equipos empiezan con un pequeño desfase permitido, como 5 minutos. Eso suele ser suficiente para retrasos normales de red y colas sin dejar pasar mensajes antiguos.

Un enfoque práctico:

  • Extrae la marca de tiempo del encabezado del proveedor (o del payload si ese es su formato).
  • Verifica la firma usando exactamente ese valor de timestamp como parte de la entrada firmada.
  • Compáralo con la hora de tu servidor y acepta solo si está dentro de la ventana de desfase.
  • Fuera de la ventana, trátalo como un replay aunque la firma sea válida.

La deriva de reloj es el modo silencioso de fallo. Si la hora de tu servidor está desincronizada, rechazarás eventos buenos. Mantén los hosts sincronizados y compara contra la hora del servidor, no contra la del cliente.

Para eventos tardíos, decide de antemano si los rechazas en firme o los envías a revisión manual. Si tu proveedor entrega webhooks con demora ocasional, registrar y revisar puede ser más seguro que procesarlos automáticamente fuera de tu ventana.

Claves de idempotencia: cómo funciona la dedupe en la práctica

Identify duplicate processing
Encuentra dónde los timeouts 5xx y reintentos están provocando efectos secundarios repetidos en tu app.

Una clave de idempotencia es una etiqueta de “hacer esto una vez” para un evento webhook. Cuando llega el mismo evento otra vez, consultas la clave y devuelves el mismo resultado en lugar de ejecutar la lógica de negocio dos veces.

La clave debe ser estable entre reintentos. Si el proveedor te da un event_id o message_id, úsalo. Si no, constrúyela a partir de campos que no cambien entre entregas, como un hash de nombre_del_proveedor + tipo_de_evento + id_recurso + timestamp_del_proveedor. No uses tu propia hora de recepción ni UUIDs aleatorios, porque los duplicados nunca coincidirán.

También importa el ámbito. Si soportas múltiples clientes, incluye el identificador del tenant en la clave almacenada para que un cliente no bloquee a otro por accidente.

Un modelo simple es almacenar una fila por clave de idempotencia con un estado y (opcionalmente) un resultado compacto:

  • in-progress (aceptado, trabajo no terminado)
  • processed (completado, los duplicados pueden devolver éxito inmediatamente)
  • failed (terminó con error, puedes decidir reintentar)

Mantén las claves el tiempo suficiente según tu riesgo. Si hay movimiento de dinero, consérvalas más tiempo (días o semanas). Si el impacto es bajo, una retención más corta puede bastar.

Una salvaguarda práctica: aplica unicidad a nivel de base de datos. Una restricción UNIQUE convierte la concurrencia en “el primer escritor gana”, incluso si dos copias llegan al mismo tiempo.

Almacenamiento idempotente: el patrón más sencillo y fiable

Si quieres dedupe que aguante reintentos, timeouts y peticiones paralelas, guarda la idempotencia en tu base de datos. Los caches de memoria expiran. Los locks en proceso fallan cuando escalas. Una restricción única en la base de datos es aburrida, rápida y difícil de eludir.

Elige una clave de idempotencia estable (a menudo el ID de evento del proveedor, o un hash de tenant + event ID). Crea una tabla como webhook_receipts con una restricción UNIQUE sobre esa clave.

Insertar primero, luego procesar

El flujo más seguro es escribir un recibo antes de hacer trabajo real. Dos peticiones no pueden “ganar” ambas. Una inserción tiene éxito, la otra falla y el duplicado se convierte en un no-op.

Un patrón fiable:

  • Valida firma y timestamp, luego calcula la clave de idempotencia.
  • Intenta insertar una fila de recibo con estado received.
  • Si la inserción falla por la restricción UNIQUE, trátalo como duplicado y devuelve un 2xx seguro.
  • Si la inserción tiene éxito, ejecuta la lógica de negocio y después actualiza el recibo a processed (o failed).

Devolver 2xx en duplicados suena raro, pero normalmente es correcto. El remitente está preguntando “¿Lo recibiste?” y sí lo hiciste. Reprocesar es la parte arriesgada.

Almacena un recibo mínimo

Mantén el recibo pequeño pero útil: idempotency_key, tenant_id, event_type, received_at, processed_at, status y quizá un campo corto result como “created invoice 123”. Esto también te da una pista de auditoría cuando necesitas explicar por qué pasó algo.

Paso a paso: construir un manejador de webhooks seguro

Confiabilidad y protección contra replays son la misma tarea: aceptar un evento una vez y solo una vez, aunque se entregue muchas veces.

Un flujo de petición que resiste reintentos

Mantén la ruta caliente corta y divídela en etapas:

  1. Verificar antes de parsear JSON. Lee el cuerpo crudo de la petición, valida la firma y comprueba una ventana de timestamp. Si falla, devuelve 4xx.
  2. Parsear y validar el esquema. Decodifica JSON y confirma campos obligatorios (event id, type, tenant/account).
  3. Calcular una clave de idempotencia. Prioriza el event id del proveedor.
  4. Registrar la clave con una escritura única. Si ya existe, devuelve 2xx inmediatamente. Si es nueva, continúa solo después de que la escritura tenga éxito.
  5. Ejecutar el trabajo de negocio fuera del camino crítico. Encola un job con el payload del evento (o una referencia). Desduplica en la entrada del webhook, no dentro del worker.

Después de devolver 2xx, puedes hacer acciones más lentas con seguridad: llamar a APIs de pago, enviar correos o actualizar tu base de datos.

Para solucionar problemas, adjunta un conjunto de correlación a los logs: request id, event id (clave de idempotencia), tenant id, event type y la decisión (aceptado vs duplicado). Si un cliente reporta un cargo doble, puedes trazar un evento a través de reintentos rápido.

Orden, concurrencia y casos extremos multi-tenant

Make retries a no op
Evita cargos duplicados y facturas repetidas añadiendo idempotencia segura y almacenamiento de recibos.

Los webhooks no son una cola. Puedes recibir el evento B antes que el A, o recibir el mismo evento dos veces a la vez. Si tu código asume un orden limpio, acabarás sobrescribiendo datos buenos con datos más antiguos o aplicando efectos secundarios dos veces.

Entregas fuera de orden: acéptalas

Diseña manejadores para ser seguros incluso cuando los eventos lleguen tarde. Para eventos de tipo actualización, aplica cambios solo si son más recientes que lo que ya almacenaste. “Más reciente” puede ser un número de versión, una secuencia o un updated_at proporcionado por el remitente. Si no tienes ninguno de esos, mantén tu propio marcador de “último procesado” por objeto y trata las actualizaciones antiguas como no-ops.

Además, no trates las creaciones como algo especial. Si procesas un “update” antes de un “create”, tu manejador debería hacer un upsert del registro y luego ignorar la create obsoleta.

Concurrencia: el mismo evento puede golpear dos veces a la vez

La deduplicación debe ser segura frente a carreras. Dos peticiones pueden pasar ambas un chequeo de “¿lo he visto?” antes de que cualquiera escriba la respuesta.

La restricción única en la base de datos es la solución más limpia. Inserta primero el registro de dedupe, luego haz el trabajo y marca cuando termine. Si el trabajo dura, guarda el estado (received, processing, succeeded, failed) y solo reintenta con seguridad cuando un intento previo claramente falló o agotó el tiempo.

Claves multi-tenant: evita colisiones entre clientes

Si atiendes a múltiples tenants, incluye el identificador del tenant en tu clave de dedupe. De lo contrario, dos clientes podrían compartir el mismo event_id y bloquearse entre sí por accidente.

Un formato de clave práctico es tenant_id + provider + event_id (o tenant_id + provider + object_id + version).

Las fallas parciales también importan. Si cobras una tarjeta pero crasheas antes de marcar el evento como exitoso, un reintento puede cobrar de nuevo a menos que registres lo que ya ocurrió.

Errores comunes que causan doble procesamiento

La mayoría de problemas de procesamiento duplicado son previsibles, no aleatorios.

Verificar la firma demasiado tarde es clásico. Si escribes en la base de datos, envías correo o cobras una tarjeta y solo entonces checas la firma, una petición forjada o replay aún puede hacer daño. La validación tiene que pasar antes de cualquier efecto secundario.

Otro problema frecuente es leer el cuerpo de la petición de forma incorrecta. Algunos frameworks parsean JSON y luego lo re-serializan (cambiando espacios, orden de campos o codificación). Si la firma del proveedor se calcula sobre los bytes crudos, la verificación fallará si validas contra un body modificado. No “aceptes temporalmente” firmas fallidas para mantener el sistema funcionando. Eso convierte las comprobaciones de firma en teatro.

Otros patrones que aparecen a menudo:

  • Deduplicar basándose solo en timestamps. Dos eventos reales pueden compartir timestamp y un atacante puede copiar uno.
  • Devolver 500 en duplicados. El remitente ve un error y reintenta más, creando una tormenta de reintentos.
  • Tratar “ya procesado” como una excepción en lugar de un resultado normal.
  • Registrar secretos o incluirlos en código del lado cliente.

Si detectas el mismo event ID dos veces, responde con un 2xx y no hagas nada más. Esa suele ser la forma más segura de detener reintentos.

Un ejemplo real y simple: prevenir cargos duplicados

Audit edge cases and race conditions
Revisamos claves multiinquilino, problemas de orden y fallos de concurrencia que generan resultados desordenados.

Una pesadilla común en soporte: un cliente dice que le cobraron dos veces. Tu proveedor de pagos envía un webhook payment_succeeded, tu servidor crea un pedido y luego un reintento o replay llega al mismo endpoint otra vez. Si tu manejador ejecuta la lógica de facturación o cumplimiento dos veces, ahora tienes un lío real.

La protección contra replays ayuda en capas. La verificación de firma asegura que solo tu proveedor pueda enviar eventos válidos. Una ventana de timestamp limita cuánto tiempo una petición capturada es usable. Pero los reintentos legítimos siguen siendo normales, y ahí es donde la dedupe mediante clave de idempotencia importa más.

Un patrón limpio es:

  • Extraer el ID de evento del proveedor (o construir uno a partir de campos estables).
  • Usarlo como clave de idempotencia, por ejemplo provider:event_id:account_id.
  • Insertar la clave en el almacenamiento con una restricción única.
  • Si la inserción tiene éxito, procesar el pedido.
  • Si ya existe, devolver 200 y no hacer nada.

Lo que ve el cliente: un cargo, un recibo y un estado de pedido consistente incluso si el proveedor reintenta cinco veces.

Lista de verificación y siguientes pasos

Si quieres protección contra replays de webhooks que funcione en producción, céntrate en unos pocos puntos innegociables:

  • Verifica la firma antes de cualquier lógica de negocio (y antes de loggear campos que no confías por completo).
  • Rechaza solicitudes con timestamps fuera de tu ventana permitida y mantén los servidores sincronizados por tiempo.
  • Deduplica con una clave de idempotencia única almacenada de forma atómica.
  • Devuelve 2xx consistentes para duplicados para que el remitente deje de reintentar.
  • Registra de forma segura: sin secretos, e incluye campos de correlación (event ID, request ID, tenant ID) para trazabilidad.

Una prueba rápida que detecta la mayoría de errores: envía exactamente el mismo payload de webhook cinco veces seguidas y luego envíalo otra vez tras expirar tu ventana de timestamp. Deberías ver una acción de negocio, varias respuestas rápidas de “ya procesado” y un rechazo limpio una vez que es demasiado antiguo.

Si heredaste un manejador de webhooks generado por IA que procesa duplicados, normalmente basta un pequeño conjunto de correcciones: verificación del cuerpo crudo, ventana de timestamp y idempotencia respaldada en base de datos. Si quieres una segunda opinión, FixMyMess (fixmymess.ai) puede ejecutar una auditoría de código gratuita para señalar dónde fallan la verificación, el endurecimiento de seguridad y la dedupe antes de que despliegues más cambios.

Preguntas Frecuentes

Why am I receiving the same webhook multiple times?

La mayoría de proveedores de webhooks solo garantizan la entrega al menos una vez. Si tu endpoint agota el tiempo, devuelve un 5xx o la respuesta se pierde, el proveedor reintentará el mismo evento. Esos reintentos son normales y debes diseñar tu manejador para que no haga nada en repeticiones.

What’s the difference between webhook duplicates and replay attacks?

Un duplicado suele ser un reintento legítimo del mismo evento real, causado por timeouts, errores o pérdida de la respuesta. Una replay es cuando una solicitud vieja y antes válida se envía otra vez para provocar el mismo efecto. Debes aceptar los reintentos legítimos de forma segura y rechazar replays caducados aplicando frescura y deduplicación por una clave de evento estable.

If I verify the signature, do I still need idempotency?

La firma demuestra que la carga no fue alterada y que el remitente conoce el secreto compartido, lo que bloquea la mayoría de solicitudes suplantadas. No impide que el mismo evento válido se entregue varias veces, porque los reintentos aún pueden llevar una firma correcta. Las comprobaciones de firma son necesarias, pero la deduplicación es lo que previene el procesamiento doble.

How do I validate webhook signatures correctly without false failures?

Valida exactamente contra los bytes crudos del cuerpo de la solicitud tal y como fueron recibidos, luego calcula el HMAC esperado (o el algoritmo que indique el proveedor) y compara con una función de tiempo constante. Si verificas contra un JSON parseado o re-serializado, pequeños cambios de formato pueden romper la verificación y tentar a los equipos a “aceptar temporalmente” firmas inválidas, lo cual es peligroso.

What timestamp window should I use to block replays?

Usa una ventana de desfase pequeña por defecto, típicamente alrededor de 5 minutos, para que los retrasos normales de red pasen pero las solicitudes capturadas caduquen rápido. La marca de tiempo debe estar incluida en lo que se firma; de lo contrario un atacante puede cambiarla. Mantén los servidores sincronizados por tiempo, porque la deriva de reloj es una razón común para rechazar eventos buenos.

What should I use as an idempotency key for webhook dedupe?

Empieza por el event_id o message_id del proveedor si existe, ya que permanece igual entre reintentos. Si debes construir uno, dérivalo de campos estables como nombre del proveedor, tipo de evento, ID del recurso, ID del tenant y una marca de tiempo del proveedor, normalmente hasheados. No uses tu propia hora de recepción ni un UUID aleatorio, porque los duplicados no coincidirán.

Why is database-backed dedupe better than using a cache or in-memory lock?

Un escrito en base de datos con una restricción UNIQUE es la forma más fiable de hacer la deduplicación segura frente a carreras entre varios servidores. El patrón común es “insertar el recibo primero, luego procesar”, de modo que solo una solicitud gana y las demás quedan como no-op. Los locks en memoria y caches suelen fallar cuando escalas o reinicias.

Should I return 200 or an error when I detect a duplicate webhook?

Para duplicados, devuelve de forma consistente un 2xx una vez que hayas confirmado que ya procesaste ese evento, así el proveedor deja de reintentar y evitas una tormenta de reintentos. Para firmas inválidas o marcas de tiempo fuera de tu ventana, devuelve un 4xx y no hagas trabajo. La idea clave es evitar efectos secundarios a menos que la solicitud sea auténtica y lo bastante nueva.

How do I handle out-of-order webhooks and concurrent deliveries?

Asume que los eventos pueden llegar fuera de orden y diseña manejadores seguros ante eso. Aplica cambios solo si son más recientes que lo que ya tienes (usando versión del proveedor, secuencia o updated_at cuando esté disponible), y prefiere upserts para que un “update antes del create” no te rompa. Además, haz la deduplicación segura frente a carreras con una clave de idempotencia única para que dos entregas idénticas no ejecuten ambas la lógica.

How do I stop webhook retries from causing double charges or duplicate invoices?

Una causa común es procesar el webhook dos veces porque no hay una guardia de idempotencia en el punto de entrada, o porque hay un fallo después de cobrar y antes de registrar el éxito. Revisa tus logs buscando el mismo ID de evento del proveedor apareciendo varias veces e incorpora la inserción de un recibo de idempotencia antes de los efectos secundarios. Si heredaste un manejador generado por IA que procesa duplicados, FixMyMess puede auditar el código gratis y aplicar las correcciones típicas: verificación del cuerpo crudo, ventanas de tiempo e idempotencia respaldada en base de datos.