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
Integration

Shopify: one webhook, a service in the middle and an invoice

Shopify cannot call Cairos on its own, and that is good news: your API key cannot live in a public shop. Here is the piece that goes in the middle, in full.

A serverless function in NodeSignature checkedWithout invoicing twice

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.

You no longer need to program this

Cairos connects to Shopify from inside the program: you switch on the online shops module, paste in the credentials your shop gives you and each order becomes an invoice with your series, your VAT and your numbering. There is no app from their app store to install: the connection is made from Cairos with the token of a private app in your own shop, which you create and you revoke.

This guide stays here for anyone who would rather build it their own way, who has a flow that does not fit, or who wants to know what the connector does inside before trusting it.

Why something in the middle is needed

Shopify cannot call Cairos on its own. There is nowhere to paste another service's API key and tell it “when an order is paid, send this JSON”: what Shopify knows how to do is send a notification by webhook to an address of yours.

So the setup is three pieces and not two:

PieceWhat it does
ShopifyIt sends an orders/paid webhook to your address, signed with your application's secret.
A service of your ownIt checks Shopify's signature, translates the order and calls Cairos with your key. It fits in a twenty-line serverless function.
CairosIt creates the contact and the invoice, issues it and records the payment.

The piece in the middle is also where your Cairos key lives, and that is exactly what you want: an API key can never sit in the shop, because the shop is public.

If you would rather not build anything yourself, Make or n8n can be that piece in the middle: they are exactly that, somewhere to receive a webhook and fire off an HTTP call.

The NIF, again

The same as in WooCommerce and for the same reason: Shopify's checkout is international and does not ask for the NIF. It has to be added, with a checkout field or a cart attribute, and it arrives with the order as a property or as an attribute.

And one more thing people forget: Shopify orders get edited after they are created. If your service invoices on receiving orders/paid and somebody edits the order half an hour later, the invoice has already been issued and it cannot be touched. The right thing then is a corrective invoice, not trying to make the invoice match the order.

The service in the middle

Two checks before touching anything, and both are compulsory: that the notification really comes from Shopify, and that the order is not invoiced already.

A serverless function, in Node
import crypto from "node:crypto";

const BASE = "https://erp.cairos.es/api/v1";

// 1 · La firma de Shopify: HMAC-SHA256 en base64 del cuerpo
//     crudo, con el secreto de tu aplicación. Cuerpo CRUDO:
//     si lo reserializas, no cuadra.
function deShopify(crudo, firma, secreto) {
  const esperada = crypto
    .createHmac("sha256", secreto)
    .update(crudo, "utf8")
    .digest("base64");
  const a = Buffer.from(esperada);
  const b = Buffer.from(String(firma));
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

async function cairos(ruta, cuerpo, llave) {
  const cabeceras = {
    Authorization: "Bearer " + process.env.CAIROS_CLAVE,
    "Content-Type": "application/json",
  };
  if (llave) cabeceras["Idempotency-Key"] = llave;

  const r = await fetch(BASE + ruta, {
    method: "POST",
    headers: cabeceras,
    body: cuerpo ? JSON.stringify(cuerpo) : undefined,
  });

  const datos = await r.json();
  if (!r.ok) {
    throw new Error("Cairos " + r.status + ": " + datos.error.codigo);
  }
  return datos;
}

export async function handler(peticion) {
  const crudo = await peticion.text();

  if (!deShopify(crudo, peticion.headers.get("x-shopify-hmac-sha256"),
                 process.env.SHOPIFY_SECRETO)) {
    return new Response("Firma no valida", { status: 401 });
  }

  const pedido = JSON.parse(crudo);
  const llave = "shopify-" + pedido.id;

  // "tipo" es un enumerado en ingles: customer o supplier.
  const contacto = await cairos("/contactos/upsert", {
    nombre: pedido.billing_address.company || pedido.billing_address.name,
    nif: (pedido.note_attributes || [])
      .find((a) => a.name === "nif")?.value,
    email: pedido.email,
    tipo: "customer",
    buscar_por: "email",
  }, llave + "-contacto");

  // El texto de la linea es "descripcion" y el impuesto
  // "tipo_iva". "concepto" e "iva" no existen: 422.
  const lineas = pedido.line_items.map((l) => ({
    descripcion: l.title,
    cantidad: l.quantity,
    precio: Number(l.price),
    tipo_iva: 21,
  }));

  const factura = await cairos("/facturas", {
    contacto_id: contacto.id,
    serie: "WEB",
    fecha_emision: pedido.created_at.slice(0, 10),
    // El precio de Shopify es el del escaparate, con el IVA
    // dentro: que lo desglose el servidor y no un nodo tuyo.
    precios_con_iva: true,
    lineas,
  }, llave);

  const emitida = await cairos(
    "/facturas/" + factura.id + "/emitir",
    null,
    llave + "-emitir"
  );

  await cairos("/cobros", {
    factura_id: factura.id,
    fecha: pedido.created_at.slice(0, 10),
    importe: Number(pedido.total_price),
    metodo: "card",
    notas: "Shopify · pedido " + pedido.name,
  }, llave + "-cobro");

  // Responder rápido: Shopify reintenta si tardas.
  // "referencia" es lo que se imprime; "numero" es un entero.
  return Response.json({ referencia: emitida.referencia });
}

Note that the idempotency key comes from the Shopify order identifier. That is the piece that stops a retry from Shopify — and there are retries, and they are normal — ending in a second invoice.

Two different signatures, and it is best not to mix them up

In this setup there are two signature checks and they are not the same:

Shopify → your serviceCairos → your service
HeaderX-Shopify-Hmac-Sha256x-cairos-firma, in lower case
AlgorithmHMAC-SHA256 of the raw bodyHMAC-SHA256 of the timestamp and the raw body
Formatbase64hexadecimal, with t= and v1=
SecretThe one on your Shopify applicationThe one on the Cairos webhook subscription

You only need the second one if you also subscribe to Cairos events, which is the sensible thing if you want to send the invoice number back to the order. It is in webhooks.

The only thing they share is the advice: the raw body and a constant-time comparison. Everything else is different.

Questions about Shopify

No. What there is is the API and this guide. You need a small service in the middle, which can be a serverless function of your own or a Make or n8n scenario.
Because Shopify has nowhere to keep another service's API key and no way to build the call. What it does know how to do is send a webhook notification to an address of yours, and that is where the key goes.
orders/paid. Invoicing on orders/create means invoicing orders that may never be paid.
With a checkout field or a cart attribute. It arrives with the order and from there it travels to the contact's nif. Without a NIF you can only issue a simplified invoice.
The invoice has already been issued and it is not touched: it needs a corrective invoice, which today is issued from Cairos by hand. An editable order and an invoice series are two things with different rules, and the invoice's rules are not ours to set.

Build the piece in the middle and test it today

The code is written against the fields the server really accepts. The only thing it lacks is your key, and you create that yourself in a minute.

orders/paid · Signature checked · An Idempotency-Key per order

Support