Cairos
Facturación
Programa de facturaciónPresupuestosFacturas recurrentesGastos y proveedoresCobros y tesorería
Contabilidad e impuestos
ContabilidadModelos de HaciendaLibros registroInmovilizadoIGIC y Canarias
Operaciones
Inventario y almacenesCRMControl horarioProyectosSubvenciones y ayudas
Cumplimiento
VeriFactuTicketBAIFactura electrónicaToda la normativaSeguridad y datos
Por tipo de negocio
AutónomosPymesAsesorías y gestoríasExtranjeros en EspañaStartupsComercios y tiendas
Por sector
Hostelería y restaurantesConstrucción y reformasServicios profesionalesComercio electrónicoTodos los sectores
Por forma jurídica
AsociacionesFundacionesCooperativasClubes deportivosTodas las formas jurídicas
Cambiar de programa
ComparativasAlternativa a HoldedMigrar tus datos
Herramientas gratis
Plantilla de facturaCalculadora de IVACalculadora de IRPFTodas las herramientas
Aprender
GuíasGlosarioCalendario fiscalBlog
Desarrolladores
API y documentaciónEmpezar en cinco minutosReferencia de recursosWebhooks
Ayuda
Centro de ayudaContacto
Precios
Empieza gratis Iniciar sesión
Errores y límites

Siete códigos, y sólo dos se reintentan

Todos los errores llegan en el mismo sobre y con un código de una lista cerrada. Lo que hay que saber de cada uno no es qué significa, sino si tiene sentido volver a intentarlo.

Formato único de errorPaginación por cursorReintento con espera creciente

La API está en marcha. Esto es lo que hay hoy y lo que no

Funciona. Las rutas de esta documentación están desplegadas y respondiendo en https://erp.cairos.es/api/v1/. Los ejemplos de estas páginas se pueden copiar y ejecutar. La especificación completa está en openapi.json, que es lo que importan Make y n8n.

Las claves te las creas tú, desde Desarrolladores en tu cuenta de Cairos. Empieza con una cai_test_: trabaja contra tus datos de verdad pero no registra en VeriFactu ni envía correos, así que puedes montar tu integración sin ensuciar una serie de facturación.

Y lo que todavía NO hay, dicho sin adornos: ningún conector oficial. Ni app de Shopify, ni plugin de WooCommerce, ni módulo publicado en Make o n8n. Con la API y su especificación se pueden construir —para eso están las guías de esta sección— pero construirlos es trabajo, y ese trabajo no está hecho.

Si montas algo con esto, escríbenos a info@cairos.es. Nos interesa especialmente lo que te falte del contrato: es lo que decide qué se amplía primero.

Un solo sobre para todos los errores

Cualquier respuesta que no sea un 2xx trae exactamente esta forma. Sin excepciones, sin páginas HTML de error y sin un formato distinto según el recurso.

404 Not Found
{
  "error": {
    "codigo": "no_encontrado",
    "mensaje": "No existe una factura con ese identificador.",
    "detalles": null
  }
}
  • codigo es para tu programa: es lo que puedes meter en un switch. Hoy son siete y un código existente no se renombra nunca, porque eso rompería el if de quien ya integra sin que nadie se entere. Lo que sí puede pasar algún día es que se añada uno; deja una rama por defecto y no te pillará.
  • mensaje es para una persona. Está en español y puede cambiar de redacción sin previo aviso, así que no lo uses para decidir nada: nunca compares el texto de un mensaje.
  • detalles es null casi siempre. Cuando no lo es, trae el desglose de qué campo falla y por qué —y, en un 500, una referencia corta que localiza ese fallo exacto en nuestro registro.

Los siete códigos, y qué hacer con cada uno

La columna que importa es la tercera. Un error del que no sabes si reintentar es un error que acaba en una tarea manual a las once de la noche.

Y una precisión antes de la tabla: el codigo y el estado HTTP no son lo mismo. Los códigos son siete; los estados, uno más, porque error_interno viaja tanto en un 500 como en el 503 del PDF.

HTTPcodigo¿Reintentar?Qué significa
401no_autenticadoNo.No ha llegado clave, o no vale, o está revocada. Reintentar da lo mismo.
403sin_permisoNo.La clave vale pero le falta el scope. Hace falta una clave con más permisos, no otra llamada.
404no_encontradoNo.Ese identificador no existe en tu organización, o esa ruta no existe. Un id de otra empresa da 404 igual que uno inventado, y eso es deliberado: si dijera «no puedes» ya habría confirmado que existe.
409conflictoNo, sin cambiar algo.El estado no permite lo que pides: reutilizar una llave de idempotencia con otro cuerpo, convertir dos veces el mismo presupuesto, borrar un contacto que ya tiene facturas, sincronizar una tienda que está pausada.
422datos_invalidosNo, sin corregir.El JSON llegó y algo de dentro no vale. detalles dice qué campo.
429demasiadas_peticionesSí, esperando.Has ido demasiado rápido. Espera y vuelve, con espera creciente.
500error_internoSí, con cuidado.Se ha roto algo por nuestro lado. Reintenta con espera creciente y con Idempotency-Key, o duplicarás. Trae una referencia dentro: guárdala.
503error_internoSí, enseguida.Sólo lo devuelve GET /facturas/{id}/pdf: el PDF se imprime de verdad y la cola estaba ocupada. Casi siempre sale a la segunda.

