Webhooks e integraciones
Cómo diseñar consumidores seguros de eventos
Diseña consumidores idempotentes y recuperables para webhooks y colas, con deduplicación, transacciones, reintentos y observabilidad.
Por Equipo AltokePayPublicado Actualizado 10 min de lectura
Un consumidor de eventos transforma mensajes en efectos: actualiza un pedido, reserva inventario, crea una tarea o envía una notificación. Su dificultad no está en leer JSON, sino en comportarse de manera predecible cuando el mismo mensaje llega más de una vez, el proceso se reinicia o una dependencia falla a mitad del trabajo.
La meta razonable no es prometer que nada fallará. Es diseñar para que una repetición no multiplique efectos, una interrupción pueda recuperarse y una persona pueda reconstruir lo ocurrido. Cada proveedor define su contrato: tipos de evento, identidad, orden, esquema, autenticación, tiempos de respuesta y reintentos. El consumidor debe implementar ese contrato concreto, no uno supuesto.
Acepta que la entrega puede repetirse
Las redes tienen resultados ambiguos. El emisor puede enviar un evento, el receptor puede procesarlo y la respuesta puede perderse. Para el emisor, la entrega parece fallida y el reintento es razonable. Por eso, “recibir una sola vez” no debe ser una condición necesaria para la corrección del negocio.
La idempotencia significa que repetir una operación con la misma identidad no cambia el resultado después de la primera aplicación válida. No significa ignorar todos los mensajes parecidos ni comparar solo el contenido. Dos eventos distintos pueden compartir monto, usuario o tipo; la deduplicación necesita la identidad estable definida por el contrato.
Conviene separar:
- intento de entrega: cada solicitud o lectura de cola;
- evento lógico: el hecho identificado por el proveedor;
- efecto de negocio: el cambio que tu aplicación decide realizar.
Registrar estas capas evita confundir un reintento técnico con una segunda venta o una segunda orden.
Deduplica con una restricción duradera
Una comprobación del tipo “buscar y luego insertar” puede fallar con concurrencia: dos procesos buscan al mismo tiempo, ninguno encuentra el evento y ambos ejecutan la acción. Una restricción única en una base de datos ofrece una decisión atómica más fuerte que una variable en memoria.
Este ejemplo hipotético en Python usa SQLite para mostrar el patrón. Supone que la solicitud ya fue autenticada según el contrato del proveedor y que event_id proviene de una fuente válida. En producción, el motor, las transacciones y el nivel de aislamiento deben adaptarse a la carga real.
import sqlite3
from dataclasses import dataclass
@dataclass(frozen=True)
class IncomingEvent:
source_id: str
event_id: str
event_type: str
payload: dict
def consume(connection: sqlite3.Connection, event: IncomingEvent) -> bool:
if event.event_type != "order.ready":
raise ValueError("Unsupported event type")
with connection:
cursor = connection.execute(
"""
INSERT OR IGNORE INTO consumed_events
(source_id, event_id, event_type)
VALUES (?, ?, ?)
""",
(event.source_id, event.event_id, event.event_type),
)
if cursor.rowcount == 0:
return False
updated = connection.execute(
"UPDATE orders SET state = ? WHERE id = ? AND state = ?",
("ready", event.payload["orderId"], "preparing"),
)
if updated.rowcount != 1:
raise ValueError("Order transition was not applied")
connection.execute(
"""
UPDATE consumed_events SET state = ?
WHERE source_id = ? AND event_id = ?
""",
("completed", event.source_id, event.event_id),
)
return True
connection = sqlite3.connect("events.db")
connection.execute(
"""
CREATE TABLE IF NOT EXISTS consumed_events (
source_id TEXT NOT NULL,
event_id TEXT NOT NULL,
event_type TEXT NOT NULL,
state TEXT NOT NULL DEFAULT 'started',
PRIMARY KEY (source_id, event_id)
)
"""
)
El ejemplo es autocontenido y no incluye secretos. Si el adaptador de entrada necesita una credencial, debe obtenerla de una variable de entorno o un gestor de secretos, nunca de una constante. La deduplicación tampoco sustituye la validación del mensaje: ambas resuelven problemas diferentes.
Une registro y efecto cuando sea posible
Si la marca “procesado” se guarda antes de actualizar el pedido y el proceso cae, el reintento podría descartarse sin haber aplicado el efecto. Si se guarda después, una caída entre el efecto y la marca puede repetirlo. La solución preferible es ejecutar ambos cambios en la misma transacción cuando pertenecen a la misma base de datos.
Cuando el efecto vive en otro sistema —por ejemplo, enviar un correo o invocar una API— no existe una transacción local que abarque todo. Patrones como una bandeja de salida transaccional permiten confirmar el cambio de negocio y una tarea pendiente en una sola transacción. Otro proceso entrega la tarea y conserva sus intentos.
No todos los efectos externos son reversibles o idempotentes. Antes de reintentar, consulta si la API destino admite una clave idempotente y cuál es su alcance. Una clave inventada localmente no obliga al otro sistema a reconocerla.
Define una máquina de estados pequeña
Un booleano processed suele ocultar demasiados casos. Estados explícitos facilitan la recuperación:
- received: el evento quedó guardado;
- processing: un trabajador tomó la tarea con un bloqueo o arrendamiento;
- completed: el efecto terminó;
- retryable: ocurrió un fallo temporal;
- discarded: el contrato indica que no debe procesarse;
- dead-letter: agotó la política de intentos y requiere revisión.
Cada transición debe tener hora, motivo y contador de intentos. Evita que dos trabajadores mantengan indefinidamente el mismo evento mediante bloqueos con vencimiento o primitivas equivalentes del sistema de colas. Un timeout no demuestra que la operación externa no ocurrió; antes de repetir un efecto costoso, consulta su estado cuando la API lo permita.
Reintenta con límites y clasificación
No todos los errores mejoran esperando. Un esquema desconocido, una referencia inválida o un permiso denegado pueden requerir corrección humana o de configuración. Una desconexión breve o una respuesta temporal de saturación puede ser reintentable.
Usa una política con espera creciente, dispersión aleatoria, máximo de intentos y límite total de edad. La fórmula concreta depende del servicio y de sus cuotas. Respeta encabezados como Retry-After cuando el contrato los utilice; RFC 9110 describe su semántica. No programes reintentos agresivos que empeoren una caída.
Una cola de mensajes fallidos no debería convertirse en cementerio. Debe tener alertas, responsable, herramientas de inspección con datos minimizados y un procedimiento de reenvío. Reprocesar requiere mantener la misma identidad lógica para que los controles de duplicidad sigan funcionando.
No presupongas orden global
Aunque el emisor genere created antes de updated, distintas rutas o reintentos pueden alterar el orden de llegada. Si el contrato no garantiza orden, el consumidor debe tolerarlo. Algunas estrategias son:
- aplicar una versión monotónica por recurso;
- rechazar transiciones imposibles y enviarlas a revisión;
- consultar el estado actual en la fuente autorizada;
- serializar eventos por clave de agregado, no toda la plataforma.
Usar la hora de recepción como verdad suele ser insuficiente. También hay que evitar que un evento antiguo revierta un estado más nuevo. El contrato debe indicar si existe versión, secuencia o marca temporal confiable y cómo interpretarla.
Valida antes de actuar y minimiza datos
La autenticación del origen, la integridad del mensaje, la autorización del tipo de evento y la validación del esquema ocurren antes del efecto de negocio. Los detalles varían por proveedor. Conserva el cuerpo original solo cuando sea necesario y durante el periodo definido; no escribas credenciales, tokens ni datos sensibles completos en logs.
La guía de logging de OWASP recomienda registrar eventos relevantes sin incluir información que deba excluirse o protegerse. Ninguna validación ofrece seguridad absoluta: combina controles, permisos mínimos, segmentación y monitoreo.
Observa resultados, no solo entregas
Mide eventos recibidos, duplicados reconocidos, duración, profundidad de cola, edad del evento más antiguo, reintentos y mensajes que requieren intervención. Añade un identificador de correlación que conecte recepción, procesamiento y efecto, pero no sustituyas con él la identidad idempotente.
Un panel que solo muestra respuestas HTTP exitosas puede ocultar consumidores detenidos. La guía para diagnosticar un endpoint que no responde cubre la primera mitad del recorrido; el consumidor completa la historia después de aceptar la solicitud.
Prueba las fallas deliberadamente
Además del caso feliz, ejecuta pruebas con el mismo evento dos veces, dos consumidores concurrentes, caída después de guardar, caída después del efecto, mensajes fuera de orden y dependencia externa lenta. Comprueba el estado de negocio, no solo el código de retorno.
Por último, decide cómo recuperar eventos ausentes. Algunos proveedores ofrecen una API de consulta que puede complementar la entrega. Webhooks vs. polling explica cuándo un barrido periódico funciona como mecanismo de reconciliación. Diseñar consumidores seguros consiste en asumir fallos parciales, limitar efectos y conservar evidencia suficiente para continuar, no en prometer una entrega perfecta.