Por qué el mismo webhook puede ejecutarse dos veces
Los proveedores reintentan los envíos, así que tu receptor puede ver el mismo evento más de una vez. Stripe lo dice en su documentación y sugiere registrar los ids de evento que ya procesaste (Stripe: eventos duplicados). Cuando tu endpoint tarda demasiado o devuelve un error, el proveedor vuelve a enviar el evento, y Stripe sigue reintentando un envío en modo live durante hasta tres días con espera exponencial (Stripe: reintentos automáticos). Si nada recuerda que el evento ya se procesó, el trabajo detrás vuelve a ejecutarse: un segundo cobro, un segundo pedido, una segunda fila en el CRM. En una tienda, un webhook que se ejecuta dos veces puede enviar dos paquetes o dos cupones para un solo pedido.
Una clave estable, reservada una sola vez
La solución es una clave que no cambia entre reintentos y que se reserva una sola vez. Cada reintento de Stripe lleva una firma y una marca de tiempo nuevas, así que la cabecera stripe-signature no puede ser la clave. El id del evento sí: la plantilla usa evt: más el id del cuerpo, y cuando el cuerpo no tiene id usa sha256: más el SHA-256 del cuerpo en bruto. La reserva es una sola sentencia sobre una clave primaria, INSERT ... ON CONFLICT (key) DO NOTHING RETURNING key, así que solo un envío recibe la fila, incluso cuando llegan dos a la vez. Si vuelve una fila, se ejecuta el paso de negocio. Si no vuelve ninguna, el evento ya se había visto: el receptor responde 200 igualmente, y el cobro, el pedido o la fila del CRM no se crean por segunda vez. El orden de las comprobaciones también importa para la seguridad. Verifica primero la firma del proveedor (Stripe: verificar eventos) y después reserva la clave, para que una petición falsificada no pueda reservar un id de evento real. La plantilla no verifica firmas, así que esa comprobación va delante de ella.
Qué pasó cuando lo ejecutamos
Ejecutamos la plantilla de n8n de abajo contra Postgres en nuestra propia máquina, con n8n 2.42.6 y Postgres 16. Enviamos el evento A, luego el evento A otra vez con una cabecera de firma distinta, y luego el evento B. n8n registró 3 ejecuciones y la tabla de negocio recibió 2 filas. La repetición de A recibió una respuesta 200 y no ejecutó el paso de negocio, así que el reintento tuvo respuesta y nada se escribió dos veces. Después enviamos dos veces un cuerpo sin id: la clave fue el SHA-256 del cuerpo en bruto, y la segunda llamada tomó la rama de duplicado. En una prueba aparte, enviamos a Postgres 8 reservas simultáneas de una misma clave con el SQL que genera esta página: una recibió la fila, y el paso de negocio se ejecutó una vez.
Cómo llevarlo a n8n
Quien gestiona n8n puede importar el flujo que exportamos tras esa prueba, sin el id de credencial del laboratorio, apuntarlo a Postgres y cambiar el paso de negocio por sus propios nodos. El nodo Webhook usa el modo de respuesta "Using Respond to Webhook node", así que responde la rama que se ejecutó. El nodo Code construye la clave, un nodo Postgres hace la reserva, y "Row Returned?" manda un evento nuevo al paso de negocio y una repetición a "Respond 200 duplicate".
Plantilla de flujo de n8n (Webhook, Code, Postgres, IF, Respond to Webhook)
El flujo exportado como archivo .json, listo para importar.
-
Crea las tablas
CREATE TABLE IF NOT EXISTS processed_events ( key text PRIMARY KEY, first_seen timestamptz NOT NULL DEFAULT now() ); -- Tabla de negocio de ejemplo que usa la plantilla. Cámbiala por tu propio paso. CREATE TABLE IF NOT EXISTS business_log ( id bigserial PRIMARY KEY, event_key text NOT NULL, event_type text, created_at timestamptz NOT NULL DEFAULT now() );
-
Configura el entorno y las credenciales
n8n autoalojado bloquea por defecto los módulos integrados en el nodo Code. Define
NODE_FUNCTION_ALLOW_BUILTIN=cryptoen el contenedor de n8n para querequire('crypto')funcione. Después de importar, abre los dos nodos Postgres y elige tu credencial de Postgres (host, puerto, base de datos, usuario, contraseña). n8n también puede guardar datos de ejecución, incluido el cuerpo del webhook, según tu configuración de ejecuciones. Si el cuerpo contiene datos personales, revisa esa configuración y su retención. -
Importa, conecta tu paso, publica
En n8n, importa el archivo .json. Cambia "Business Step (insert business_log)" por tus propios nodos, mantenlos en la rama true de "Row Returned?" y publica el flujo. Pon tu comprobación de firma delante del nodo Code si tu proveedor firma las peticiones.
-
Prueba con un envío repetido
Envía el mismo evento dos veces y un evento distinto una vez. En Executions deberías ver que la primera y la tercera ejecución pasan por el paso de negocio, y que la segunda toma "Respond 200 duplicate".
-
Limpia las claves antiguas
La limpieza es un paso aparte, fuera del flujo del webhook. Guarda las claves más tiempo del que el proveedor puede reenviar. Stripe permite un reenvío manual desde el Dashboard durante hasta 15 días y desde la CLI durante hasta 30 días (Stripe: reintentos manuales). Un flujo programado de n8n o una tarea cron puede ejecutar:
DELETE FROM processed_events WHERE first_seen < now() - interval '30 days';
Límite: la clave se reserva antes de que se ejecute el paso de negocio. Si el paso de negocio falla después de la reserva, un reintento de ese evento se trata como duplicado. Añade una ruta de error que ejecute DELETE FROM processed_events WHERE key = $1 para esa clave, o corrige y reenvía el evento a mano.
Qué se guarda y qué se queda en tu navegador
La clave de esta página se calcula con crypto.subtle.digest en tu navegador, y el script de la página no hace ninguna petición de red con lo que pegas. En Postgres, processed_events guarda la clave y una marca de tiempo. La clave es el id del evento del proveedor, o un SHA-256 del cuerpo cuando no hay id. La tabla de ejemplo business_log guarda la clave y el tipo de evento, y tu propio paso de negocio decide qué más se guarda. El planificador de arriba genera la misma reserva que la plantilla de n8n: INSERT ... ON CONFLICT (key) DO NOTHING RETURNING key, una rama según la fila devuelta y un DELETE de limpieza aparte. Estas notas describen el código de esta página y de la plantilla. No son una certificación.