De cero a una factura emitida, en seis llamadas
Sin SDK, sin instalar nada y sin leerse la referencia entera. Seis llamadas que puedes copiar, pegar y ejecutar ahora mismo: la API está desplegada y la clave te la creas tú desde tu cuenta. Empieza con una de pruebas y no ensuciarás ninguna serie de facturación.
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.
Lo que vas a hacer
Tres bloques. El primero se hace en tu cuenta, en un minuto; los otros dos son código.
Crear la clave
En Desarrolladores, dentro de tu cuenta. Empieza por una de pruebas, y apúntala: se enseña una sola vez.
Comprobar que llega
Una llamada a /organizacion, que no pide ningún permiso. Si responde, el resto es cuesta abajo.
Facturar
Contacto, factura en borrador, emisión y PDF. Cuatro llamadas más y ya está hecho.
1 · Crea la clave
En tu cuenta de Cairos, en Desarrolladores. No hay que pedirla por correo ni esperar a que nadie conteste: eliges el modo, marcas los permisos uno a uno y se genera al momento. Empieza con una de pruebas, que empieza por cai_test_.
Marca los permisos que vas a usar en esta página y ninguno más: contacts:write para el paso 3, e invoices:read e invoices:write para los pasos 4, 5 y 6. Si te falta uno, el error lo dice por su nombre y se arregla creando otra clave.
La clave se enseña una sola vez. No la guardamos: guardamos su huella, que sirve para reconocerla pero no para reconstruirla. Si la pierdes no te la podemos recuperar; hay que revocarla y crear otra. Guárdala donde guardes las contraseñas de tu servidor —una variable de entorno, un gestor de secretos— y no en el código.
Qué hace distinta a la clave de pruebas
Trabaja contra los mismos datos que la de producción: lo que crees con ella aparece en tu cuenta. Lo que no hace es registrar en VeriFactu ni enviar correos. Eso es exactamente lo que te permite desarrollar sin ensuciar una serie de facturación de verdad. No es una copia de tu empresa en un servidor aparte, y conviene tenerlo claro antes de lanzar un bucle de pruebas.
2 · La primera llamada, que confirma que todo está bien
GET /organizacion devuelve los datos fiscales de tu empresa. Es la llamada más barata que hay y no pide ningún scope: si tu clave vale, responde.
export CAIROS_CLAVE=cai_test_TU_CLAVE
curl https://erp.cairos.es/api/v1/organizacion \
-H "Authorization: Bearer $CAIROS_CLAVE"const CLAVE = process.env.CAIROS_CLAVE;
const BASE = "https://erp.cairos.es/api/v1";
const r = await fetch(BASE + "/organizacion", {
headers: { Authorization: "Bearer " + CLAVE },
});
const datos = await r.json();
console.log(r.status, datos);<?php
$clave = getenv("CAIROS_CLAVE");
$base = "https://erp.cairos.es/api/v1";
$ch = curl_init($base . "/organizacion");
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer " . $clave,
"Accept: application/json",
]);
$cuerpo = curl_exec($ch);
$estado = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
var_dump($estado, json_decode($cuerpo, true));Cuando salga un 200 con el nombre de tu empresa dentro, habrás terminado la parte difícil.
{
"id": "clx3k2p9y0000qz8h1a2b3c4d",
"nombre": "Talleres Miralles SL",
"razon_social": "Talleres Miralles, S.L.",
"nif": "B12345674",
"ciudad": "Girona",
"provincia": "Girona",
"moneda": "EUR",
"region_fiscal": "peninsula",
"tipo_iva_defecto": 21,
"recargo_equivalencia": false,
"plan_contable": "pgc"
}Los identificadores son así de feos a propósito: son cuid, opacos y sin prefijo. No los interpretes ni intentes deducir de qué recurso son —guárdalos tal cual y mándalos donde toque—, porque el día que dejen de tener esta forma tu código no debería enterarse.
Y si sale un 401, la clave no ha llegado bien. Los dos motivos son casi siempre el mismo: falta la palabra Bearer delante, o la variable de entorno está vacía y estás mandando la cabecera sin nada detrás.
3 · Crea un contacto
Una factura necesita a quién se la haces. Con contacts:write:
curl -X POST https://erp.cairos.es/api/v1/contactos \
-H "Authorization: Bearer $CAIROS_CLAVE" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: alta-cliente-1042" \
-d '{
"nombre": "Grupo Bonaire SL",
"nif": "B17654321",
"email": "admin@bonaire.example",
"tipo": "customer"
}'const r = await fetch(BASE + "/contactos", {
method: "POST",
headers: {
Authorization: "Bearer " + CLAVE,
"Content-Type": "application/json",
"Idempotency-Key": "alta-cliente-1042",
},
body: JSON.stringify({
nombre: "Grupo Bonaire SL",
nif: "B17654321",
email: "admin@bonaire.example",
tipo: "customer",
}),
});
const contacto = await r.json();<?php
$datos = [
"nombre" => "Grupo Bonaire SL",
"nif" => "B17654321",
"email" => "admin@bonaire.example",
"tipo" => "customer",
];
$ch = curl_init($base . "/contactos");
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($datos));
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer " . $clave,
"Content-Type: application/json",
"Idempotency-Key: alta-cliente-1042",
]);
$contacto = json_decode(curl_exec($ch), true);
curl_close($ch);Vuelve un 201 con el contacto creado y su id. Guárdatelo: es lo que va a pedir la factura.
Fíjate en la cabecera Idempotency-Key. Aquí todavía no duele —un contacto duplicado se borra— pero es el mismo hábito que dentro de dos pasos evita que emitas dos facturas del mismo pedido. Está contado entero en idempotencia.
4 · Crea la factura, que nace en borrador
Con invoices:write. La factura se crea sin número y en estado borrador:
curl -X POST https://erp.cairos.es/api/v1/facturas \
-H "Authorization: Bearer $CAIROS_CLAVE" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: pedido-1042" \
-d '{
"contacto_id": "clx3k2p9y0000qz8h1a2b3c4d",
"serie": "F27",
"fecha_emision": "2027-03-02",
"lineas": [
{
"descripcion": "Revision anual",
"cantidad": 2,
"precio": 180.00,
"tipo_iva": 21
}
]
}'const r = await fetch(BASE + "/facturas", {
method: "POST",
headers: {
Authorization: "Bearer " + CLAVE,
"Content-Type": "application/json",
"Idempotency-Key": "pedido-1042",
},
body: JSON.stringify({
contacto_id: contacto.id,
serie: "F27",
fecha_emision: "2027-03-02",
lineas: [
{
descripcion: "Revision anual",
cantidad: 2,
precio: 180.0,
tipo_iva: 21,
},
],
}),
});
const factura = await r.json();<?php
$factura = [
"contacto_id" => $contacto["id"],
"serie" => "F27",
"fecha_emision" => "2027-03-02",
"lineas" => [
[
"descripcion" => "Revision anual",
"cantidad" => 2,
"precio" => 180.00,
"tipo_iva" => 21,
],
],
];
$ch = curl_init($base . "/facturas");
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($factura));
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer " . $clave,
"Content-Type: application/json",
"Idempotency-Key: pedido-1042",
]);
$creada = json_decode(curl_exec($ch), true);
curl_close($ch);{
"id": "clx3k2p9y0001qz8h5e6f7g8h",
"serie": "F27",
"numero": null,
"referencia": null,
"tipo_documento": "invoice",
"estado": "draft",
"fecha_emision": "2027-03-02",
"fecha_vencimiento": null,
"base_imponible": 360.00,
"cuota_iva": 75.60,
"cuota_irpf": 0.00,
"total": 435.60,
"cobrado": 0.00,
"pendiente": 435.60
}numero y referencia vienen a null, y eso es correcto: el número no existe hasta que se emite. Los totales, en cambio, ya vienen calculados, así que aquí es donde se comprueba que lo que has mandado cuadra con lo que esperabas cobrar.
Fíjate en estado: vale draft, en inglés. Los recursos y los campos de esta API están en castellano, pero unos cuantos valores de enumerado se quedaron en inglés —draft, sent, paid, overdue— y ahí se quedan, porque traducirlos rompería toda integración escrita hasta hoy.
5 · Emítela, que es el paso que no se deshace
Ahora sí. Al emitir, la factura toma el siguiente número de su serie y se genera el registro de VeriFactu encadenado con el anterior.
curl -X POST \
https://erp.cairos.es/api/v1/facturas/clx3k2p9y0001qz8h5e6f7g8h/emitir \
-H "Authorization: Bearer $CAIROS_CLAVE" \
-H "Idempotency-Key: emitir-pedido-1042"const r = await fetch(BASE + "/facturas/" + factura.id + "/emitir", {
method: "POST",
headers: {
Authorization: "Bearer " + CLAVE,
"Idempotency-Key": "emitir-pedido-1042",
},
});
const emitida = await r.json();<?php
$url = $base . "/facturas/" . $creada["id"] . "/emitir";
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer " . $clave,
"Idempotency-Key: emitir-pedido-1042",
]);
$emitida = json_decode(curl_exec($ch), true);
curl_close($ch);{
"id": "clx3k2p9y0001qz8h5e6f7g8h",
"serie": "F27",
"numero": 42,
"referencia": "F27-0042",
"estado": "sent",
"total": 435.60,
"pendiente": 435.60,
"verifactu": {
"registrada": true,
"huella": "7f3c1a…d904",
"registrada_en": "2027-03-02T10:41:08.517Z",
"aeat": null
}
}Dos cosas de esa respuesta que conviene mirar bien. numero es un entero, no un texto: el «F27-0042» que se enseña impreso es referencia, que junta serie y número. Y el estado pasa a sent, que es el mismo en el que la deja el botón «Marcar enviada» de la pantalla.
Si vuelves a llamar a /emitir sobre una factura ya emitida, no sale un error: sale la misma factura con su mismo número, y un 200 en vez del 201. Es a propósito, y es lo que permite reintentar sin arriesgar un salto en la numeración cuando no sabes si la primera llamada llegó.
Con una clave de pruebas, «verifactu» viene con registrada: false
El bloque llega igual —no desaparece—, pero dice la verdad: una clave cai_test_ no registra en VeriFactu ni manda correos, así que huella y registrada_en vienen a null. Todo lo demás —el número de la serie, los totales, el PDF— funciona igual. Y ese campo es justo el que hay que mirar el día que pases a producción, para comprobar que ahora sí se registra.
6 · Y descárgate el PDF
GET /facturas/{id}/pdf no devuelve JSON: devuelve el fichero, con su código QR y su leyenda ya impresos.
curl https://erp.cairos.es/api/v1/facturas/clx3k2p9y0001qz8h5e6f7g8h/pdf \
-H "Authorization: Bearer $CAIROS_CLAVE" \
-o factura.pdfY ya está: seis llamadas, y al final una factura emitida, numerada y en PDF. El resto de la documentación es detalle sobre esto.
Lo que falla en los dos primeros minutos
Casi siempre es una de estas cinco cosas, y ninguna es interesante. Por eso vale la pena tenerlas juntas:
| Lo que ves | Lo que pasa de verdad |
|---|---|
401 no_autenticado | Falta la palabra Bearer delante de la clave, o la variable de entorno está vacía y mandas la cabecera con nada dentro. Comprueba lo segundo antes de dar por perdida la clave. |
403 sin_permiso | La clave es válida pero le falta el scope de esa llamada. Y ojo con la trampa: tener invoices:write no te deja leer facturas. Son dos permisos distintos. |
422 datos_invalidos | El JSON llegó, pero algo dentro no vale. error.detalles te dice qué campo y por qué; míralo antes de tocar nada. |
409 conflicto | Estás intentando algo que el estado del documento no permite: emitir una factura ya emitida, o reutilizar una Idempotency-Key con un cuerpo distinto. |
| El servidor no responde JSON | Suele ser el Content-Type. Si mandas cuerpo, tiene que ir application/json: sin él, el cuerpo llega y no se lee. |
Los siete códigos de error, con lo que hay que hacer con cada uno, están en errores y límites.
Y ahora, ¿por dónde sigo?
Depende de lo que estés montando, y sinceramente sólo hay tres caminos:
- Vas a crear facturas desde otro sistema. Lee idempotencia antes que nada. Es la página más aburrida de esta sección y la que evita el único error de esta API que no se arregla borrando.
- Necesitas enterarte de lo que pasa en Cairos. Entonces lo tuyo son los webhooks, no consultar la API cada cinco minutos.
- Vas a leer datos y poco más. Mira la paginación por cursor, porque una lista de facturas de un año no cabe en una respuesta.
Y si lo que tienes delante es una tienda o una herramienta de automatización, hay página propia: WooCommerce, Shopify, Make y n8n.
Dudas del primer día
DELETE que lo arregle. Está explicado en VeriFactu.precio es el precio unitario sin impuestos y tipo_iva es el tipo en porcentaje. La respuesta trae base_imponible, cuota_iva y total ya calculados, que es lo que conviene comparar con lo que esperabas. Si vienes de una tienda al público, donde el precio que existe es el de la etiqueta, manda precios_con_iva: true y Cairos desglosa la base hacia atrás en céntimos enteros, con el mismo motor que usa el conector de tiendas.Seis llamadas, quince minutos con calma
Crea la clave de pruebas, pega el primer curl y sigue hasta el PDF. Y si montas algo con esto, cuéntanoslo: lo que le falta a la gente del contrato es lo que decide qué se amplía primero.
Clave de pruebas · Mismos datos, sin VeriFactu ni correos