Regla corta: los 4xx son cosa tuya y reintentarlos sin cambiar nada sólo gasta cuota; los 5xx y el 429 son los que se reintentan.

El 422, que es el único que te dice dónde mirar

Cuando la validación falla, detalles trae una lista en campos, y cada entrada dice qué campo falla y por qué. El nombre del campo lleva el índice dentro cuando el fallo está en una lista, así que señala la línea exacta de una factura de treinta:

422 Unprocessable Content
{
  "error": {
    "codigo": "datos_invalidos",
    "mensaje": "Los datos no son validos: iva no es un campo de este recurso.",
    "detalles": {
      "campos": [
        { "campo": "iva", "motivo": "no es un campo de este recurso" },
        { "campo": "lineas[1].precio", "motivo": "es obligatorio" }
      ]
    }
  }
}

Fíjate en que es una lista y no un objeto de campo a mensaje: un mismo campo puede fallar por dos motivos, y con un objeto uno de los dos se perdería. Y el índice va entre corchetes —lineas[1]—, contando desde cero, así que la segunda línea es la del uno.

Enséñaselo a quien esté usando tu integración en vez de tragártelo. La mitad de los 422 son un dato mal escrito en el otro sistema, y quien puede arreglarlo es quien lo escribió, no tú.

Y el motivo más aburrido de todos: un nombre de campo que no existe. La API no ignora en silencio lo que no conoce, así que un iva donde iba tipo_iva, o un concepto donde iba descripcion, sale por aquí. Si un ejemplo copiado de cualquier parte te devuelve un 422, esto es lo primero que hay que mirar.

Paginación por cursor, no por número de página

Todas las listas se recorren igual, con tres parámetros:

ParámetroQué hace
limiteCuántos elementos por respuesta. 50 por defecto, 200 de tope. Pedir más de 200 no es un error: te dan 200, y el limite de la respuesta te lo dice.
desdeEl cursor. Es una cadena opaca: no es el id del último elemento ni una fecha, y no hay que construirla a mano. Sale de la respuesta anterior.
ordenreciente (el de por defecto), antiguo —el que hay que usar para recorrer un histórico entero— o modificado, para sondear lo que ha cambiado.
GET /facturas?limite=2
{
  "datos": [
    { "id": "clx3k2p9y0001qz8h5e6f7g8h",
      "referencia": "F27-0042", "total": 435.60 },
    { "id": "clx3k2p9y0004qz8hq7r8s9t0",
      "referencia": "F27-0041", "total": 1210.00 }
  ],
  "total": 1284,
  "limite": 2,
  "siguiente": "https://erp.cairos.es/api/v1/facturas?limite=2&desde=cmVjaWVudGV8MTc2..."
}

siguiente es la URL entera de la página que sigue, con los filtros que llevabas ya puestos. No es el cursor suelto: se pide tal cual y no hay que recomponer nada ni acordarse de arrastrar el ?estado= que pusiste al principio. Y total es cuántos hay en total, para poder enseñar un progreso.

Viene a null cuando ya no queda nada, y ésa —y no que datos venga vacío— es la condición con la que se termina el bucle.

El cursor va atado a su orden

Un cursor dice «sigue después de aquí», y «después» significa una cosa distinta en cada orden. Cambiarlo a mitad de recorrido no tiene ningún resultado correcto posible, así que la API lo rechaza con un 422 en vez de devolverte una lista con huecos. Si necesitas otro orden, empiezas la lista de nuevo.

Para volcar un histórico entero usa orden=antiguo, que va sobre una fecha que ya no se mueve. orden=modificado existe para los disparadores por sondeo de Make, Zapier y n8n —pone arriba lo último que ha cambiado— y tiene un coste que conviene saber: el orden se mueve mientras paginas, así que sirve para mirar las últimas y no para recorrer el histórico.

// "siguiente" ya viene con los filtros y el cursor dentro:
// se sigue tal cual y no se recompone nada.
async function todas(recurso) {
  const salida = [];
  let url = BASE + recurso + "?limite=200&orden=antiguo";

  while (url) {
    const r = await fetch(url, {
      headers: { Authorization: "Bearer " + CLAVE },
    });
    if (!r.ok) throw new Error("Cairos respondio " + r.status);

    const pagina = await r.json();
    salida.push(...pagina.datos);
    url = pagina.siguiente;
  }

  return salida;
}

Por qué cursor y no ?pagina=7

Porque los datos se mueven mientras los lees. Con números de página, una factura nueva creada a mitad del recorrido desplaza todo un puesto: acabas leyendo dos veces un elemento y saltándote otro, y no te enteras. El cursor apunta a un sitio fijo de la lista, así que lo que llega después no descoloca lo que ya has leído.

El límite de peticiones: 120 por minuto y por clave

120 peticiones por minuto y por clave, en ventana deslizante. Al pasarlo vuelve un 429 demasiadas_peticiones con un Retry-After que dice cuántos segundos esperar.

