LealUp Docs
Integraciones

API de ingesta

Envía eventos de uso y eventos de facturación a LealUp desde tu propio sistema, con autenticación por API key.

Si tu producto no está en la lista de integraciones, o quieres enviar eventos propios, la API de ingesta es el camino directo: tu backend hace un POST y los eventos entran a LealUp igual que los de cualquier conector.

Es la única API pública de LealUp hoy. Está pensada para máquinas, no para el navegador: la llave se usa desde tu servidor.

Antes de empezar

  • URL base: https://api.lealup.com/v1
  • Autenticación: cabecera X-API-Key
  • Formato: JSON en la petición y en la respuesta

Conseguir tu API key

  1. Entra a Configuración → API key.
  2. Genera la llave. Tiene el formato sk_live_ seguido de 64 caracteres hexadecimales.
  3. Cópiala en ese momento. Después solo verás los últimos 4 caracteres; el resto queda enmascarado y no hay forma de recuperarla.
  4. Si la pierdes o se filtra, regenérala desde la misma pantalla. La anterior deja de funcionar de inmediato.

La llave identifica a tu organización: LealUp deduce el tenant de la llave, nunca del cuerpo de la petición. Guárdala como cualquier otro secreto de producción y no la publiques en código de cliente.

Una llave sk_live_ da acceso de escritura a los eventos de tu organización. Trátala como una credencial de servidor: variable de entorno o gestor de secretos, jamás en un repositorio ni en JavaScript del navegador.

Enviar eventos de uso

POST /v1/ingest/events

Es el endpoint principal: registra lo que tus usuarios hacen en tu producto. Esos eventos alimentan la adopción, el health score y los disparadores de playbooks.

Cuerpo de la petición

CampoTipoObligatorioDetalle
eventslistaEntre 1 y 100 eventos por lote

Y dentro de cada evento:

CampoTipoObligatorioDetalle
event_nametextoNombre del evento, 1 a 255 caracteres
customer_idtextover notaUUID del cliente en LealUp
external_customer_idtextover notaEl identificador que ese cliente tiene en tu sistema
external_sourcetextonoSistema de origen de external_customer_id. Por defecto internal
user_idtextonoIdentificador del usuario que generó el evento
product_idUUIDnoAtribución a un producto de tu catálogo
product_codetextonoIgual que product_id, pero por código o SKU
propertiesobjetonoPropiedades libres en JSON
timestampfecha ISO 8601noCuándo ocurrió. Por defecto, el momento de la ingesta

Nota sobre el cliente: cada evento necesita una referencia a un cliente, y vale cualquiera de las dos. Si envías customer_id, se usa ese. Si envías solo external_customer_id, LealUp lo resuelve con la combinación de tu organización, external_source y ese identificador; tiene que coincidir con el valor que traía el cliente cuando se creó en LealUp. Si mandas los dos, gana customer_id.

Nota sobre el producto: si envías product_id y product_code, gana product_id. Un evento sin ninguno de los dos es un evento de cuenta, no de producto, y se comporta como siempre. Un producto que no existe en tu catálogo no rechaza el evento: se guarda como evento de cuenta.

Ejemplo

curl -X POST https://api.lealup.com/v1/ingest/events \
  -H "X-API-Key: $LEALUP_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "events": [
      {
        "event_name": "reporte_exportado",
        "external_customer_id": "acme_12345",
        "external_source": "internal",
        "user_id": "u_889",
        "properties": { "formato": "pdf", "filas": 1240 },
        "timestamp": "2026-08-05T14:32:00Z"
      }
    ]
  }'

Respuesta

{
  "accepted": 1,
  "rejected": 0,
  "errors": [],
  "message": "Accepted 1 events for processing"
}

El código es 202 Accepted.

Lotes parcialmente aceptados

Esto es lo más importante de este endpoint: un evento malo no bota el lote. La respuesta sigue siendo 202 y te dice cuáles no entraron:

{
  "accepted": 2,
  "rejected": 1,
  "errors": [
    {
      "index": 1,
      "event_name": "reporte_exportado",
      "code": "unknown_external_customer",
      "message": "..."
    }
  ],
  "message": "Accepted 2 events; 1 rejected"
}

El index es la posición del evento dentro del arreglo que enviaste, empezando en 0. Los códigos posibles:

