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.
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:
| Piece | What it does |
|---|---|
| Shopify | It sends an orders/paid webhook to your address, signed with your application's secret. |
| A service of your own | It checks Shopify's signature, translates the order and calls Cairos with your key. It fits in a twenty-line serverless function. |
| Cairos | It 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.
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 service | Cairos → your service | |
|---|---|---|
| Header | X-Shopify-Hmac-Sha256 | x-cairos-firma, in lower case |
| Algorithm | HMAC-SHA256 of the raw body | HMAC-SHA256 of the timestamp and the raw body |
| Format | base64 | hexadecimal, with t= and v1= |
| Secret | The one on your Shopify application | The 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.
The piece in the middle can also be built without code
Receiving a webhook and firing off an HTTP call is exactly what these two tools do.
With Make
A scenario with an incoming webhook and an outgoing HTTP module. With error handling and retries.
With n8n
A Webhook node, a Code node to check the signature and an HTTP Request node. And you host it wherever you like.
Or if your shop is WooCommerce
There you need no piece in the middle: the hook lives inside WordPress, in PHP.
Questions about Shopify
orders/paid. Invoicing on orders/create means invoicing orders that may never be paid.nif. Without a NIF you can only issue a simplified invoice.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