Deslizante y no un contador que se pone a cero al dar la hora: con eso se podrían meter 120 a las 10:00:59 y otras 120 a las 10:01:00, que son 240 en un segundo y es justo el pico que se quiere evitar.

Da de sobra para lo que se hace de verdad —una sincronización nocturna gasta unas tres llamadas por pedido, así que son unos cuarenta pedidos por minuto—, y es lo bastante bajo para que una integración en bucle no deje sin respuesta al resto de clientes.

Y no hace falta llevar la cuenta tú: las tres cabeceras RateLimit-* van en todas las respuestas, no sólo en el rechazo.

CabeceraQué dice
RateLimit-LimitEl tope de la ventana. Hoy, 120.
RateLimit-RemainingCuántas te quedan. Un cliente bien hecho mira esto y se frena solo antes de comerse el 429.
RateLimit-ResetSegundos hasta que se libere el primer hueco.
Retry-AfterLo mismo, pero sólo en el 429. Va en el formato que entiende cualquier librería de reintento desde 1999.

Aun así, escribe el cliente para que no dependa de la cifra, que es como hay que escribirlo de todos modos:

  • Reintenta el 429 con espera creciente. Un segundo, dos, cuatro, ocho, y ríndete a los cinco intentos.
  • Añade un poco de azar a la espera. Si cinco procesos tuyos reintentan al mismo tiempo, vuelven a chocar todos a la vez. Un margen aleatorio los separa.
  • No hagas un bucle apretado. Para volcar mil facturas, limite=200 y cinco llamadas es mejor que mil llamadas de una en una, y encima es más rápido.
  • Escucha en vez de preguntar. Consultar cada minuto por si hay algo nuevo es lo que agota cualquier límite. Para eso están los webhooks.
Reintento con espera creciente
async function conReintento(peticion, intentos = 5) {
  for (let i = 0; i < intentos; i++) {
    const r = await peticion();

    // Sólo estos dos se reintentan. Un 4xx reintentado
    // gasta cuota y falla exactamente igual.
    if (r.status !== 429 && r.status < 500) return r;

    const espera = Math.pow(2, i) * 1000 + Math.random() * 400;
    await new Promise((listo) => setTimeout(listo, espera));
  }
  throw new Error("Cairos no responde despues de varios intentos");
}

Reintentar sin llave de idempotencia es cómo se duplican las facturas

Un reintento de una escritura es exactamente el caso para el que existe Idempotency-Key. Si vas a reintentar un POST, mándala; si no, el primer 500 que te encuentres puede haber creado ya la factura que estás a punto de crear otra vez. Está contado entero en idempotencia.

Y cuando el error es nuestro

Un 500 error_interno significa que se ha roto algo por nuestro lado. Tres cosas que conviene hacer, en este orden:

  1. Reintenta con espera creciente y con llave de idempotencia. Muchos 500 son pasajeros.
  2. Si insiste, para. Un proceso que reintenta indefinidamente contra un servidor caído no ayuda a nadie, y menos a que se levante antes.
  3. Cuéntanoslo con la referencia. Un 500 trae en error.detalles.referencia una cadena corta —ocho caracteres— que es la que identifica ese fallo concreto en nuestro registro. Guárdala en tu propio log y pégala en el correo a info@cairos.es: con ella se encuentra en un minuto, y sin ella hay que ir adivinando por la hora.

Y lo que no hay que hacer: dar la operación por hecha o por no hecha. Un 500 no dice si el trabajo se hizo. Lo que lo dice es consultar el recurso, o haber mandado una llave de idempotencia y repetir la llamada.

Preguntas sobre errores y límites

Siete: no_autenticado (401), sin_permiso (403), no_encontrado (404), conflicto (409), datos_invalidos (422), demasiadas_peticiones (429) y error_interno (500). Es una lista cerrada y siempre llegan con el mismo formato.
No. El mensaje está pensado para que lo lea una persona y puede cambiar de redacción. Lo que no cambia es codigo: es lo que tiene que mirar tu programa.
120 por minuto y por clave, en ventana deslizante. Al pasarlo devuelve 429 demasiadas_peticiones con un Retry-After. Y no hace falta contarlas: RateLimit-Remaining viene en todas las respuestas, así que un cliente bien hecho se frena solo.
Con ?limite=200&orden=antiguo, y luego siguiendo el campo siguiente de cada respuesta, que ya es la URL entera de la página que sigue, con tus filtros dentro. Se para cuando siguiente viene a null. No hay números de página: con datos que se mueven, saltarse un elemento sería cuestión de tiempo.
Reintentar con espera creciente y con Idempotency-Key, y si insiste, parar y avisarnos. Guarda la referencia que viene dentro de error.detalles: es lo que encuentra ese fallo en nuestro registro. Y lo que no hay que hacer es suponer si la operación se hizo o no: un 500 no lo dice.

Un cliente que trata bien los errores no da guerra en un año

Mira el código y no el mensaje, reintenta sólo el 429 y el 500, y manda siempre la llave de idempotencia cuando escribas.

Siete códigos · Cursor, no páginas · Espera creciente

Soporte