CódigoQué significa
unknown_customerEnviaste customer_id, pero no existe un cliente con ese UUID en tu organización
unknown_external_customerLa combinación de external_source y external_customer_id no resolvió a ningún cliente
invalid_customer_idEl customer_id no es un UUID válido

Revisa siempre rejected. Un 202 no significa que entraron todos.

Si mandas una Idempotency-Key y te llega rejected > 0, corrige y reenvía SOLO los elementos corregidos — con una llave NUEVA. La respuesta que recibiste queda en caché bajo la llave que mandaste, por 24 horas (ver "Reintentos e Idempotency-Key" más abajo). Reenviar el MISMO lote — incluso después de corregir los datos — con la MISMA llave repite esa respuesta ORIGINAL tal cual, con el rechazo incluido: no se escribe nada nuevo, y el evento que querías corregir nunca entra. Una llave NUEVA es lo que hace que el reenvío corregido se ejecute de verdad.

Enviar eventos de facturación

POST /v1/ingest/billing-events

Registra hechos de cobranza (pagos fallidos, disputas, reintentos) que alimentan la salud de pago del cliente.

Cuerpo de la petición

CampoTipoObligatorioDetalle
eventslistaEntre 1 y 50 eventos por lote

Y dentro de cada evento:

CampoTipoObligatorioDetalle
event_typetextoUno de: payment_failed, payment_succeeded, invoice_disputed, dunning_attempt, refund_issued
customer_idUUIDUUID del cliente en LealUp. Aquí no sirve el identificador externo
amountnúmeronoMonto, cero o positivo
currencytextonoCódigo ISO 4217 de 3 letras. Por defecto USD
statustextonoUno de: pending, resolved, escalated. Por defecto pending
product_idUUIDnoAtribución a un producto de tu catálogo
product_codetextonoIgual que product_id, pero por código o SKU

Nota sobre el producto: si envías product_id y product_code, gana product_id. Un evento sin ninguno de los dos es un evento de cuenta, y se comporta como siempre. Un producto que no existe en tu catálogo no rechaza el evento: se guarda a nivel cuenta y la respuesta lo avisa en warnings, con el índice del evento dentro del lote. Revisa warnings igual que revisas rejected: el lote entra con un 202 aunque la atribución se haya perdido.

Ejemplo

curl -X POST https://api.lealup.com/v1/ingest/billing-events \
  -H "X-API-Key: $LEALUP_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "events": [
      {
        "event_type": "payment_failed",
        "customer_id": "6f1c2e40-9b3a-4a11-8f0e-2a7c5d9e1b44",
        "amount": 249.90,
        "currency": "USD",
        "status": "pending"
      }
    ]
  }'

Respuesta

{
  "accepted": 1,
  "rejected": 0,
  "skipped": 0,
  "warnings": [],
  "errors": [],
  "message": "Accepted 1 billing events (0 skipped)"
}

También 202 Accepted. skipped cuenta los eventos que la base de datos rechazó por chocar con uno ya registrado.

Un customer_id que no existe en tu organización es un error por evento, igual que en el endpoint de eventos de uso: la respuesta sigue siendo 202, lo cuenta en rejected y lo nombra en errors con el código unknown_customer. No es un 500 ni un rechazo del lote completo.

Este endpoint no deduplica por contenido: envía cada evento una sola vez. El choque que skipped reporta se decide, entre otros campos, por el instante exacto en que se escribió la fila, así que dos envíos del mismo evento — sin una Idempotency-Key compartida — entran como dos eventos distintos. En la práctica skipped siempre llega en 0.

Reintentar el mismo lote SÍ es seguro si mandas una Idempotency-Key (ver "Reintentos" más abajo): el reintento repite la respuesta original en vez de volver a escribir las filas. Lo que no hace es detectar dos peticiones DISTINTAS que describen el mismo evento de facturación — si tu proceso reenvía sin reutilizar la llave, lleva tú el control de qué ya mandaste.

Enviar leads (web-to-lead)

POST /v1/ingest/leads

Es la puerta de entrada del pipeline de ventas: el formulario de contacto o demo de tu sitio hace un POST (desde tu backend, con la misma X-API-Key — la llave nunca va en el navegador) y LealUp da de alta la cuenta, el contacto, una oportunidad en la primera etapa del pipeline de ventas y una nota con el mensaje.

