Bandeja de eventos de integración
Por qué un cobro no acabó en factura — los motivos de descarte tipados, cuáles te avisan, cuáles parquean el evento para que puedas reprocesarlo, y cómo funciona la ventana de retención de 30 días.
Una pasarela de pago envía a Factuarea un evento por todo lo que ocurre en tu cuenta: un cobro con éxito, una devolución emitida, un ciclo de suscripción cobrado, un payout que llega. La mayoría de esos eventos producen algo — una factura, una rectificativa, un registro de pago. Algunos no producen nada, y cuando eso pasa la pregunta interesante siempre es la misma: ¿por qué este cobro no acabó en factura?
La bandeja de eventos de integración la contesta. Cada evento que Factuarea recibe queda registrado con lo que produjo y, cuando no produjo nada, con un motivo de descarte tipado salido de un catálogo cerrado. Sin adivinar en los logs, sin abrir un ticket de soporte: el motivo es un valor por el que puedes filtrar y, en los motivos sobre los que puedes actuar, viene con el siguiente paso y, a veces, con la posibilidad de reprocesar el evento.
La bandeja es agnóstica de la pasarela. Registra eventos de cualquier integración que escriba historial — incluidas las pasarelas que todavía no están liberadas y los eventos históricos de una que se retire — porque ocultar esas filas te dejaría sin explicación para cobros que nunca se facturaron.
La exponen tres endpoints:
| Operación | Endpoint | Scope |
|---|---|---|
| Listar eventos | GET /v1/integrations/events | integration_events:read |
| Obtener un evento | GET /v1/integrations/events/{event} | integration_events:read |
| Reprocesar un evento parqueado | POST /v1/integrations/events/{event}/replay | integration_events:write |
La misma superficie existe como tools MCP — list_integrations_events,
get_integrations_event y replay_integrations_event — con los mismos scopes.
Recorrer la bandeja
Del más reciente al más antiguo, acotado a la empresa autenticada. Paginación por
cursor con limit (de 1 a 100; 25 por defecto) y starting_after:
curl -G https://api.factuarea.com/v1/integrations/events \
-H "Authorization: Bearer $FACTUAREA_API_KEY" \
--data-urlencode "provider=stripe" \
--data-urlencode "status=skipped" \
--data-urlencode "limit=50"{
"data": [
{
"id": "0192f3a4-7b2c-7c1d-9e8f-1a2b3c4d5e6f",
"object": "integration_event",
"provider": "stripe",
"event_type": "invoice.paid",
"direction": "inbound",
"status": "skipped",
"discard_reason": "subscription_autoinvoicing_disabled",
"discard_reason_label": "Auto-facturación de suscripciones desactivada",
"is_actionable": true,
"is_replayable": true,
"error_message": null,
"duration_ms": 412,
"created_at": "2026-07-14T09:31:07Z"
}
],
"has_more": true,
"next_cursor": "84120"
}Trata next_cursor como opaco: en este listado es una cadena numérica, no un
UUID v7 como los cursores de los listados de documentos. Devuélvelo tal cual en
starting_after.
discard_reason_label llega siempre en español, el idioma de la interfaz del
producto, sea cual sea el idioma de tu integración. Si construyes un panel en
otro idioma, apoya tus propios textos en discard_reason — ese valor es el
identificador estable y cerrado.
Filtros
| Filtro | Valores | Notas |
|---|---|---|
provider | stripe, gocardless, monei, slack, teams, a3, norma43, norma19, ubl | Conjunto cerrado |
status | success, skipped, failure | Conjunto cerrado |
event_type | texto libre, coincidencia exacta, hasta 100 caracteres | No es un enum — ver abajo |
discard_reason | uno de los veinte motivos del catálogo | Conjunto cerrado |
is_parked | true / false | Ver la nota de abajo |
created_at[gte], created_at[lte] | ISO 8601 | Ventana inclusiva |
discard_reason es el eje cerrado; event_type no es un enum. La columna
event_type mezcla a propósito dos convenciones: las ramas instrumentadas más
tarde guardan el tipo crudo de la pasarela (charge.refunded), mientras que las
preexistentes conservan su propio valor semántico (autoinvoice.*). Búscalo por
coincidencia exacta cuando sepas qué persigues, pero no lo modeles nunca como un
conjunto cerrado — estarías modelando algo que la columna no garantiza.
is_parked=false no es lo mismo que omitir el parámetro. El primero excluye
los eventos parqueados; el segundo no excluye nada.
Un valor fuera de su catálogo devuelve 422, y un parámetro de consulta
desconocido devuelve 400 parameter_unknown en lugar de ignorarse en
silencio — un filtro que se cae sin avisar te entrega una página que crees
acotada y no lo está.
El catálogo de motivos de descarte
Veinte motivos tipados, uno por cada rama de descarte del pipeline de webhooks de las pasarelas. Cada uno declara dos decisiones de negocio que no son banderas decorativas:
- Accionable — ¿puede hacer algo el titular de la cuenta? Solo los motivos accionables avisan. Avisar a alguien de un descarte que no puede resolver le enseña a ignorar la bandeja, y así es como se pierde el aviso que sí importaba.
- Parqueado — ¿reprocesar el mismo contenido podría dar otro resultado? Solo los eventos parqueados guardan su contenido cifrado y admiten un reproceso.
La regla detrás de la columna de parqueo: un evento se parquea cuando el descarte lo causó un estado externo que puedes cambiar (un ajuste apagado, una cuenta conectada que se desvinculó, una moneda todavía sin tipo de cambio). No se parquea cuando la causa es el contenido del propio evento (malformado, duplicado, de tipo no cubierto, importe cero, ciclo ya facturado) — reprocesarlo tomaría exactamente la misma rama y solo escribiría una segunda fila. De ahí el invariante: todo motivo parqueado es accionable, y seis de los nueve accionables se parquean.
| Motivo | Qué lo provoca | Accionable | Parqueado | Qué hacer |
|---|---|---|---|---|
event_not_normalizable | Evento malformado, o de un tipo que no se puede interpretar | No | No | Nada — no puedes arreglar el payload de la pasarela |
duplicate_redelivery | El evento ya se procesó; su efecto existe | No | No | Nada — reprocesarlo sería un no-op por deduplicación |
connected_account_missing | El webhook está mal configurado en la pasarela: el evento no dice a qué cuenta pertenece | Sí | No | Revisa en la pasarela que el webhook se envía desde la cuenta que tienes vinculada en Factuarea |
connected_account_unknown | La cuenta existe en la pasarela pero no está vinculada en Factuarea | Sí | Sí | Vuelve a vincular esa cuenta de la pasarela y reprocesa el evento |
spontaneous_payment_missing_id | El cobro no trae id, así que no hay clave de idempotencia | No | No | Nada — reprocesarlo duplicaría o volvería a fallar |
autoinvoicing_disabled | La auto-facturación está desactivada para esa integración | Sí | Sí | Activa la auto-facturación y reprocesa, o crea la factura a mano — nunca las dos cosas |
unsupported_currency | El tipo de cambio del Banco Central Europeo del día todavía no está disponible | Sí | Sí | Reprocesa el evento más tarde, cuando el tipo oficial del día esté publicado |
refund_without_items | La devolución no trae reembolsos individuales que rectificar | No | No | Nada — no hay nada que emitir |
refund_autoinvoicing_disabled | La rectificativa automática está desactivada para esa integración | Sí | Sí | Activa la rectificativa automática y reprocesa, o emite la rectificativa a mano — nunca las dos cosas |
subscription_missing_invoice_id | El ciclo cobrado no tiene identificador de factura | Sí | No | Crea a mano la factura de este ciclo; reprocesar daría el mismo resultado |
subscription_proration_review | Se cobró un prorrateo suelto y exige una decisión humana | Sí | No | Comprueba el importe del prorrateo en la pasarela y emite la factura a mano |
subscription_not_a_cycle | La factura de la pasarela no corresponde a un ciclo de suscripción facturable | No | No | Nada — el descarte es correcto |
subscription_trial_skipped | Importe cero o negativo (prueba o crédito): no hay base imponible | No | No | Nada — no hay nada que facturar |
subscription_autoinvoicing_disabled | La auto-facturación de suscripciones está desactivada | Sí | Sí | Activa la auto-facturación de suscripciones y reprocesa el evento |
subscription_already_invoiced | El ciclo ya tiene su factura | No | No | Nada — reprocesarlo sería un no-op por idempotencia |
payout_missing_id | El payout no trae identificador | No | No | Nada — no se puede conciliar ni reprocesar con seguridad |
payout_connected_account_missing | La cuenta conectada del payout no está vinculada | Sí | Sí | Vincula la cuenta conectada y reprocesa el evento |
payment_failed | El cobro falló en la pasarela | No | No | Nada — no hay nada que emitir ni que reintentar |
event_type_not_covered | Tipo de evento fuera del alcance del producto | No | No | Nada — reprocesarlo volvería a no hacer nada |
checkout_lines_retrieve_failed | Degradación, no descarte: la factura sí se emitió, con una línea única | No | No | Nada que reprocesar; revisa las líneas de la factura si te importa el desglose |
Un motivo sin nada que hacer lo dice explícitamente. Once de los veinte son
informativos, y el contrato no se inventa una instrucción para ellos: el endpoint
de detalle devuelve recommended_action: null en lugar de una frase fabricada
para rellenar el campo.
«O una, o la otra» significa una, no las dos. Dos motivos te ofrecen dos salidas — activar el ajuste y reprocesar, o emitir el documento a mano. Son excluyentes. La idempotencia del reproceso va por la identidad del cobro y solo reconoce los documentos emitidos por esa misma vía automática, así que una factura que hayas creado a mano no lo frena. Hacer las dos cosas deja el mismo cobro con dos facturas, cada una numerada en su serie y dada de alta en VeriFactu — un daño fiscal que solo se deshace con una rectificativa.
Avisos: solo lo que puedes arreglar
Un descarte accionable avisa a los administradores de la cuenta. Uno informativo no avisa nunca.
El aviso lleva throttling: si ya existe un aviso sin leer de la misma empresa, la misma pasarela y el mismo motivo dentro de las últimas 24 horas, no se crea un segundo — un webhook mal configurado dispara cientos de eventos idénticos. La condición es sin leer a propósito: una vez lo has leído, si siguen llegando descartes, el siguiente sí avisa. Eso no es ruido, significa que la incidencia sigue viva.
El parqueo y la ventana de 30 días
Cuando un motivo es parqueable, Factuarea guarda el evento crudo cifrado en reposo, para poder reprocesarlo más tarde. Ese contenido nunca se devuelve por la API — ni en el listado, ni en el detalle. Contiene datos personales de tus clientes finales y datos de pago, y existe para exactamente un propósito: hacer posible el reproceso.
El contenido se purga a los 30 días de parquearse el evento. La fila
sobrevive: su motivo, su estado, su fecha y su marca is_parked siguen en tu
bandeja indefinidamente, porque el registro de que un cobro no produjo factura es
historial que puedes necesitar mucho después de que el contenido caduque.
Un evento que sigue con is_parked: true pero ya no es is_replayable
significa exactamente una cosa: la ventana de retención se agotó. La marca se
deriva de si el contenido sigue ahí, así que cambia sola el día que corre la
purga. Un reproceso intentado después devuelve 422 con el subcódigo
integration_event_payload_purged.
El detalle: qué hacer a continuación
El endpoint de detalle devuelve todo lo del listado, más recommended_action:
una frase en imperativo con el siguiente paso para ese motivo concreto, o null
cuando el motivo es informativo.
curl https://api.factuarea.com/v1/integrations/events/0192f3a4-7b2c-7c1d-9e8f-1a2b3c4d5e6f \
-H "Authorization: Bearer $FACTUAREA_API_KEY"La frase distingue a propósito los motivos reproducibles («… y reprocesa el evento») de los que no lo son («… emítela a mano»), de modo que nunca te apunta a una operación que respondería 422.
Un evento de otra empresa y un evento que no existe devuelven el mismo 404
resource_not_found. El endpoint nunca revela si un id existe en otro sitio.
Reprocesar un evento parqueado
Vuelve a procesar un evento de la pasarela que quedó parqueado, una vez que ya no está la causa que le impidió producir su efecto — has reactivado la auto-facturación, has vuelto a vincular la cuenta conectada, ya está disponible el tipo de cambio oficial del día.
Esta acción puede tener consecuencias fiscales reales. Si la causa del
descarte ya está resuelta, el reproceso puede emitir una factura real, con
su número de serie y su alta en VeriFactu. No es un reintento inocuo:
confírmalo con el titular de la cuenta antes de llamarlo. Por eso lleva su
propio scope de escritura, integration_events:write, en lugar del scope de
lectura de la bandeja — una credencial de solo lectura no debe poder facturar
jamás.
curl -X POST https://api.factuarea.com/v1/integrations/events/0192f3a4-7b2c-7c1d-9e8f-1a2b3c4d5e6f/replay \
-H "Authorization: Bearer $FACTUAREA_API_KEY"Cuatro propiedades de esta operación importan más que su firma:
- No duplica facturas. El reproceso pasa por el mismísimo control de idempotencia que el intento original, así que si ese cobro ya produjo una factura, el job se detiene solo y no crea nada.
- Es asíncrono.
202significa aceptado y encolado, no completado. El cuerpo devuelve el evento tal como está ahora — suis_replayablesigue siendotrue—, no el resultado del reintento. El resultado aparece como un evento nuevo en la bandeja, así que consultaGET /v1/integrations/eventspara ver cómo acabó. - Si la causa sigue presente, el evento se descarta otra vez y se registra de nuevo. Es correcto, y es observable.
- No admite entrada. Cualquier parámetro de consulta o clave del cuerpo
devuelve 400
parameter_unknownen lugar de ignorarse. Enviar uno significa que crees estar configurando algo del reintento — un modo, una serie, una fecha — que esta operación no soporta, y aceptarlo en silencio confirmaría esa expectativa falsa sobre una acción que puede emitir una factura. Un cuerpo vacío, o directamente ningún cuerpo, es el caso normal.
Cuando se rechaza un reproceso
is_replayable: true es el contrato: cuando vale true, el reproceso no
responde 422. Es la conjunción de tres condiciones — el evento está parqueado,
todavía conserva su contenido y su motivo admite reproceso —, evaluadas en ese
mismo orden por el propio handler que guarda el reproceso. Eso es lo que te
permite ofrecer un botón de reintento sin adivinar.
Los tres rechazos devuelven 422 business_rule_violation y te dicen cuál es
a través del subcode:
subcode | Qué significa | ¿Hay salida? |
|---|---|---|
integration_event_not_parked | El evento nunca se parqueó — o tuvo éxito, o su motivo no guarda el contenido | No, y nunca la habrá |
integration_event_payload_purged | Se parqueó, pero su contenido se borró al agotarse la ventana de 30 días | No — resuélvelo a mano |
integration_event_reason_not_replayable | Está parqueado y conserva su contenido, pero su motivo volvería a tomar exactamente la misma rama | No — sigue en su lugar la acción recomendada |
Dónde encaja esto
- Auto-facturación con Stripe — el flujo que
produce la mayoría de los eventos que encontrarás aquí, incluidos los
ciclos de suscripción cuyo
ajuste está detrás de
subscription_autoinvoicing_disabled. - Payouts y conciliación bancaria — la
ingesta de payouts que hay detrás de
payout_missing_idypayout_connected_account_missing. - Modo de prueba y sandbox — valida tu tratamiento de la
bandeja con una clave
fact_test_antes de conectar un botón de reproceso a una credencial de producción. - Gestión de errores — el envelope de las respuestas 400, 404 y 422 citadas más arriba.