En resumen
Los webhooks duplicados son problema del receptor, porque una entrega por red solo puede prometer una vez como mínimo. Un ciclo de Paymos son 11 intentos repartidos en unas 16 horas, y un evento fallido se puede reenviar después. Descarta entregas por el id `evt_` que viaja en `X-Webhook-Id`, y apunta pedidos contra el id de recurso que viaja dentro de `data`. El primero cambia con cada endpoint; el segundo no cambia nunca.
Un webhook de pago puede llegar dos veces. El único sitio donde la segunda llegada se vuelve inofensiva es el receptor, porque una entrega por red promete una vez como mínimo: quien envía y no recibe respuesta dentro de su tiempo de espera no distingue una petición perdida de una respuesta perdida, así que vuelve a preguntar. Un ciclo de entrega de Paymos son 11 intentos repartidos en unas 16 horas, y después todavía puedes reenviar el evento desde el panel. Cada entrega trae su propio id evt_… en X-Webhook-Id. La factura que viaja dentro de data trae otro que no cambia nunca. Descarta por el primero, apunta pedidos contra el segundo, y una repetición cuesta una consulta de índice.
El contrato de entrega está en la documentación de webhooks. Aquí va por qué tiene esa forma y qué le pide al código de tu lado.
¿Por qué nadie puede prometer una sola entrega?
Porque nada en una red puede. Dos partes que solo hablan por un canal capaz de perder mensajes no alcanzan la certeza sobre el estado de la otra, y eso está demostrado desde 1975; no es una cuestión de presupuesto.
Mira lo que eso le hace a un webhook. Quien envía abre la petición, escribe el cuerpo y espera. El tiempo de espera de 10 segundos se agota sin nada en el socket. ¿No lo vio nunca el receptor? ¿O verificó la firma, abonó el pedido y perdió la respuesta de vuelta? Desde el lado que envía las dos cosas son el mismo silencio.
Queda una elección, y solo una de sus mitades es honesta con el dinero. Enviar una vez, y una factura pagada a veces no llega nunca al sistema de pedidos. Enviar otra vez, y a veces un handler corre dos veces sobre un mismo pago, que resulta ser el único fallo contra el que un receptor puede programar.
¿Sobre qué id se descartan los repetidos?
Dos identidades viajan en una entrega, y escoger la equivocada es el fallo que este diseño intenta evitar.
X-Webhook-Id lleva el mismo valor que event_id en el cuerpo, un evt_…, y nombra la entrega: la copia que un endpoint tiene de una transición. Cada reintento de esa entrega lo lleva, y un reenvío manual lo conserva.
El id de recurso que va en data es un inv_…, wdr_… o pcd_…, y nombra aquello a lo que le pasó el dinero. No cambia entre los endpoints suscritos a ese evento, ni cuando el recurso avanza al estado siguiente. La referencia de payloads trae el sobre entero.
Ahora las dos maneras de equivocarse. Un comercio con dos endpoints que usa event_id como clave de su tabla de pedidos ve dos id distintos para un pago y lo apunta dos veces. Un comercio que descarta entregas por el id de factura bloquea la segunda entrega de esa factura; esa segunda es el invoice.paid que llega detrás del invoice.confirming, así que el pedido no sale nunca.
Stripe llega al mismo reparto desde el otro lado, y recomienda apoyarse en el id del objeto en data.object junto con el tipo de evento cuando dos objetos de evento distintos describen una misma cosa.
¿Por qué se escribe la fila antes de trabajar?
Escribir la fila primero es una regla de recuperación ante caídas, y se gana el sueldo en una ventana estrecha. Un handler que entrega el pedido y luego anota el evento abre un hueco entre las dos cosas. Un proceso que muere dentro de ese hueco deja un pedido enviado del que no queda constancia, y la siguiente entrega lo envía otra vez.
Dale la vuelta al handler. Verifica la firma, inserta la entrega, responde, y deja el resto a un proceso aparte que corra al ritmo que tú decidas.
create table webhook_delivery (
event_id text primary key, -- evt_…, esta entrega
resource_id text not null, -- inv_… / wdr_… / pcd_…, el pago
event_type text not null,
body jsonb not null,
received_at timestamptz not null default now(),
processed_at timestamptz
);
insert into webhook_delivery (event_id, resource_id, event_type, body)
values ($1, $2, $3, $4)
on conflict (event_id) do nothing
returning event_id;
Cero filas de vuelta significa que esta entrega ya está registrada: responde 2xx y para, porque no queda nada que decidir. Una fila de vuelta significa que el trabajo es tuyo, y el 2xx que mandas es un acuse de los bytes, no la afirmación de que se haya enviado nada.
Ese reparto es también lo que mantiene al handler dentro del tiempo de espera de 10 segundos. La llamada a un tercero que se queda colgada, y la licencia que un mal día tarda en emitirse, van las dos detrás de una cola con sus propios reintentos.
¿Es esto una peculiaridad de las cripto?
La repetición no lo es. Google documenta la entrega de al menos una vez como el comportamiento por defecto de Pub/Sub en todos los tipos de suscripción, y le pide al suscriptor que sea idempotente. La página de webhooks de Stripe dice que un endpoint recibe de vez en cuando el mismo evento más de una vez, y que la respuesta es registrar los id ya procesados. Misma restricción, misma conclusión.
Lo que cambia entre productos es el precio del duplicado. Un ping de analítica repetido tuerce una gráfica. Un invoice.paid repetido manda un segundo paquete o emite una segunda licencia, y el sistema de pedidos no se entera, porque cada ejecución por separado parecía correcta.
El RFC 9110 es preciso con la palabra: un método es idempotente cuando el efecto buscado de varias peticiones idénticas es el mismo que el de una sola. Es una propiedad de lo que el receptor hace con el evento, no de lo que quien envía pone en el cable. Una cabecera puede llevar una clave; igualar el efecto solo puede hacerlo el receptor.
¿Qué no arregla el patrón outbox?
Los duplicados, y eso no es un defecto del patrón. Un cambio de estado y un mensaje tienen que salir juntos, y ninguna transacción abarca a la vez una base de datos y una red. Escribe la fila primero y una caída anterior al envío pierde el mensaje. Envía primero y una caída anterior a la escritura anuncia algo que no ocurrió. Eso es la doble escritura, y el outbox transaccional es la respuesta estándar: el mensaje entra en la misma base de datos, dentro de la misma transacción que el hecho que describe, y un relé lo saca al cable.
Lee la garantía despacio, porque es más estrecha que su fama. El mensaje existe si y solo si la transacción se confirmó. La otra mitad la cuenta Richardson entre los problemas del propio patrón: el relé puede publicar un mensaje, morirse antes de anotar que lo publicó, y publicarlo otra vez al reiniciar.
El cambio merece nombrarse. Un mensaje que se esfumó no deja rastro en ninguna parte; uno que se repite llega con el id que ya traía la primera vez, y un id es algo que un receptor puede indexar. Desde fuera no se ve si quien envía usa outbox o no. Lo que llega a tu endpoint es la propiedad que produce.
¿Qué hace seguro un reenvío?
Un reenvío se lanza a mano desde el panel, sobre eventos fallidos, y conserva el id de esa entrega. Así que un reenvío de algo que tu tabla ya guarda se caza en la puerta, al precio de una consulta de índice.
El caso interesante es el otro. Un endpoint que se pasó la caída rechazando entregas tiene la tabla vacía, así que tras el arreglo cada evento reenviado le resulta nuevo. Mientras tanto, el estado de negocio detrás de esos eventos puede estar ya correcto, porque alguien de soporte leyó el panel y marcó el pedido como pagado, o una conciliación nocturna llegó antes.
Una tabla de descarte no ve nada de eso. Sabe qué entregas ha leído y nada más. La protección para ese caso va en la transición: una factura ya apuntada como pagada no se vuelve a apuntar, y esa comprobación va dentro de la misma transacción que la escritura que protege.
¿Cuánto tiempo sigue volviendo un evento?
Once intentos, con huecos que crecen de un minuto al principio a ocho horas al final, lo que deja el último unas 16 horas después del primero. El calendario de entrega está publicado intento a intento.
Léelo como dos hechos distintos sobre tus propias caídas. Un despliegue pasa desapercibido: los primeros peldaños están a minutos de distancia, así que el evento vuelve antes de que nadie abra un panel. Una caída de base de datos que dura una jornada entera no pasa desapercibida, y los eventos cuyo ciclo se agotó dentro quedan marcados como fallidos.
Al endpoint no le pasa nada. Cuando falla el intento número once, lo único que queda marcado como fallido es el evento; el endpoint se queda como estaba, activo y suscrito. No hay contador de faltas ni nada que volver a encender. Un receptor que estuvo caído toda la tarde vuelve a un endpoint vivo y a una lista de eventos fallidos que esperan reenvío.
El panel enseña estado de entrega, número de intentos y próximo reintento de los cien eventos más recientes, del más nuevo al más viejo, y no pasa de ahí. Trátalo como una ventana al último tramo de tráfico. El archivo es tu propia tabla de entregas.
¿Qué se rompe mientras rota el secreto?
Durante las 24 horas siguientes a una rotación la cabecera de firma lleva dos valores v1 en vez de uno, y una entrega es válida si coincide con cualquiera de los dos. Esa ventana es lo que le permite al receptor recoger el secreto nuevo con su propio calendario de despliegue.
Un receptor que lee v1 como un campo y no como una lista falla todas las entregas de esa ventana. Cada fallo es un intento del escalón. Dieciséis horas más tarde esos eventos quedan marcados como fallidos, y el primer síntoma visible suele ser un pedido que nunca salió.
Así que interpreta v1 como lista, compara cada candidato con una función de tiempo constante y acepta al primer acierto. La página de firma detalla qué se resume y en qué orden. Los ocho SDK oficiales de Paymos ya funcionan así, y la suite de conformidad fija una cabecera de dos valores como vector de prueba, de modo que una integración construida sobre un SDK lo hereda.
Un detalle que se malinterpreta a menudo: Paymos sella cada entrega con X-Webhook-Timestamp, y cuánta desviación de reloj aceptas al verificarla la decides tú. No hay una ventana impuesta desde este lado.
¿Cómo se demuestra que el receptor es idempotente?
Reenviando, no leyendo el código. La propiedad que se prueba es una afirmación sobre la segunda ejecución, y la segunda ejecución es justo lo que un test unitario no suele producir.
La consola de API del panel manda peticiones reales firmadas con HMAC usando las credenciales del propio comercio, solo en Sandbox, y la de webhooks produce una entrega de verdad. Con eso basta para pasar la comprobación entera contra un receptor de pruebas:
- Lanza una entrega, deja que el handler termine y apunta la fila de pedido que creó.
- Lanza el mismo evento otra vez. Tiene que llegar a tu tabla, encontrar el id y devolver
2xxsin tocar el pedido. - Haz que el receptor se quede colgado un par de minutos: los 10 segundos de espera se agotan y el primer reintento entra mientras la primera ejecución sigue en marcha. Ese choque es para lo que está el índice único.
- Reenvía un evento fallido después de arreglar el receptor, y confirma que un pedido ya apuntado se queda apuntado una sola vez.
- Compara tu tabla de entregas con las facturas que la API da por pagadas. Una fila que falta es un problema de suscripción o de cortafuegos; una entrega doble es un problema de clave.
Pasa esas cinco antes de que el receptor salga a producción y el escalón de reintentos se convierte en ruido de fondo. El intento once se trata igual que el primero: el id ya está registrado, y nada se mueve detrás de él.
| Sobre qué clave descartas | Reintento de una entrega | Dos endpoints, un pago | confirming y después paid | |
|---|---|---|---|---|
| event_id para las dos cosas | Bloqueado | El pedido se apunta dos veces | Los dos se procesan | |
| Id de factura para las dos cosas | Bloqueado | Bloqueado | Se pierde paid | |
| event_id para descartar, id de factura para atribuir | Bloqueado | Bloqueado | Los dos se procesan |
Preguntas frecuentes
¿Por qué he recibido dos veces el mismo webhook de pago?
La entrega es de una vez como mínimo. Un intento que agota el tiempo de espera se reintenta aunque el receptor lo haya procesado, porque desde el lado que envía una petición perdida y una respuesta perdida se parecen. Un reenvío manual produce otra repetición.
¿Descarto por event_id o por el id de la factura?
Por event_id, que es el mismo valor de la cabecera X-Webhook-Id, para las entregas. Por el id de la factura para el pedido. El event_id cambia entre dos endpoints suscritos a un mismo pago; el id de la factura es el mismo en todas partes y en todos los estados por los que pasa.
¿Qué tiene que devolver mi handler ante un evento ya procesado?
Un 2xx, y enseguida. Una repetición reconocida es una entrega correcta. Devolver un error vuelve a meter el evento en el escalón de reintentos sin ningún motivo.
¿Un webhook reenviado llega con un id de evento nuevo?
No. Un reenvío conserva el id `evt_` de esa entrega, así que un receptor que lo guardó la primera vez reconoce la repetición con una consulta de índice.
¿Cuánto tiempo se reintenta un webhook fallido?
Unas 16 horas y 11 intentos. El primer reintento llega al minuto; el último, tras ocho horas. Después el evento pasa a fallido y solo vuelve si alguien lo reenvía. El endpoint sigue activo.
Cuándo NO conviene usar la entrega por webhook
- Si el receptor solo existe en un portátil o dentro de una red privada, la entrega no tiene dónde aterrizar. Un destino que no sea público y HTTPS se rechaza, y una redirección hacia dentro no se sigue. Mientras desarrollas, consulta el estado de la factura por API.
- Si te llegan un puñado de pagos al día y ya los revisa una persona, una tabla de entregas, una cola y una clave de descarte son más maquinaria de la que ese volumen paga.
- Si tu sistema de pedidos no sabe rechazar una transición repetida, arregla eso antes de conectar un endpoint. Una clave de descarte delante de un camino de entrega que va a correr dos veces estrecha la ventana sin cerrarla.
- Si quieres un flujo con un solo nombre de evento, la suscripción no te lo va a dar. Funciona por categorías, los nombres sueltos de dentro no se eligen por separado, y un endpoint suscrito a facturas recibe todos los eventos de factura. Ramifica en el handler.
Fuentes
- 1. HTTP Semantics (RFC 9110), apartado 9.2.2 — métodos idempotentes (accessed 2026-09-15)
- 2. Google Cloud Pub/Sub — suscripciones y entrega de al menos una vez (accessed 2026-09-15)
- 3. Stripe — Receive Stripe events in your webhook endpoint (accessed 2026-09-15)
- 4. Chris Richardson — patrón outbox transaccional (accessed 2026-09-15)
- 5. Problema de los dos generales — Akkoyunlu, Ekanadham y Huber (1975) (accessed 2026-09-15)
Última revisión: 15 sept 2026