Cuerpo de la petición

CampoTipoObligatorioDetalle
companytextoNombre de la empresa, 1 a 255 caracteres
emailtextoEmail de contacto — es la llave de deduplicación
contact_nametextonoNombre de la persona de contacto
phonetextonoTeléfono de contacto
messagetextonoMensaje libre del formulario, hasta 5000 caracteres
reasontextonoMotivo de contacto tal como lo captura tu propio formulario (por ejemplo, un select que armaste tú)
source_tagtextonoDe dónde viene el formulario (ej. landing-pricing) — queda registrado en la nota
product_idUUIDnoProducto de tu catálogo por el que pregunta el lead. Gana sobre product_name si mandas los dos
product_nametextonoProducto de tu catálogo por nombre, en vez de UUID. Se resuelve por coincidencia exacta contra tu catálogo

Ejemplo

curl -X POST https://api.lealup.com/v1/ingest/leads \
  -H "X-API-Key: $LEALUP_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 4b1e9f2a-6c3d-4e8a-9f21-7a5c3e9d1b44" \
  -d '{
    "company": "Acme SpA",
    "email": "[email protected]",
    "contact_name": "María Pérez",
    "message": "Quiero una demo del producto",
    "source_tag": "landing-pricing",
    "product_name": "Plataforma de customer success"
  }'

Respuesta

{
  "customer_id": "6f1c2e40-9b3a-4a11-8f0e-2a7c5d9e1b44",
  "contact_id": "8a2d1f30-7c4b-4e22-9a11-3b6c5d8e1f22",
  "opportunity_id": "1e9f4a20-5b3c-4d11-8e0a-2c7d5e9f1a44",
  "matched_by": null,
  "warnings": [],
  "deduped": false,
  "message": "Lead registrado"
}

El código es 201 Created.

  • matched_by: "email" cuando el email ya existía como contacto en tu organización y se reutilizó esa cuenta; null cuando se creó una cuenta nueva.
  • deduped: equivalente a matched_by !== null — se mantiene por compatibilidad hacia atrás.
  • opportunity_id: null cuando no hay un pipeline de ventas activo, no hay un dueño resoluble, no se resolvió un producto, o la cuenta ya tenía una oportunidad abierta para ese mismo producto (la deduplicación es por cuenta × producto, no solo por cuenta). El motivo exacto queda en warnings — nunca es un null silencioso.
  • warnings: notas para el desarrollador (en inglés) sobre lo que se degradó en esta petición. Un product_name que no coincide exactamente con tu catálogo, por ejemplo, deja entrar igual a la cuenta, el contacto y la nota, pero sin abrir oportunidad — es degradación honesta, no un error. Un 201 con warnings vacío es un lead resuelto por completo. Códigos frecuentes:
CódigoQué significa
opportunity_skipped:missing_productNo mandaste product_id/product_name, o ninguno se resolvió — la cuenta y el contacto se registran igual
opportunity_skipped:missing_ownerNo hay dueño de leads entrantes configurado ni admin activo en el tenant
no_active_sales_pipelineEl tenant no tiene un pipeline de ventas activo
product_id_not_found:<uuid> / product_name_not_found:<nombre>El producto que mandaste no existe en el catálogo del tenant
product_id_inactive:<uuid> / product_name_inactive:<nombre>El producto existe pero está dado de baja
possible_duplicate_account:<uuid>El nombre de la empresa se parece mucho a una cuenta que ya existe. Se crea una cuenta nueva de todas formas, marcada para revisión manual — nunca se fusiona sola
replayed_from_ledgerEsta petición es un reintento de un lead que ya se registró por completo, reconocido a través de un registro aparte y de vida más larga que se guarda por 90 días — ver "Reintentos después de 24 horas" más abajo. El customer_id/contact_id/opportunity_id de la respuesta son los ORIGINALES; no se creó nada nuevo

(Lista no exhaustiva — cualquier código nuevo sigue el mismo patrón: categoría o categoría:id.)

Reintentos e Idempotency-Key

