Webhooks e integraciones
Cómo evitar webhooks duplicados con idempotencia
Aprende a procesar webhooks repetidos una sola vez mediante identificadores estables, operaciones atómicas y estados recuperables.
Por Equipo AltokePayPublicado Actualizado 9 min de lectura
Un receptor debe asumir que el mismo evento puede llegar varias veces. Quizá se perdió la respuesta, venció el tiempo de espera o alguien reprodujo una entrega. El emisor no siempre distingue entre “la acción falló” y “la acción terminó, pero no recibí la respuesta”.
La idempotencia busca que repetir una solicitud con la misma identidad no repita efectos como crear dos envíos o descontar inventario dos veces. No garantiza procesamiento exactamente una vez en cualquier arquitectura: se diseña para operaciones concretas.
Cada proveedor define su contrato: dónde aparece el identificador, cuánto tiempo puede conservarse, si una reproducción mantiene la misma identidad y qué orden tienen los eventos. Antes de implementar, confirma esas reglas en su documentación. Los ejemplos siguientes usan nombres genéricos y no describen ningún producto real.
Entrega al menos una vez y ambigüedad
Muchos sistemas de eventos prefieren repetir antes que perder una notificación. Esto se conoce como entrega al menos una vez. El receptor puede ver una o varias copias, pero debe llevar el resultado a un estado coherente.
Imagina este flujo:
- llega
event-481; - el receptor crea una orden;
- antes de responder, la conexión se corta;
- el emisor considera fallida la entrega;
- un reintento con exponential backoff envía
event-481otra vez.
Responder más rápido reduce la ventana, pero no elimina la ambigüedad. La solución no es confiar en que la red entregue una única copia, sino hacer que la segunda solicitud reconozca el trabajo anterior.
Elegir una clave idempotente
La mejor clave suele ser un identificador estable asignado por el emisor. No uses solo el tipo, la fecha o el contenido: eventos legítimos podrían compartirlos. Tampoco generes un UUID al recibir cada solicitud, porque cada repetición obtendría otra identidad.
Si falta un identificador, pregunta qué combinación recomienda el proveedor. Una huella del cuerpo tiene riesgos: la serialización puede cambiar y dos eventos separados podrían tener contenido idéntico. La decisión requiere semántica de dominio.
El alcance de la clave importa. event-481 podría ser único globalmente, por cuenta o por endpoint. Una clave compuesta como (source_id, event_id) evita colisiones entre emisores cuando el contrato solo promete unicidad local.
Reclamar el evento de forma atómica
Este patrón parece correcto, pero tiene una carrera:
si el evento no existe:
ejecutar acción
guardar evento
Dos trabajadores pueden comprobar al mismo tiempo que no existe y ambos ejecutar la acción. La decisión debe apoyarse en una restricción única o en una operación atómica de la base de datos.
Un esquema mínimo puede separar el evento recibido del recurso de negocio:
CREATE TABLE processed_webhook_events (
source_id TEXT NOT NULL,
event_id TEXT NOT NULL,
status TEXT NOT NULL,
received_at TEXT NOT NULL,
completed_at TEXT,
PRIMARY KEY (source_id, event_id)
);
Insertar primero con status = 'processing' reclama la identidad. Si otra transacción intenta insertar la misma clave, reconoce el duplicado. Sin embargo, marcar “procesado” antes de ejecutar la acción puede perder trabajo si el proceso cae. Marcarlo después puede repetir trabajo si la acción está fuera de la misma transacción. Hay que diseñar ambos cambios como una unidad o usar un flujo recuperable.
Ejemplo en Python con SQLite
El ejemplo usa únicamente la biblioteca estándar y modela una acción local que puede compartir transacción con la reclamación. En una aplicación real, el motor, los niveles de aislamiento y la estrategia de concurrencia deben revisarse según la documentación correspondiente.
import json
import os
import sqlite3
from datetime import datetime, timezone
DATABASE_PATH = os.environ.get("WEBHOOK_DATABASE_PATH", "webhooks.db")
def process_event(raw_body: bytes) -> str:
event = json.loads(raw_body)
if not isinstance(event, dict):
raise ValueError("Invalid event")
event_id = event.get("id")
event_type = event.get("type")
if not isinstance(event_id, str) or not isinstance(event_type, str):
raise ValueError("Invalid event")
now = datetime.now(timezone.utc).isoformat()
with sqlite3.connect(DATABASE_PATH) as connection:
cursor = connection.execute(
"""
INSERT OR IGNORE INTO processed_webhook_events
(source_id, event_id, status, received_at)
VALUES (?, ?, 'processing', ?)
""",
("generic-provider", event_id, now),
)
if cursor.rowcount == 0:
return "duplicate"
connection.execute(
"""
INSERT INTO audit_entries (event_id, event_type, created_at)
VALUES (?, ?, ?)
""",
(event_id, event_type, now),
)
connection.execute(
"""
UPDATE processed_webhook_events
SET status = 'completed', completed_at = ?
WHERE source_id = ? AND event_id = ?
""",
(now, "generic-provider", event_id),
)
return "processed"
La documentación oficial de Python explica el control de transacciones del módulo sqlite3. El código presupone que las tablas existen y que la acción es la inserción local en audit_entries. No incluye autenticación, servidor HTTP ni validación detallada del esquema; deben añadirse según el contrato antes de llamar a process_event.
Cuando el efecto ocurre en otro sistema
Una transacción local no puede incluir fácilmente el envío de un correo, una llamada a una API externa o una publicación en otro servicio. Si se confirma la base de datos y después falla la llamada, queda trabajo pendiente. Si se llama primero y luego falla la confirmación, el efecto externo puede repetirse.
Una solución habitual es el patrón transactional outbox:
- dentro de una transacción, reclama el evento y registra la acción pendiente;
- confirma ambos registros juntos;
- un trabajador publica la acción;
- registra el resultado y reintenta si es necesario;
- el consumidor posterior también utiliza una clave idempotente cuando sea posible.
Esto no crea magia de “exactamente una vez”. Convierte una ventana difícil en estados persistentes y recuperables. Si el servicio externo admite una clave de idempotencia, deriva una estable del evento y del tipo de acción, siguiendo su documentación. Si no la admite, quizá necesites consultar antes de crear, reconciliar después o aceptar una revisión manual para casos ambiguos.
Estados atascados y recuperación
El estado processing puede quedar abandonado si el trabajador cae. Conserva su momento, número de intentos y último error no sensible. Un proceso controlado puede reclamar trabajos vencidos. Para un evento antiguo quizá sea mejor consultar el estado vigente que ejecutar una acción histórica; cada contrato y operación deben definir el criterio.
También define retención. Borrar claves demasiado pronto permite que una repetición tardía produzca efectos; guardarlas para siempre puede contradecir la minimización de datos. Ajusta el periodo al horizonte de reintentos documentado y conserva solo la identidad necesaria.
Orden, versiones y eventos distintos
Deduplicar no resuelve mensajes fuera de orden. Si el contrato incluye una versión monotónica, aplica solo estados más recientes; si no, quizá debas consultar el recurso actual. Tampoco dedupliques por la identidad del recurso: una orden puede generar varios eventos legítimos. La clave debe distinguir cada evento.
Respuestas y observabilidad
Un duplicado reconocido suele ser un resultado exitoso desde la perspectiva de entrega: el evento ya fue aceptado. Sin embargo, debes devolver exactamente el código que el proveedor documenta. Responder error a todos los duplicados puede provocar reintentos innecesarios.
Mide eventos únicos, duplicados, trabajos atascados y tiempo hasta finalizar, sin guardar secretos. Un aumento de duplicados puede indicar timeouts o respuestas tardías; correlaciónalo con latencia y códigos antes de concluir.
La guía de idempotencia de métodos del RFC 9110 explica la semántica HTTP, pero una solicitud POST de webhook no se vuelve idempotente por declararlo: la lógica del receptor debe imponer esa propiedad sobre sus efectos.
Una propiedad de extremo a extremo
Para recibir un webhook de forma confiable, autentica primero según el proveedor, valida el esquema y reclama la identidad mediante una operación atómica. Después ejecuta o programa la acción, registra su resultado y responde de acuerdo con el contrato. Si la autenticación usa firmas, verifica los bytes antes de analizar; la guía de HMAC-SHA256 explica ese patrón general.
La idempotencia reduce daños causados por repeticiones previsibles, pero no sustituye autorización, validación, conciliación ni revisión de efectos externos. Diseñarla como una secuencia de estados persistentes, en vez de como un simple if, permite recuperarse de fallos sin prometer garantías absolutas que una red distribuida no puede ofrecer por sí sola.