← EXCOIN / ARTÍCULOS

Receptores de webhooks de pagos: idempotencia, duplicados y eventos desordenados

Guía técnica para construir receptores de webhooks de pagos resilientes: verificación de firma, acuse rápido, deduplicación con restricción única en PostgreSQL, claves de idempotencia en llamadas salientes, manejo de eventos desordenados y monitoreo del backlog, basada en documentación pública de Stripe, Adyen y PostgreSQL, con sus límites señalados.

Los proveedores de pagos entregan eventos por HTTP, y la entrega por HTTP falla de maneras ordinarias: tiempos de espera agotados, despliegues que cortan conexiones, reintentos tras una respuesta lenta. Un receptor de webhooks que supone que cada evento llega exactamente una vez, a tiempo y en orden acabará, tarde o temprano, corrompiendo el estado. Este artículo recorre un diseño de receptor que trata los duplicados, los retrasos y el reordenamiento como condiciones normales y no como excepciones. Se apoya en la documentación pública de Stripe, Adyen y PostgreSQL para las afirmaciones concretas, y señala dónde las fuentes no dicen lo suficiente para tener certeza.

Por qué los manejadores de webhooks deben asumir duplicados

Los webhooks existen porque muchos flujos de pago son asíncronos. La documentación de webhooks de Stripe (https://docs.stripe.com/webhooks) describe el envío de datos en tiempo real a tu endpoint HTTPS cuando ocurren eventos, por ejemplo cuando el banco de un cliente confirma un pago, cuando un cliente abre una disputa o cuando un pago recurrente se realiza con éxito. La documentación de Adyen (https://docs.adyen.com/development-resources/webhooks/) plantea lo mismo: el estado final de un recurso suele ser desconocido justo después de la solicitud a la API, de modo que el proveedor te notifica cuando el resultado está disponible, y los webhooks también transportan eventos que se originan fuera de tus propias solicitudes, como los contracargos y la disponibilidad de informes.

Conviene fijarse en lo que estas descripciones implican. Los eventos te los empuja un tercero según su propio calendario. Los extractos que revisamos no especifican calendarios de reintento exactos ni garantías de orden, por lo que un diseño seguro no debe depender de ningún comportamiento concreto. Si necesitas esos detalles para un proveedor específico, lee directamente su documentación vigente sobre entrega y reintentos en lugar de inferirlos de una guía general como esta.

Tres modos de fallo condicionan el diseño:

La idempotencia, es decir, que procesar la misma entrada dos veces tenga el mismo efecto que procesarla una sola vez, es la propiedad central que neutraliza el primer fallo y ayuda con los otros dos.

Verifica la firma antes de confiar en la carga útil

Un endpoint de webhook es una URL pública. Stripe exige que los endpoints registrados sean URL HTTPS accesibles públicamente (https://docs.stripe.com/webhooks), lo que significa que cualquiera que descubra la URL puede enviarle solicitudes. Por eso el proveedor te da una forma de demostrar la autenticidad. En el flujo de configuración de Stripe, crear un destino de webhook genera un secreto de firma que empieza por whsec_, que usas al construir el manejador. El árbol de documentación de Adyen incluye páginas dedicadas a la seguridad de los webhooks y a la verificación de firmas HMAC (https://docs.adyen.com/development-resources/webhooks/).

Reglas prácticas que se derivan de esto:

1. Verifica antes de ejecutar lógica de negocio. Calcula o comprueba la firma sobre el cuerpo crudo de la solicitud exactamente como se recibió. Volver a serializar el JSON ya parseado puede alterar los bytes y romper la verificación. 2. Rechaza cuando falle. Devuelve un estado 4xx para las solicitudes cuya firma no valide, y no las pongas en cola. 3. Mantén los secretos fuera del código y de los registros. Guarda el secreto de firma en tu gestor de secretos y rótalo según el proceso de tu proveedor. 4. Primero verifica, después deduplica. Nunca escribas en tu tabla de deduplicación el ID de un evento sin verificar; de lo contrario, un atacante podría envenenarla y hacer que eventos legítimos se descarten como duplicados.

El algoritmo de verificación exacto y las ventanas de tolerancia dependen de cada proveedor. Usa la biblioteca oficial del proveedor cuando exista en lugar de implementar tú mismo la comparación.

Acusa recibo rápido, procesa después

La guía de Adyen es directa: acepta el webhook con un código de estado HTTP 2xx, almacena el mensaje y luego procesa su contenido (https://docs.adyen.com/development-resources/webhooks/). Esa secuencia es la columna vertebral de un receptor resiliente. La solicitud HTTP debe hacer lo mínimo posible: verificar la firma, persistir el evento crudo de forma duradera y devolver éxito. Todo lo costoso ocurre después, en un worker.

Esta separación importa porque el procesamiento síncrono lento genera sus propios duplicados. Si tu manejador llama a tres servicios descendentes antes de responder y uno de ellos se bloquea, el proveedor puede considerar fallida la entrega y reenviar el evento mientras tu primer intento sigue en ejecución. Ahora hay dos copias del mismo evento en vuelo de forma concurrente. Acusar recibo después del almacenamiento duradero elimina la mayor parte de esa ventana. Los extractos que revisamos no indican los valores de tiempo de espera del proveedor, así que trata el umbral exacto como algo que debes confirmar en la documentación de tu proveedor.

Una forma razonable para el receptor:

1. Lee el cuerpo crudo. 2. Verifica la firma; devuelve 4xx si falla. 3. Inserta el evento en una tabla duradera (detalles en la siguiente sección). 4. Devuelve 2xx. 5. Un worker independiente reclama las filas sin procesar y ejecuta la lógica de negocio.

Si el paso 3 falla porque tu base de datos está caída, devuelve un 5xx para que el proveedor reintente. Devolver 2xx antes de que el evento esté almacenado de forma segura anula el propósito.

Deduplica con una restricción única en PostgreSQL

La defensa más fiable contra duplicados es la que impone la propia base de datos. Crea una tabla de eventos con una restricción única sobre el ID de evento del proveedor:

CREATE TABLE webhook_events ( id bigserial PRIMARY KEY, provider text NOT NULL, event_id text NOT NULL, payload jsonb NOT NULL, received_at timestamptz NOT NULL DEFAULT now(), processed_at timestamptz, UNIQUE (provider, event_id) );

Luego el receptor almacena los eventos con:

INSERT INTO webhook_events (provider, event_id, payload) VALUES ($1, $2, $3) ON CONFLICT (provider, event_id) DO NOTHING RETURNING id;

La documentación de INSERT de PostgreSQL (https://www.postgresql.org/docs/current/sql-insert.html) explica por qué funciona. ON CONFLICT especifica una acción alternativa a lanzar un error por violación de restricción única, y DO NOTHING es una de las acciones de conflicto disponibles. La cláusula RETURNING devuelve valores solo para las filas que realmente se insertaron, o se actualizaron cuando se usa DO UPDATE. Así, si RETURNING devuelve una fila, esta entrega es nueva; si no devuelve nada, ya existe una copia y aun así puedes acusar recibo con 2xx. Como la comprobación de unicidad la impone el índice dentro de la base de datos, dos entregas concurrentes del mismo evento no pueden insertarse ambas, algo que un SELECT seguido de INSERT a nivel de aplicación no puede garantizar.

Algunas decisiones de diseño merecen explicación:

Una salvedad de la página de PostgreSQL: las inserciones en tablas con índices únicos pueden bloquearse cuando sesiones concurrentes bloquean o modifican filas con los mismos valores únicos. En una tabla de webhooks esto dura poco, pero conviene saberlo si observas esperas de bloqueo ante ráfagas de entregas duplicadas.

La deduplicación a nivel de evento te protege de la misma entrega dos veces. No hace por sí sola que tu lógica de negocio sea segura: el worker puede caerse después de realizar un efecto secundario pero antes de establecer processed_at, y un reintento lo repetirá. Ahí entra la idempotencia en las llamadas salientes.

Usa claves de idempotencia en las llamadas salientes a la API

Los workers suelen llamar de vuelta al proveedor, por ejemplo para crear un reembolso, capturar un pago o crear un objeto. Si ocurre un error de conexión a mitad de la llamada, no puedes saber si la operación se realizó. La referencia de solicitudes idempotentes de Stripe (https://docs.stripe.com/api/idempotent_requests) aborda exactamente esto: aportas una clave de idempotencia para que una solicitud pueda reintentarse sin ejecutar dos veces la misma operación.

Detalles clave de esa página que conviene tener en cuenta al diseñar:

La decisión de diseño crucial es derivar la clave de forma determinista a partir de tu propio estado en lugar de generar un valor aleatorio nuevo en cada intento. Una clave aleatoria nueva por reintento anula el mecanismo. En su lugar, persiste la clave junto con la unidad de trabajo, o derívala de algo estable, como el ID de la fila del evento más el nombre de la acción (por ejemplo, refund para el evento 4812). Así, la misma operación lógica presenta siempre la misma clave, incluso tras caídas de workers y reentregas. Dada la nota sobre la eliminación a las 24 horas en v1, no confíes en una clave para proteger un reintento que ocurra días después; registra también la finalización en tu propia base de datos.

Adyen también documenta la idempotencia de su API en su lista de recursos (https://docs.adyen.com/development-resources/webhooks/ enlaza a ella), pero el extracto que revisamos no describe su comportamiento, así que consulta esa página antes de suponer que coincide con el de Stripe.

Maneja eventos retrasados y desordenados

La deduplicación responde a si ya viste un evento; no responde a si el evento sigue siendo relevante. Adyen señala que los mensajes de webhook contienen una instantánea del recurso para que tu sistema actualice su estado en consecuencia (https://docs.adyen.com/development-resources/webhooks/). Las instantáneas son cómodas pero peligrosas cuando se reordenan: aplicar una instantánea antigua después de una más reciente hace retroceder tu estado.

Estrategias, de la más simple a la más robusta:

1. Máquinas de estado monótonas. Define las transiciones permitidas (por ejemplo, pendiente a pagado a reembolsado) e ignora cualquier evento que haría retroceder el estado. Un evento desordenado pasa a ser entonces una operación nula inofensiva. 2. Compara marcas de tiempo o campos de versión cuando el proveedor los aporte. Aplica un evento solo si es más reciente que lo que ya tienes almacenado. Confirma en el esquema del proveedor qué campo es el autoritativo; los extractos que revisamos no especifican ninguno. 3. Vuelve a consultar el objeto actual. Cuando llega un evento, trátalo como una señal y lee el estado más reciente desde la API del proveedor antes de actualizar. Esto cambia una llamada adicional por resiliencia frente a cargas útiles obsoletas, y el GET es idempotente por naturaleza. 4. Orden por entidad en el worker. Procesa los eventos del mismo pago o cliente en serie para que dos workers nunca compitan sobre un mismo objeto.

Elige según el riesgo. Para transiciones que mueven dinero, prefiere una máquina de estado más una nueva consulta; para notificaciones de bajo riesgo, basta con una guarda sencilla.

Monitorea el backlog de eventos

Una vez desacoplados el acuse de recibo y el procesamiento, aparece un nuevo modo de fallo: el proveedor ve éxito mientras tu worker se queda atrás en silencio. El monitoreo debe cubrir tu lado de la canalización.

Señales útiles, todas calculables a partir de la tabla de eventos anterior:

Fija los umbrales de alerta a partir de tus propios patrones de tráfico; las fuentes no ofrecen cifras universales, e inventarlas no te ayudaría.

Lista de verificación de implementación

Limitaciones

Este artículo se basa en un conjunto limitado de extractos de documentación. Varios de ellos estaban truncados, por lo que no cubre los ejemplos de código de manejadores de Stripe, los formatos de carga útil delgada frente a instantánea en profundidad, ni los pasos de verificación HMAC de Adyen. No indica calendarios de reintento, umbrales de tiempo de espera ni garantías de orden de los proveedores porque el material revisado no los especificaba. La semántica de idempotencia difiere entre proveedores y versiones de API, así que confirma el comportamiento de los endpoints exactos que uses. El SQL mostrado es un patrón de partida; adapta el esquema, la indexación y la reclamación de filas por los workers a tu propia carga y a tus estándares operativos, y prueba escenarios de fallo como la entrega duplicada concurrente y las caídas a mitad del procesamiento antes de confiar en el diseño en producción.

Fuentes

Consultar proyecto
Receptores de webhooks de pagos: idempotencia, duplicados y eventos desordenados | EXCOIN