Manda una cabecera Idempotency-Key con cualquier petición a /v1/ingest/events, /v1/ingest/billing-events o /v1/ingest/leads — cualquier string, mientras sea único por envío lógico. Genera un UUID nuevo para cada envío nuevo (cada lote nuevo, cada lead nuevo) y reutiliza ESE MISMO UUID solo para reintentar ESE envío — nunca derives la llave del contenido de la petición. Un hash del contenido suena prolijo pero rompe justo el caso para el que existe la llave: un lote que vuelve con rejected > 0, al que le corriges las filas malas y reenvías, necesita una llave NUEVA (ver la advertencia en "Lotes parcialmente aceptados" más arriba) — un hash "del lote" no tiene forma de saber que el lote cambió si lo sigues derivando de la misma manera. Tampoco metas la hora del envío en la llave: un doble clic, o un reintento que le mete su propio timestamp, produciría una llave distinta en cada intento y la protección desaparece.

Nunca pongas un email, un nombre ni ningún otro dato personal en la llave misma. A diferencia de los campos de tu payload, una llave que elige el que llama no tiene ningún enmascarado de nuestro lado — puede aparecer en nuestros diagnósticos de soporte si alguna vez abres un ticket sobre una petición puntual. Un UUID al azar evita el problema por completo.

LealUp recuerda la llave por 24 horas y, ante un reenvío, repite exactamente la MISMA respuesta que dio la primera vez — mismo código de estado, mismo cuerpo — en vez de volver a hacer el trabajo:

  • Misma llave, mismo cuerpo → la respuesta original, repetida. La respuesta trae la cabecera Idempotent-Replay: true, así que puedes distinguir una repetición de una corrida nueva; no se escribe nada de nuevo. En /leads eso significa que vuelven el mismo customer_id/contact_id/opportunity_id, no una segunda cuenta.
  • Misma llave, una petición anterior con ella todavía está corriendo → 409. Dos peticiones que se solapan con la misma llave (un doble envío ingenuo, no un reintento deliberado) reciben esto en vez de correr las dos a la vez. Espera alrededor de un segundo y manda exactamente la misma petición de nuevo — la cabecera Retry-After te dice cuánto — y para entonces la primera ya habrá terminado, dándote su respuesta repetida. Si nuestro propio proceso se interrumpe a mitad de camino (un deploy, una caída), la llave puede quedar bloqueada hasta por un minuto antes de liberarse sola — sigue reintentando con espera creciente en vez de darte por vencido.
  • Misma llave, cuerpo distinto → 422. Reutilizar una llave para algo que en realidad no es un reintento de la misma petición es un error de tu integración, no un duplicado — repetir la respuesta equivocada sería una falla silenciosa de integridad de datos, así que esto se rechaza en vez de repetirse. La comparación es sobre el cuerpo de la petición en bruto, byte a byte idéntico, no sobre su significado — un SDK que reserializa el JSON en un reintento (por ejemplo, reordenando llaves) puede disparar esto aunque "la petición" te parezca la misma; reenvía los bytes exactos.
  • Ante un timeout o un 5xx, reintenta con la MISMA llave — es justo lo que te pedimos que hagas.

La protección de arriba es best-effort. En la rara ocasión en que nuestro caché no está disponible, un reintento se procesa como una petición completamente nueva en vez de reconocerse como una repetición — simplemente no vas a ver Idempotent-Replay: true, y la petición corre como si fuera normal. Para eventos de facturación en particular vale la pena planificar esto: lleva tú el registro de qué ya mandaste, igual que harías si no estuvieras mandando ninguna llave, porque este es el único caso donde un duplicado es un costo real y no solo una pantalla que se repite. (/v1/ingest/leads es la excepción — ver "Reintentos después de 24 horas" justo abajo: ahí un reintento se sigue reconociendo, y uno con cuerpo distinto se sigue rechazando, bastante más allá de una caída del caché.)

Reintentos después de 24 horas (solo /v1/ingest/leads)

