Patrón outbox en PostgreSQL para integraciones de API fiables
Aprende el patrón outbox para almacenar eventos en PostgreSQL y entregarlos a APIs externas con reintentos, orden y deduplicación.

Por qué fallan las integraciones aunque tu app funcione
Es común ver una acción “exitosa” en tu app mientras la integración detrás falla en silencio. Tu escritura en la base de datos es rápida y fiable. Una llamada a una API de terceros no lo es. Eso crea dos mundos: tu sistema dice que el cambio ocurrió, pero el sistema externo nunca se enteró.
Un ejemplo típico: un cliente hace un pedido, tu app lo guarda en PostgreSQL y luego intenta notificar a un proveedor de envíos. Si el proveedor tarda 20 segundos y tu petición acaba fallando, la orden sigue siendo real, pero el envío nunca se crea.
Los usuarios ven esto como un comportamiento confuso e inconsistente. Los eventos perdidos parecen “no pasó nada”. Los eventos duplicados parecen “¿por qué me cobraron dos veces?”. Los equipos de soporte también sufren porque es difícil saber si el problema fue tu app, la red o el socio.
Los reintentos ayudan, pero por sí solos no garantizan corrección. Si reintentas tras un timeout, puede que envíes el mismo evento dos veces porque no sabes si el socio recibió la primera petición. Si reintentas fuera de orden, podrías enviar “Pedido enviado” antes que “Pedido pagado”.
Estos problemas suelen venir de la concurrencia normal: múltiples workers procesando en paralelo, varios servidores de aplicación escribiendo al mismo tiempo y colas de “mejor esfuerzo” donde el tiempo cambia bajo carga. Los modos de fallo son previsibles: las APIs se caen o se vuelven lentas, las redes pierden peticiones, los procesos se bloquean en el peor momento y los reintentos generan duplicados cuando no hay idempotencia.
El patrón outbox existe porque estas fallas son normales.
Qué es el patrón outbox en términos sencillos
El patrón outbox es simple: cuando tu app hace un cambio importante (como crear una orden), también escribe un pequeño registro de “evento a enviar” en una tabla de base de datos, dentro de la misma transacción. Si el commit de la base de datos tiene éxito, sabes que los datos de negocio y el registro de evento existen juntos.
Después, un worker separado lee la tabla outbox y entrega esos eventos a APIs externas. Si una API está lenta, caída o timeoutea, la petición principal del usuario sigue teniendo éxito porque no espera la llamada externa.
Esto evita los estados incómodos que obtienes cuando llamas a una API dentro del manejador de la petición:
- La orden se guarda, pero la llamada a la API falla.
- La llamada a la API tiene éxito, pero tu app se cae antes de guardar la orden.
- El usuario reintenta y envías lo mismo dos veces.
El patrón outbox ayuda principalmente con eventos perdidos, fallos parciales (base de datos bien, API externa no), envíos dobles accidentales y reintentos más seguros (puedes volver a intentar más tarde sin adivinar).
No lo arregla todo. Si tu payload está mal, tus reglas de negocio son erróneas o la API externa rechaza los datos, seguirás necesitando validación, buen manejo de errores y una forma de inspeccionar y corregir eventos fallidos.
Diseñando una tabla outbox en PostgreSQL
Una buena tabla outbox es aburrida a propósito. Debe ser fácil de escribir, fácil de leer y difícil de usar mal.
Aquí tienes un esquema práctico base que puedes adaptar:
create table outbox_events (
id bigserial primary key,
aggregate_id text not null,
event_type text not null,
payload jsonb not null,
status text not null default 'pending',
created_at timestamptz not null default now(),
available_at timestamptz not null default now(),
attempts int not null default 0,
locked_at timestamptz,
locked_by text,
meta jsonb not null default '{}'::jsonb
);
Elegir un ID
Usar bigserial (o bigint) mantiene el orden simple y los índices rápidos. Los UUID son geniales para unicidad entre sistemas, pero no se ordenan por creación, lo que puede hacer que el sondeo sea menos predecible y los índices más pesados.
Un compromiso común es: mantener id como bigint para orden, y añadir un event_uuid separado si necesitas un identificador estable para compartir entre servicios.
Índices que importan
Tu worker consultará los mismos patrones todo el día. La mayoría de sistemas necesitan:
- Un índice como
(status, available_at, id)para obtener los próximos eventos pendientes en orden. - Un índice en
(locked_at)si planeas expirar locks obsoletos. - Un índice como
(aggregate_id, id)si a veces entregas por agregado en orden.
Mantén los payloads estables
Mantén los payloads pequeños y predecibles. Guarda lo que el receptor realmente necesita, no toda tu fila. Añade una versión explícita (por ejemplo, en meta) para poder evolucionar campos con seguridad.
Usa meta para enrutamiento y contexto de depuración como tenant ID, correlation ID, trace ID y una clave de deduplicación. Ese contexto extra vale la pena cuando soporte necesita responder “¿qué pasó con esta orden?”.
Cómo almacenar eventos de forma segura con tu escritura de negocio
La regla más importante es simple: escribe los datos de negocio y el evento outbox en la misma transacción de base de datos. Si la transacción hace commit, ambos existen. Si hace rollback, ninguno existe.
Ejemplo: un cliente realiza un pedido. En una transacción insertas la fila de la orden, las filas de los items y una fila outbox como order.created. Si algún paso falla, no quieres que un evento “created” se escape al mundo.
¿Un evento o varios?
Empieza con un evento por acción de negocio cuando puedas. Es más fácil de razonar y más barato de procesar. Separa en varios eventos solo cuando diferentes consumidores realmente necesiten diferente timing o payloads (por ejemplo, order.created para fulfillment y payment.requested para facturación). Generar muchos eventos por un solo clic aumenta reintentos, problemas de orden y manejo de duplicados.
¿Qué payload deberías almacenar?
Normalmente eliges entre:
- Snapshot: guarda campos clave como estaban en el momento de la acción (total de la orden, moneda, ID de cliente). Esto evita lecturas adicionales y mantiene el mensaje estable.
- ID de referencia: guarda solo el ID de la orden y deja que el worker cargue los detalles después. Esto mantiene el outbox pequeño, pero exige lecturas adicionales y puede cambiar si la orden se edita.
Un punto intermedio práctico es identificadores más un pequeño snapshot de valores críticos. Ayuda a que los receptores actúen rápido y facilita la depuración.
Mantén el límite de la transacción estrecho. No llames a APIs de terceros dentro de la misma transacción.
Entregar eventos a APIs externas: el bucle del worker
Una vez que los eventos están en tu outbox, necesitas un worker que los lea y llame a la API externa. Esta es la parte que convierte el patrón en una integración fiable.
El sondeo suele ser la opción más sencilla. LISTEN/NOTIFY puede reducir latencia, pero añade piezas móviles y aún necesita un fallback cuando se pierden notificaciones o el worker se reinicia. Para la mayoría de equipos, un sondeo constante con un lote pequeño es más fácil de operar y depurar.
Reclamar filas de forma segura
El worker debe reclamar filas para que dos workers no procesen el mismo evento al mismo tiempo. En PostgreSQL, el enfoque común es seleccionar un lote usando bloqueos de fila y SKIP LOCKED, luego marcarlos como en progreso.
Un flujo práctico de estados es:
pending: listo para enviarprocessing: bloqueado por un worker (usalocked_byylocked_at)sent: entregado con éxitofailed: detenido tras el máximo de intentos (o movido para revisión manual)
Mantén los lotes pequeños para ser amable con la base de datos. Un lote de 10 a 100 filas, ejecutándose cada 1 a 5 segundos, es un punto de partida común.
Cuando una llamada tiene éxito, marca la fila sent. Cuando falla, incrementa attempts, fija available_at a un tiempo futuro (backoff), borra el lock y devuélvela a pending.
Logs que ayudan (sin filtrar secretos)
Los buenos logs hacen que los fallos sean accionables. Registra el id del outbox, el tipo de evento, el nombre del destino, el número de intentos, tiempos y el estado HTTP o la clase de error. Evita cuerpos de petición, cabeceras de autenticación y respuestas completas. Si necesitas correlación, almacena un request ID seguro o un hash en lugar del payload crudo.
Reglas de orden que funcionan en sistemas reales
Muchos equipos empiezan con “enviar eventos en el mismo orden en que los creamos”. El problema es que “el mismo orden” rara vez es global. Si fuerzas una cola global, un solo cliente lento o una API inestable puede detener a todos.
Una regla práctica es: preserva el orden por grupo, no para todo el sistema. Elige una clave de agrupación que coincida con cómo el mundo externo piensa sobre tus datos, como customer_id, account_id o un aggregate_id como order_id. Luego garantiza el orden dentro de cada grupo mientras entregas muchos grupos en paralelo.
Workers paralelos sin romper el orden
Ejecuta múltiples workers, pero asegura que dos workers no procesen el mismo grupo al mismo tiempo. El enfoque habitual es siempre entregar el evento pendiente más antiguo para un aggregate_id dado y permitir paralelismo entre diferentes aggregates.
Mantén las reglas de reclamación simples:
- Solo entrega el evento pendiente más antiguo por grupo.
- Permite paralelismo entre grupos, no dentro de un grupo.
- Reclama un evento, envíalo, actualiza el estado y luego sigue.
Cuando un evento bloquea al resto
Tarde o temprano, un evento “venenoso” fallará durante horas (payload malo, token revocado, proveedor caído). Si haces cumplir estrictamente el orden por grupo, los eventos posteriores en ese grupo deben esperar, pero otros grupos deberían continuar.
Un compromiso práctico es limitar los reintentos por evento. Después de eso, márcalo failed y pausa solo ese grupo hasta que alguien arregle la causa raíz. Así un cliente roto no ralentiza a todos.
Reintentos sin empeorar las cosas
Los reintentos son donde una buena configuración outbox se vuelve confiable o ruidosa. La meta es simple: volver a intentar cuando sea probable que funcione y parar rápido cuando no lo sea.
Usa backoff exponencial y un tope duro. Por ejemplo: 1 minuto, 2 minutos, 4 minutos, 8 minutos, luego parar (o seguir con un máximo como 15 minutos). Siempre fija un número máximo de intentos para que un evento malo no pueda atascar el sistema para siempre.
No todos los fallos deben reintentarse. Mantén las reglas claras:
- Reintentar: timeouts de red, resets de conexión, problemas DNS y respuestas HTTP 429 o 5xx.
- No reintentar: HTTP 400 (bad request), 401/403 (problemas de autenticación), 404 (endpoint incorrecto) o errores de validación que puedas detectar antes de enviar.
Almacena el estado de reintento en la fila outbox. Incrementa attempts, fija available_at para el próximo intento y registra un resumen corto y seguro del error (código de estado, clase de error, mensaje recortado). No almacenes payloads completos ni datos sensibles en los campos de error.
Los límites de tasa necesitan manejo especial. Si recibes HTTP 429, respeta Retry-After cuando exista. Si no, haz un backoff más agresivo para evitar una tormenta de reintentos.
Deduplicación e idempotencia básicas
Si quieres integraciones API fiables, asume que el mismo evento puede enviarse dos veces. Un worker puede caerse después de la llamada HTTP pero antes de registrar el éxito. Un timeout puede ocultar un éxito. Un reintento puede solaparse con un intento lento. El patrón outbox reduce eventos perdidos, pero no evita duplicados por sí mismo.
El enfoque más seguro es la idempotencia: entregas repetidas producen el mismo resultado que una sola entrega. Al llamar a una API de terceros, incluye una clave de idempotencia que sea estable para ese evento y ese destino. Muchas APIs soportan una cabecera; si no, pon la clave en el cuerpo de la petición.
Una clave simple es destino:event_id. Para un evento con ID evt_123, usa siempre algo como destA:evt_123.
En tu lado, evita envíos duplicados manteniendo un log de entregas salientes y aplicando una regla única como (destination, event_id). Incluso si dos workers compiten, solo uno podrá crear el registro “estamos enviando esto”.
Los webhooks también duplican
Si recibes callbacks webhook (como “entrega confirmada” o “estado actualizado”), trátalos igual. Los proveedores reintentan y puedes ver el mismo payload varias veces. Guarda IDs de webhook procesados o calcula un hash estable del ID del mensaje del proveedor y rechaza repetidos.
Cuánto tiempo conservar los datos
Mantén las filas outbox hasta haber registrado éxito (o un fallo final que aceptes). Mantén los logs de entrega más tiempo, porque son tu rastro de auditoría cuando alguien pregunta “¿lo enviamos?”.
Un enfoque común:
- Filas outbox: borrar o archivar tras el éxito más una ventana de seguridad corta (días).
- Logs de entrega: conservar semanas o meses, según cumplimiento y necesidades de soporte.
- Claves de idempotencia: conservar al menos mientras puedan ocurrir reintentos (y más tiempo para duplicados de webhooks).
Paso a paso: implementar el patrón outbox
Decide qué publicar. Mantén los eventos pequeños, enfocados y fáciles de reprocesar. Una buena regla es un hecho de negocio por evento, con datos suficientes para que el receptor actúe.
Construye la base
Elige nombres de eventos claros (por ejemplo, order.created, order.paid) y versiona tu esquema de payload (como v1, v2). La versionización permite añadir campos después sin romper consumidores antiguos.
Crea tu tabla outbox en PostgreSQL y añade índices para las consultas que tu worker hará más, especialmente (status, available_at, id).
Actualiza tu flujo de escritura para que el cambio de negocio y la inserción en outbox ocurran en la misma transacción. Esa es la garantía central.
Añade entrega y control
Un plan de implementación simple:
- Define tipos de evento y versiones de payload que puedas soportar a largo plazo.
- Crea la tabla outbox y los índices.
- Inserta una fila outbox junto al cambio de datos principal.
- Construye un worker que reclame filas, envíe al API de terceros y luego actualice el estado.
- Añade programación de reintentos con backoff y un estado
failedcuando se agoten los intentos.
Añade métricas básicas para notar problemas temprano: lag (edad del evento sin enviar más antiguo), tasa de envío y tasa de fallos.
Un ejemplo simple: enviar eventos de pedido a servicios externos
Un cliente hace un pedido en tu app. Dos cosas deben ocurrir fuera de tu sistema: el proveedor de cobros debe cargar la tarjeta y el proveedor de envíos debe crear un envío.
Con el patrón outbox, no llamas a esas APIs dentro de la petición de checkout. En su lugar, guardas la orden y una fila outbox en la misma transacción PostgreSQL, así nunca terminas con “orden guardada, pero sin notificación” (o al revés).
Una fila outbox típica para un evento de orden podría incluir un aggregate_id (el ID de la orden), un event_type como order.created y un payload JSONB con totales, items y detalles de destino.
Un worker luego recoge filas pendientes y llama a los servicios externos (ya sea en un orden definido o emitiendo eventos separados como payment.requested y shipment.requested). Si un proveedor está caído, el worker registra el intento, programa el siguiente intento empujando available_at al futuro y sigue adelante. La orden sigue existiendo y el evento será reintentado más tarde sin bloquear nuevos checkouts.
El orden suele ser “por orden” o “por cliente”. Haz que los eventos con el mismo aggregate_id se procesen uno a la vez para que order.paid nunca llegue antes que order.created.
La deduplicación es lo que evita cobrar dos veces o crear dos envíos. Envía una clave de idempotencia cuando el tercero lo soporte y guarda un registro de entrega por destino para que un reintento tras un timeout no produzca una segunda acción.
Comprobaciones rápidas antes de desplegar
Antes de confiar una integración para mover dinero, notificar clientes o sincronizar datos, prueba los bordes: caídas, reintentos, duplicados y múltiples workers.
Comprobaciones que capturan fallos comunes:
- Confirma que la fila outbox se crea en la misma transacción que el cambio de negocio.
- Verifica que el emisor sea seguro para ejecutarse en múltiples instancias. Dos workers no deberían enviar el mismo evento a la vez.
- Si el orden importa, define la regla en una frase y aplícala con una clave estable.
- Para cada destino, decide cómo evitas duplicados y cómo pruebas “lo enviamos”.
- Define la salida: tras N intentos, mueve el evento a
failed, guarda el último resumen de error y proporciona una acción simple de reprocesado.
Una comprobación de realidad: Stripe puede aceptar una petición pero tu worker caerse antes de guardar el éxito. Sin idempotencia, un reintento puede causar una acción doble. Con idempotencia y un registro de entrega guardado, el reintento es seguro.
Siguientes pasos: desplegar esto sin interrumpir tu app
El despliegue es donde los proyectos outbox suelen triunfar o atascarse. Empieza pequeño para ver el comportamiento real sin poner en riesgo toda tu capa de integraciones.
Comienza con una integración y un tipo de evento. Por ejemplo, solo envía order.created a una API de un proveedor mientras el resto sigue igual. Eso te da una línea base limpia para throughput, latencia y tasas de fallo.
Haz visibles los problemas temprano. Añade dashboards y alertas para outbox lag (cuántos eventos esperan y qué edad tiene el más antiguo) y tasa de fallos (cuántos están atascados en reintento). Si puedes responder “¿estamos retrasados ahora?” en 10 segundos, detectarás problemas antes que los usuarios.
Ten un plan de reprocesado seguro antes del primer incidente. Decide qué significa “reprocesar”: reenviar el mismo payload, reconstruir el payload desde los datos actuales o enviarlo para revisión manual. Documenta qué casos son seguros para reenviar y cuáles necesitan revisión humana.
Si construyes esto con una plataforma no-code como AppMaster (appmaster.io), la misma estructura sigue aplicando: escribe tus datos de negocio y una fila outbox juntos en PostgreSQL, luego ejecuta un proceso backend separado para entregar, reintentar y marcar eventos como enviados o fallidos.
FAQ
Usa el patrón outbox cuando una acción de usuario actualiza tu base de datos y debe desencadenar trabajo en otro sistema. Es especialmente útil cuando los timeouts, redes inestables o caídas del tercero pueden crear situaciones de “guardado en nuestra app, pero faltante en la de ellos”.
Escribir la fila de negocio y la fila outbox en la misma transacción de base de datos te da una garantía clara: o bien ambas existen o ninguna existe. Esto evita fallos parciales como “la llamada a la API tuvo éxito pero la orden no se guardó” o “la orden se guardó pero la llamada a la API nunca ocurrió”.
Un buen conjunto de campos por defecto es id, aggregate_id, event_type, payload, status, created_at, available_at, attempts, más campos de bloqueo como locked_at y locked_by. Esto mantiene simple el envío, la programación de reintentos y la concurrencia segura sin sobrecomplicar la tabla.
Una base común es un índice en (status, available_at, id) para que los workers obtengan rápidamente el siguiente lote de eventos enviables en orden. Añade otros índices solo cuando realmente consultes por esos campos, porque los índices extras ralentizan las inserciones.
La sondeo (polling) es el enfoque más simple y predecible para la mayoría de los equipos. Empieza con lotes pequeños y un intervalo corto, luego afina según carga y retraso; puedes añadir optimizaciones después, pero un bucle simple es más fácil de depurar cuando algo falla.
reclama filas usando bloqueos a nivel de fila para que dos workers no puedan procesar el mismo evento simultáneamente, típicamente con SKIP LOCKED. Luego marca la fila como processing con una marca de tiempo y un ID de worker, envíala, y finalmente márcala sent o devuélvela a pending con un available_at futuro.
Usa backoff exponencial con un límite duro de intentos, y reintenta solo fallos que probablemente sean temporales. Timeouts, errores de red y respuestas HTTP 429/5xx son buenos candidatos a reintento; errores de validación y la mayoría de respuestas 4xx deben tratarse como definitivos hasta que corrijas datos o configuración.
No, el patrón outbox no garantiza entrega exactamente una vez por sí solo. Asume que los duplicados pueden ocurrir (por ejemplo, si un worker se cae después de la llamada HTTP pero antes de registrar el éxito). Usa una clave de idempotencia estable por destino y por evento, y guarda un registro de entregas con una restricción única para que incluso si hay carrera entre workers no se creen dos envíos.
Preserva el orden dentro de un grupo, no a nivel global. Usa una clave de agrupación como aggregate_id (ID de orden) o customer_id, procesa solo un evento a la vez por grupo y permite paralelismo entre grupos para que un cliente lento no bloquee a todos.
Márcalo como failed tras un número máximo de intentos, guarda un resumen seguro del último error y detén el procesamiento de eventos posteriores para ese mismo grupo hasta que alguien arregle la causa raíz. Esto contiene el impacto y evita reintentos interminables mientras otros grupos siguen avanzando.


