Cairos
Invoicing
Invoicing softwareQuotesRecurring invoicesExpenses and suppliersReceipts and cash flow
Accounting and tax
AccountingAEAT tax formsRecord booksFixed assetsIGIC and the Canary Islands
Operations
Inventory and warehousesCRMTime trackingProjectsGrants and funding
Compliance
VeriFactuTicketBAIElectronic invoicingAll the regulationsSecurity and data
By type of business
Self-employedSmall businessesAccountants and tax advisersForeigners in SpainStartupsRetail and shops
By sector
Hospitality and restaurantsConstruction and renovationProfessional servicesE-commerceAll sectors
By legal structure
AssociationsFoundationsCooperativesSports clubsAll legal structures
Switching software
ComparisonsAn alternative to HoldedMigrating your data
Free tools
Invoice templateVAT calculatorIRPF calculatorAll the tools
Learn
GuidesGlossaryTax calendarBlog
Developers
API and documentationGet started in five minutesResource referenceWebhooks
Help
Help centreContact
Pricing
Start for free Log in
First steps

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.

curl, JavaScript and PHPNo dependenciesCopy and paste

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.

The journey

What you are going to do

Three blocks. The first is done in your account, in a minute; the other two are code.

1

Create the key

In Developers, inside your account. Start with a test one, and write it down: it is shown only once.

2

Checking that it arrives

One call to /organizacion, which asks for no permission. If it answers, the rest is downhill.

3

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"

When a 200 comes back with your company's name inside it, you will have finished the hard part.

200 OK
{
  "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"
  }'

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
      }
    ]
  }'
201 Created
{
  "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"
201 Created
{
  "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
curl https://erp.cairos.es/api/v1/facturas/clx3k2p9y0001qz8h5e6f7g8h/pdf \
  -H "Authorization: Bearer $CAIROS_CLAVE" \
  -o factura.pdf

And 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 seeWhat really happens
401 no_autenticadoThe 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_permisoThe 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_invalidosThe 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 conflictoYou 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 JSONIt 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

No. The key is issued against an organisation, so you need an account — the free plan will do. You create the key yourself from Developers; there is nothing to request and nobody to wait for.
Because issuing is irreversible: it assigns the sequential number in the series and generates the VeriFactu record. Splitting it into two calls lets you check the totals before the part that cannot be undone happens. If you want both things one after the other, chain the two calls; what we do not do is take away your chance to look.
An issued invoice is not deleted: it is corrected, with a corrective invoice that leaves a record. That is what the invoicing regulations require, and that is why there is no DELETE that fixes it. It is explained in VeriFactu.
Not by default: 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.
Do not do it. Putting an API key in a page's JavaScript is publishing it: anyone who opens the browser tools can see it. The calls are made from your server, and the browser talks to your server, not to Cairos.

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

Support