Para el envío de leads en particular, hay un segundo registro de vida más larga que se conserva por 90 días — mucho más que las 24 horas del caché de arriba. Si el caché ya no se acuerda de tu llave (pasaron las 24 horas, o estuvo brevemente no disponible) pero la petición ORIGINAL con esa misma llave ya se había registrado por completo, igual te devolvemos el 201 original — mismo customer_id/contact_id/opportunity_id — esta vez marcado con replayed_from_ledger en warnings en lugar de la cabecera Idempotent-Replay: true. Revisa cualquiera de las dos señales si quieres detectar una repetición de forma confiable más allá del primer día. La regla de cuerpo igual/distinto de arriba también se mantiene: un reintento con cuerpo distinto igual recibe un 422, incluso después de 24 horas o durante una caída del caché — se rechaza, nunca se aplica en silencio al lead equivocado. /v1/ingest/events y /v1/ingest/billing-events no tienen este segundo registro; para esos dos, una vez que el caché ya no se acuerda de una llave, un reintento corre genuinamente como una petición nueva.

Sin Idempotency-Key no hay protección contra duplicados: dos envíos de la misma petición — un doble clic del usuario en un formulario, un reintento que genera una llave nueva cada vez — se procesan cada uno por completo, y en /leads eso significa dos cuentas.

Límites

LímiteValor
Peticiones por minuto (eventos + facturación)300 por organización
Peticiones de leads por minuto60 por organización
Peticiones simultáneas (eventos + facturación)8 a la vez, por organización
Peticiones de leads simultáneas8 a la vez, por organización
Eventos de uso por lote100
Eventos de facturación por lote50
Tamaño del cuerpo de la petición1 MiB

El límite de 300 por minuto es compartido entre los endpoints de eventos y de facturación y se cuenta por organización, no por llave ni por IP. POST /v1/ingest/leads tiene su propio cupo de 60 por minuto — no compite por cupo con los eventos ni con la facturación. Ambos son valores por defecto: si tu volumen lo justifica, se pueden subir para tu organización.

Aplican dos límites independientes, no uno. La tabla de arriba acota el volumen; un límite aparte acota la simultaneidad: cuántas peticiones tuyas estamos atendiendo en el mismo instante. Aunque estés bien por debajo de tu cupo por minuto, enviar más de 8 peticiones a la vez a los endpoints de eventos/facturación (u 8 a leads) hace que las más nuevas reciban un 429 con Retry-After: 1 — un aviso de un segundo, no de un minuto, porque se espera que una ráfaga que se despeja un instante después funcione. Si tu integración dispara peticiones en paralelo, limita ese paralelismo de tu lado en vez de esperar que nosotros lo encolemos por ti.

Cabeceras de rate limit

Una respuesta exitosa, y un 429 del límite por minuto, traen seis cabeceras que describen esa ventana de volumen:

CabeceraSignificado
RateLimit-Limit / X-RateLimit-LimitPeticiones permitidas por minuto para el nivel de este endpoint
RateLimit-Remaining / X-RateLimit-RemainingPeticiones que quedan en la ventana actual de un minuto
RateLimit-Reset / X-RateLimit-ResetSegundos que faltan para que la ventana se reinicie

RateLimit-* es la forma del draft del IETF; X-RateLimit-* es la más antigua y más soportada — las dos traen el mismo valor, así que usa la que ya conozca tu cliente HTTP. Lee RateLimit-Remaining para regular tu propio ritmo en vez de esperar a que te rechacen: para eso existen estas cabeceras y no solo un 429.

Estas seis cabeceras hablan puntualmente del cupo de volumen por minuto, así que están ausentes cuando ese cupo nunca se consultó: un 429 del límite de simultaneidad de arriba (deniega antes de tocar la ventana por minuto, así que no tiene nada fresco que reportar — solo Retry-After: 1), y cualquier respuesta donde el limitador no pudo ejecutarse (sin contexto de organización, o nuestra caché brevemente inalcanzable — LealUp falla abierto en vez de bloquear tu tráfico por un problema de nuestra propia infraestructura).

Tamaño del cuerpo de la petición

Las peticiones JSON a estos endpoints están limitadas a 1 MiB — muy por encima del tráfico real (un lote de 100 eventos pesa unos 50 KB) y solo pensado para rechazar un cuerpo patológico antes de leerlo. Un cuerpo demasiado grande recibe un 413 antes de que LealUp haga cualquier trabajo con él.

Para volúmenes altos, agrupa: 100 eventos en una petición cuestan lo mismo que 1.

Errores

Toda respuesta de error es application/problem+json, siguiendo la RFC 7807. La forma es siempre la misma — type, title, status, un detail opcional, el trace_id y el instance (la URL que llamaste) —, así que con un solo parser cubres todos los códigos. Algunos errores agregan miembros extra encima: errors en un 422, retry_after en un 429.

