Shopify: un webhook, un servei al mig i una factura
Shopify no pot cridar Cairos pel seu compte, i això és una bona notícia: la clau de la teva API no pot viure en una botiga pública. Aquí hi ha la peça que va al mig, sencera.
L'API està en marxa. Això és el que hi ha avui i el que no
Funciona. Les rutes d'aquesta documentació estan desplegades i responent a https://erp.cairos.es/api/v1/. Els exemples d'aquestes pàgines es poden copiar i executar. L'especificació completa és a openapi.json, que és el que importen Make i n8n.
Les claus te les crees tu, des de Desenvolupadors al teu compte de Cairos. Comença amb una cai_test_: treballa contra les teves dades de veritat però no registra a VeriFactu ni envia correus, així que pots muntar la teva integració sense embrutar una sèrie de facturació.
I el que encara NO hi ha, dit sense adorns: cap connector oficial. Ni app de Shopify, ni plugin de WooCommerce, ni mòdul publicat a Make o n8n. Amb l'API i la seva especificació es poden construir —per això hi ha les guies d'aquesta secció— però construir-los és feina, i aquesta feina no està feta.
Si muntes alguna cosa amb això, escriu-nos a hola@cairos.es. Ens interessa especialment el que et falti del contracte: és el que decideix què s'amplia primer.
Ja no cal programar això
Cairos connecta amb Shopify des de dins del programa: encens el mòdul de botigues en línia, enganxes les credencials que et dóna la teva botiga i cada comanda es converteix en factura amb la teva sèrie, el teu IVA i la teva numeració. No cal instal·lar cap app de la seva botiga d'aplicacions: la connexió es fa des de Cairos amb el testimoni d'una app privada de la teva pròpia botiga, que tu crees i tu revoques.
Aquesta guia es queda per a qui prefereixi muntar-s'ho a la seva manera, tingui un flux que no encaixi, o vulgui saber què fa el connector per dins abans de fiar-se'n.
Per què cal alguna cosa al mig
Shopify no pot cridar Cairos pel seu compte. No hi ha un lloc on enganxar una clau d'API aliena i dir-li «quan es pagui una comanda, envia aquest JSON»: el que Shopify sap fer és avisar per webhook una adreça teva.
Així que el muntatge són tres peces i no dues:
| Peça | Què fa |
|---|---|
| Shopify | Envia un webhook orders/paid a la teva adreça, signat amb el secret de la teva aplicació. |
| Un servei teu | Comprova la signatura de Shopify, tradueix la comanda i crida Cairos amb la teva clau. Cap en una funció sense servidor de vint línies. |
| Cairos | Crea el contacte, la factura, l'emet i registra el cobrament. |
La peça del mig és també on viu la teva clau de Cairos, i això és exactament el que es vol: una clau d'API mai no pot estar a la botiga, perquè la botiga és pública.
Si prefereixes no muntar res tu, aquesta peça del mig la poden fer Make o n8n: són just això, un lloc on rebre un webhook i llançar una crida HTTP.
El NIF, un altre cop
Igual que a WooCommerce i pel mateix motiu: el procés de compra de Shopify és internacional i no pregunta el NIF. Cal afegir-lo, amb un camp del procés de compra o amb un atribut del carretó, i arriba a la comanda com a propietat o com a atribut.
I una cosa més que s'oblida: les comandes de Shopify s'editen després de creades. Si el teu servei factura en rebre orders/paid i algú edita la comanda mitja hora més tard, la factura ja està emesa i no es pot tocar. El correcte llavors és una rectificativa, no intentar quadrar la factura amb la comanda.
El servei del mig
Dues comprovacions abans de tocar res, i totes dues són obligatòries: que l'avís ve de Shopify de debò, i que aquella comanda no està ja facturada.
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 });
}Fixa't que la clau d'idempotència surt de l'identificador de la comanda de Shopify. Aquesta és la peça que fa que un reintent de Shopify —que n'hi ha, i són normals— no acabi en una segona factura.
Dues signatures diferents, i convé no barrejar-les
En aquest muntatge hi ha dues comprovacions de signatura i no són la mateixa:
| Shopify → el teu servei | Cairos → el teu servei | |
|---|---|---|
| Capçalera | X-Shopify-Hmac-Sha256 | x-cairos-firma, en minúscula |
| Algorisme | HMAC-SHA256 del cos cru | HMAC-SHA256 de la marca de temps i el cos cru |
| Format | base64 | hexadecimal, amb t= i v1= |
| Secret | El de la teva aplicació de Shopify | El de la subscripció de webhook de Cairos |
Només necessites la segona si a més et subscrius als esdeveniments de Cairos, que és el raonable si vols retornar el número de factura a la comanda. És a webhooks.
L'única cosa que comparteixen és el consell: cos cru i comparació en temps constant. Tota la resta canvia.
La peça del mig també es pot muntar sense codi
Rebre un webhook i llançar una crida HTTP és exactament el que fan aquestes dues eines.
Amb Make
Un escenari amb webhook d'entrada i mòdul HTTP de sortida. Amb control d'errors i reintents.
Amb n8n
Node Webhook, node Code per comprovar la signatura i node HTTP Request. I l'allotges on vulguis.
O si la teva botiga és WooCommerce
Allà no cal peça al mig: la connexió va dins de WordPress, en PHP.
Preguntes sobre Shopify
orders/paid. Facturar a orders/create vol dir facturar comandes que potser no arribin a pagar-se.nif del contacte. Sense NIF només es pot emetre factura simplificada.Munta la peça del mig i prova-la avui
El codi està escrit contra els camps que el servidor accepta de debò. L'única cosa que li falta és la teva clau, i aquesta te la crees tu en un minut.
orders/paid · Signatura comprovada · Idempotency-Key per comanda