From zero to an issued invoice, in six calls
No SDK, nothing to install and no need to read the whole reference. Six calls you can copy, paste and run right now: the API is deployed and you create the key yourself from your account. Start with a test one and you will not dirty any invoice series.
The API is up and running. This is what there is today and what there is not
It works. The routes in this documentation are deployed and responding at https://erp.cairos.es/api/v1/. The examples on these pages can be copied and run. The full specification is at openapi.json, which is what Make and n8n import.
You create the keys yourself, from Developers in your Cairos account. Start with a cai_test_ key: it works against your real data but does not record to VeriFactu and does not send emails, so you can build your integration without dirtying an invoice series.
And what there still is NOT, said plainly: no official connector. No Shopify app, no WooCommerce plugin, no published Make or n8n module. They can be built with the API and its specification — that is what the guides in this section are for — but building them is work, and that work is not done.
If you build something with this, write to us at hola@cairos.es. We are particularly interested in whatever you find missing from the contract: that is what decides which parts get extended first.
What you are going to do
Three blocks. The first is done in your account, in a minute; the other two are code.
Create the key
In Developers, inside your account. Start with a test one, and write it down: it is shown only once.
Checking that it arrives
One call to /organizacion, which asks for no permission. If it answers, the rest is downhill.
Invoicing
Contact, draft invoice, issue and PDF. Four more calls and it is done.
1 · Create the key
In your Cairos account, in Developers. There is no need to ask for it by email or to wait for anyone to reply: you choose the mode, tick the permissions one by one and it is generated on the spot. Start with a test one, which begins with cai_test_.
Tick the permissions you are going to use on this page and no others: contacts:write for step 3, and invoices:read and invoices:write for steps 4, 5 and 6. If one is missing, the error names it and it is fixed by creating another key.
The key is shown only once. We do not keep it: we keep its hash, which serves to recognise it but not to reconstruct it. If you lose it we cannot recover it for you; it has to be revoked and another one created. Keep it wherever you keep your server's passwords — an environment variable, a secrets manager — and not in the code.
What makes the test key different
It works against the same data as the production one: whatever you create with it appears in your account. What it does not do is record to VeriFactu or send emails. That is exactly what lets you develop without dirtying a real invoice series. It is not a copy of your company on a separate server, and it is worth being clear about that before you launch a loop of tests.
2 · The first call, which confirms everything is in order
GET /organizacion returns your company's tax details. It is the cheapest call there is and it asks for no scope: if your key is valid, it answers.
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));When a 200 comes back with your company's name inside it, you will have finished the hard part.
{
"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"
}The identifiers are this ugly on purpose: they are cuids, opaque and without a prefix. Do not interpret them or try to work out which resource they belong to — store them exactly as they are and send them where they belong — because the day they stop having this shape your code should not notice.
And if a 401 comes back, the key has not arrived properly. The two reasons are almost always the same one: the word Bearer is missing in front, or the environment variable is empty and you are sending the header with nothing after it.
3 · Create a contact
An invoice needs somebody to make it out to. With 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);A 201 comes back with the contact created and its id. Keep it: it is what the invoice is going to ask for.
Note the Idempotency-Key header. Here it does not hurt yet — a duplicated contact can be deleted — but it is the same habit that two steps from now stops you issuing two invoices for the same order. The whole story is in idempotency.
4 · Create the invoice, which is born as a draft
With invoices:write. The invoice is created without a number and in the borrador state:
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 and referencia come back as null, and that is correct: the number does not exist until it is issued. The totals, on the other hand, come already calculated, so this is where you check that what you sent adds up to what you expected to charge.
Look at estado: its value is draft, in English. This API's resources and fields are in Spanish, but a handful of enumerated values stayed in English — draft, sent, paid, overdue — and there they stay, because translating them would break every integration written to date.
5 · Issue it, the step that cannot be undone
Now it does. On issue, the invoice takes the next number in its series and the VeriFactu record is generated, chained to the previous one.
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
}
}Two things in that response worth a good look. numero is an integer, not a string: the «F27-0042» that is shown printed is referencia, which joins series and number. And the state moves to sent, the same one the «Marcar enviada» button on screen leaves it in.
If you call /emitir again on an invoice that is already issued, you do not get an error: you get the same invoice with the same number, and a 200 instead of the 201. It is deliberate, and it is what lets you retry without risking a jump in the numbering when you do not know whether the first call arrived.
With a test key, «verifactu» comes back with registrada: false
The block still arrives — it does not disappear — but it tells the truth: a cai_test_ key does not record to VeriFactu and does not send emails, so huella and registrada_en come back as null. Everything else — the number in the series, the totals, the PDF — works the same. And that field is exactly the one to look at the day you move to production, to check that it really is recording now.
6 · And download the PDF
GET /facturas/{id}/pdf does not return JSON: it returns the file, with its QR code and its legend already printed.
curl https://erp.cairos.es/api/v1/facturas/clx3k2p9y0001qz8h5e6f7g8h/pdf \
-H "Authorization: Bearer $CAIROS_CLAVE" \
-o factura.pdfAnd that is it: six calls, and an invoice issued, numbered and in PDF at the end of them. The rest of the documentation is detail on top of this.
What goes wrong in the first two minutes
It is almost always one of these five things, and none of them is interesting. That is why it is worth having them in one place:
| What you see | What really happens |
|---|---|
401 no_autenticado | The word Bearer is missing in front of the key, or the environment variable is empty and you are sending the header with nothing in it. Check the second before giving the key up for lost. |
403 sin_permiso | The key is valid but it lacks the scope for that call. And watch out for the trap: having invoices:write does not let you read invoices. They are two different permissions. |
422 datos_invalidos | The JSON arrived, but something inside it is not valid. error.detalles tells you which field and why; look at it before you touch anything. |
409 conflicto | You are trying something the document's state does not allow: issuing an invoice that is already issued, or reusing an Idempotency-Key with a different body. |
| The server does not answer with JSON | It is usually the Content-Type. If you send a body, it has to be application/json: without it, the body arrives and is not read. |
All seven error codes, with what to do about each one, are in errors and limits.
And now, where do I go next?
It depends on what you are building, and honestly there are only three routes:
- You are going to create invoices from another system. Read idempotency before anything else. It is the most boring page in this section and the one that prevents the only mistake in this API that cannot be fixed by deleting.
- You need to find out what is happening in Cairos. Then what you want is webhooks, not asking the API every five minutes.
- You are going to read data and little else. Look at cursor pagination, because a year's worth of invoices does not fit in one response.
And if what you have in front of you is a shop or an automation tool, there is a page of its own: WooCommerce, Shopify, Make and n8n.
First-day questions
DELETE that fixes it. It is explained in VeriFactu.precio is the unit price excluding tax and tipo_iva is the rate as a percentage. The response brings base_imponible, cuota_iva and total already calculated, which is what you want to compare with what you expected. If you come from a shop selling to the public, where the price that exists is the one on the label, send precios_con_iva: true and Cairos works the taxable base back out in whole cents, with the same engine the shop connector uses.Six calls, fifteen unhurried minutes
Create the test key, paste the first curl and carry on to the PDF. And if you build something with this, tell us: what the people working against the contract find missing is what decides what gets extended first.
Test key · The same data, without VeriFactu and without emails