Toda respuesta, exitosa o no, trae además la cabecera X-Trace-Id, con el mismo valor que el trace_id del cuerpo. Guárdala: es lo primero que te va a pedir soporte.

401, problema con la llave

{
  "type": "https://lealup.com/errors/401",
  "title": "Unauthorized",
  "status": 401,
  "detail": "Missing X-API-Key header",
  "trace_id": "01a081cb9c0afe8feb35cfa5cc830bc8",
  "instance": "https://api.lealup.com/v1/ingest/events"
}

Aparece si falta la cabecera X-API-Key o si la llave no es válida (el detail cambia a Invalid API key). La causa más común es haber regenerado la llave y no haber actualizado la variable de entorno.

422, el cuerpo no pasa la validación

{
  "type": "https://lealup.com/errors/validation",
  "title": "Validation error",
  "status": 422,
  "detail": "events: List should have at most 100 items after validation, not 101",
  "trace_id": "01a081cbaaeede9ff4e3549c2671d656",
  "instance": "https://api.lealup.com/v1/ingest/events",
  "errors": [{ "field": "events", "code": "too_long" }]
}

Lee errors, nunca detail. errors es el contrato: una entrada por problema, con un field y un code sobre los que puedes ramificar y armar tu propio mensaje, en tu propio idioma. detail es prosa para quien lee un log o abre un ticket: está en inglés, puede citar textualmente a un validador de terceros y su redacción no está garantizada entre versiones.

  • field es la ruta al campo con problema, sin la parte de la petición: events.0.event_type es el event_type del primer evento del lote. Una regla que aplica a todo el cuerpo —incluido un cuerpo que no es JSON válido— llega como __root__.
  • code es el tipo de error de Pydantic, una clave estable: missing, too_long, too_short, string_too_short, value_error, json_invalid, entre otros.

Ojo con la diferencia: un lote de 101 eventos, o un event_type que no está en la lista permitida, son errores de validación y botan el lote completo con 422. Un cliente que no existe es un error por evento: el lote igual devuelve 202 y nombra ese evento en la lista errors de esa respuesta.

409, otra petición con esta llave sigue corriendo

{
  "type": "https://lealup.com/errors/idempotency-key-in-flight",
  "title": "Duplicate request",
  "status": 409,
  "detail": "A request with this Idempotency-Key is already being processed. Retry after a second.",
  "trace_id": "01a081cb8f6fe69f4f83521b68c997ff",
  "instance": "https://api.lealup.com/v1/ingest/leads"
}

En cualquiera de los tres endpoints de ingesta, cuando dos peticiones con la MISMA Idempotency-Key se solapan — típicamente un doble envío ingenuo, no un reintento deliberado. Deberías ver esto solo mientras la primera petición todavía está corriendo. Espera alrededor de un segundo (la cabecera Retry-After te dice cuánto exactamente) y manda exactamente la misma petición de nuevo con la misma llave: para entonces la primera ya habrá terminado, y te llega su respuesta repetida — con la cabecera Idempotent-Replay: true — no un segundo 409. Si te llega esto justo después de que la primera petición dio un 5xx, algo anda mal: por diseño, una falla del lado de LealUp libera la llave para que el reintento sí se procese (ver "Reintentos" arriba).

422, la Idempotency-Key se reutilizó con un cuerpo distinto

{
  "type": "https://lealup.com/errors/idempotency-key-reused",
  "title": "Idempotency-Key reused with a different body",
  "status": 422,
  "detail": "This Idempotency-Key was already used for a request with a different body. Use a new key for a different request.",
  "trace_id": "01a081cb8f6fe69f4f83521b68c997ff",
  "instance": "https://api.lealup.com/v1/ingest/leads"
}

Te llega esto cuando mandas la misma Idempotency-Key en una petición cuyo cuerpo no coincide con el de la petición para la que se usó esa llave por primera vez. NO es el 422 de validación de arriba — no trae un arreglo errors — significa que la llave se está reutilizando para algo que en realidad no es un reintento de la misma petición. Genera una llave nueva para una petición genuinamente distinta; nunca reutilices una entre dos lotes o dos leads diferentes.

429, superaste el límite

