De cero a una factura emitida, en seis llamadas
Sin SDK, sin instalar nada y sin leerse la referencia entera. Seis llamadas que puedes tener escritas hoy y ejecutar el día que la API abra. Ojo: hoy las rutas devuelven todavía 404, y esta página lo dice en cada paso.
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 hola@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 es el único que necesita que alguien te conteste un correo.
Conseguir la clave
Se pide por correo mientras la API no está abierta. Llega una clave de pruebas y 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 · Consigue una clave
Escribe a hola@cairos.es diciendo qué quieres integrar. Cuando haya contra qué llamar, te llega una clave de pruebas, que empieza por cai_test_.
Hoy este paso todavía no se completa, y por eso está el primero: las rutas de los pasos siguientes devuelven 404 porque aún no están escritas. Lo que sí puedes hacer desde ya es dejar montado el código de los pasos 2 a 6, que está escrito contra el contrato definitivo.
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. Hoy lo que sale es un 404, porque la ruta todavía no está escrita; eso no significa que la ruta sea otra.
{
"id": "org_3Kd8vR",
"nombre": "Talleres Miralles SL",
"nif": "B12345674",
"regimen": "general",
"moneda": "EUR",
"series": ["F27"]
}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": "cliente"
}'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: "cliente",
}),
});
const contacto = await r.json();<?php
$datos = [
"nombre" => "Grupo Bonaire SL",
"nif" => "B17654321",
"email" => "admin@bonaire.example",
"tipo" => "cliente",
];
$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": "con_7Qb3xK",
"serie": "F27",
"fecha": "2027-03-02",
"lineas": [
{
"concepto": "Revision anual",
"cantidad": 2,
"precio": 180.00,
"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: "2027-03-02",
lineas: [
{ concepto: "Revision anual", cantidad: 2, precio: 180.0, iva: 21 },
],
}),
});
const factura = await r.json();<?php
$factura = [
"contacto_id" => $contacto["id"],
"serie" => "F27",
"fecha" => "2027-03-02",
"lineas" => [
[
"concepto" => "Revision anual",
"cantidad" => 2,
"precio" => 180.00,
"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": "fac_5Np2wD",
"numero": null,
"estado": "borrador",
"contacto_id": "con_7Qb3xK",
"serie": "F27",
"fecha": "2027-03-02",
"base": 360.00,
"cuota_iva": 75.60,
"total": 435.60
}numero viene 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.
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/fac_5Np2wD/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": "fac_5Np2wD",
"numero": "F27/0042",
"estado": "emitida",
"total": 435.60,
"verifactu": {
"estado": "remitida",
"huella": "7f3c1a…d904"
}
}Con una clave de pruebas, el bloque «verifactu» no viene
Es la diferencia que importa entre cai_test_ y cai_live_: la de pruebas no registra en VeriFactu ni manda correos. Todo lo demás —el número de la serie, los totales, el PDF— funciona igual.
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/fac_5Np2wD/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 e iva es el tipo en porcentaje. La respuesta trae base, cuota_iva y total ya calculados, que es lo que conviene comparar con lo que esperabas.Déjalo escrito hoy y ejecútalo el día que abra
Seis llamadas, quince minutos con calma. Dinos qué estás montando y te avisamos en cuanto haya contra qué llamar.
Clave de pruebas · Mismos datos, sin VeriFactu ni correos