{
  "type": "https://lealup.com/errors/rate-limited",
  "title": "Rate limit exceeded",
  "status": 429,
  "detail": "Rate limit exceeded for tier 'ingestion'. Try again in 37 seconds.",
  "trace_id": "01a081ce4a405c7b01b70a30e8076405",
  "instance": "https://api.lealup.com/v1/ingest/events",
  "retry_after": 37
}

retry_after es un miembro de primer nivel del objeto de error: los segundos que faltan, como número. La respuesta trae además la cabecera Retry-After con ese mismo valor; respétala en vez de reintentar de inmediato.

500, algo falló de nuestro lado

{
  "type": "https://lealup.com/errors/500",
  "title": "Internal Server Error",
  "status": 500,
  "detail": "Failed to ingest events",
  "trace_id": "01a081cbd3e14f8a97c2b0d1e6a45f77",
  "instance": "https://api.lealup.com/v1/ingest/events"
}

detail nombra el endpoint que falló: Failed to ingest billing events en /v1/ingest/billing-events, y un genérico An unexpected error occurred en /v1/ingest/leads, donde la ruta no atrapa el error por su cuenta. Guíate por status y type — nunca por ese texto. Esa respuesta genérica puede llegar además sin la cabecera X-Trace-Id y sin el miembro trace_id; si ocurre, anota la hora exacta de la petición y el endpoint que llamaste, que la identifican igual de bien.

Ningún evento del lote se guardó: la escritura es atómica por petición. Reintenta con espera creciente y, si persiste, escríbenos con el X-Trace-Id — o con esa hora y ese endpoint si falta.

503, la consulta tardó demasiado

{
  "type": "https://lealup.com/errors/query-timeout",
  "title": "Query Timeout",
  "status": 503,
  "detail": "The database took too long to answer this request. Try again shortly.",
  "trace_id": "01a081cf12d3e4f5a67b8c90d1e2f3a4",
  "instance": "https://api.lealup.com/v1/ingest/events"
}

Toda petición tiene un límite de 5 segundos para la consulta a la base de datos de nuestro lado. Es poco frecuente —significa que la base de datos tardó inusualmente en esta petición en particular— y es seguro reintentar: no se guardó nada cuando esto pasa. Reintenta con espera creciente, igual que ante un 429 o un 500.

Cómo integrar bien

  • Envía por lotes, no evento por evento. Acumula y despacha cada pocos segundos o cada 100 eventos, lo que llegue primero.
  • Reintenta solo los 429 y los 5xx, con espera creciente. Un 422 no mejora al reintentarlo: el cuerpo está mal y hay que corregirlo.
  • Reintentar sin Idempotency-Key reenvía. Si una petición se cae por timeout sin que sepas si llegó, un reintento sin llave puede registrar los eventos dos veces. Manda una Idempotency-Key en cada lote (ver "Reintentos" arriba) y un reintento repite en vez de reenviar — para métricas de adopción un doble envío ocasional suele ser tolerable, pero la llave es la solución, no un parche.
  • No bloquees a tu usuario. Manda los eventos desde una cola en tu backend, no dentro del request que atiende a la persona.
  • Empieza con external_customer_id. Te evita mantener una tabla de equivalencias con los UUID de LealUp.
  • Revisa rejected y registra los errors. Es donde vas a ver que un cliente nuevo todavía no existe en LealUp.

Preguntas frecuentes

¿Puedo llamar la API desde el navegador?

No. La llave da acceso de escritura a los eventos de toda tu organización y en el navegador queda expuesta. Llama siempre desde tu servidor.

¿Qué pasa si envío un evento de un cliente que todavía no existe en LealUp?

Ese evento se rechaza con unknown_customer o unknown_external_customer, y el resto del lote entra igual. Crea primero el cliente (a mano, por CSV o por una integración de CRM) y reenvía — con una Idempotency-Key NUEVA si mandaste una la primera vez (ver la advertencia en "Lotes parcialmente aceptados"): la llave anterior sigue apuntando a la respuesta que traía el rechazo.

¿Puedo borrar un evento ya enviado?

No por la API. Escríbenos a [email protected] si necesitas corregir datos.

¿Hay librería oficial?

Todavía no. Son dos endpoints con JSON: cualquier cliente HTTP sirve.

On this page