Shopify: un webhook, un servizo no medio e unha factura
Shopify non pode chamar a Cairos pola súa conta, e iso é unha boa noticia: a clave da túa API non pode vivir nunha tenda pública. Aquí está a peza que vai no medio, enteira.
A API está en marcha. Isto é o que hai hoxe e o que non
Funciona. As rutas desta documentación están despregadas e respondendo en https://erp.cairos.es/api/v1/. Os exemplos destas páxinas pódense copiar e executar. A especificación completa está en openapi.json, que é o que importan Make e n8n.
As claves créalas ti, desde Desenvolvedores na túa conta de Cairos. Comeza cunha cai_test_: traballa contra os teus datos de verdade pero non rexistra en VeriFactu nin envía correos, así que podes montar a túa integración sen enlixar unha serie de facturación.
E o que aínda NON hai, dito sen adornos: ningún conector oficial. Nin app de Shopify, nin plugin de WooCommerce, nin módulo publicado en Make ou n8n. Coa API e a súa especificación pódense construír —para iso están as guías desta sección— pero construílos é traballo, e ese traballo non está feito.
Se montas algo con isto, escríbenos a hola@cairos.es. Interésanos especialmente o que che falte do contrato: é o que decide que se amplía primeiro.
Xa non fai falta programar isto
Cairos conecta con Shopify desde dentro do programa: acendes o módulo de tendas en liña, pegas as credenciais que che dá a túa tenda e cada pedido convértese en factura coa túa serie, o teu IVE e a túa numeración. Non hai que instalar ningunha app da súa tenda de aplicacións: a conexión faise desde Cairos co token dunha app privada da túa propia tenda, que ti creas e ti revogas.
Esta guía queda para quen prefira montalo á súa maneira, teña un fluxo que non encaixe, ou queira saber que fai o conector por dentro antes de fiarse del.
Por que fai falta algo no medio
Shopify non pode chamar a Cairos pola súa conta. Non hai un sitio onde pegar unha clave de API allea e dicirlle «cando se pague un pedido, manda este JSON»: o que Shopify sabe facer é avisar por webhook a un enderezo teu.
Así que a montaxe son tres pezas e non dúas:
| Peza | Que fai |
|---|---|
| Shopify | Manda un webhook orders/paid ao teu enderezo, asinado co segredo da túa aplicación. |
| Un servizo teu | Comproba a sinatura de Shopify, traduce o pedido e chama a Cairos coa túa clave. Cabe nunha función sen servidor de vinte liñas. |
| Cairos | Crea o contacto, a factura, emítea e rexistra o cobro. |
A peza do medio é tamén onde vive a túa clave de Cairos, e iso é exactamente o que se quere: unha clave de API nunca pode estar na tenda, porque a tenda é pública.
Se prefires non montar nada ti, esa peza do medio pódena facer Make ou n8n: son xusto iso, un sitio onde recibir un webhook e lanzar unha chamada HTTP.
O NIF, outra vez
Igual ca en WooCommerce e polo mesmo motivo: o proceso de compra de Shopify é internacional e non pregunta o NIF. Hai que engadilo, cun campo do proceso de compra ou cun atributo do carriño, e chega no pedido como propiedade ou como atributo.
E unha cousa máis que se esquece: os pedidos de Shopify edítanse despois de creados. Se o teu servizo factura ao recibir orders/paid e alguén edita o pedido media hora máis tarde, a factura xa está emitida e non se pode tocar. O correcto entón é unha rectificativa, non intentar cadrar a factura co pedido.
O servizo do medio
Dúas comprobacións antes de tocar nada, e as dúas son obrigatorias: que o aviso vén de Shopify de verdade, e que ese pedido non está facturado xa.
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 });
}Fíxate en que a chave de idempotencia sae do identificador do pedido de Shopify. Esa é a peza que fai que un reintento de Shopify —que os hai, e son normais— non acabe nunha segunda factura.
Dúas sinaturas distintas, e convén non mesturalas
Nesta montaxe hai dúas comprobacións de sinatura e non son a mesma:
| Shopify → o teu servizo | Cairos → o teu servizo | |
|---|---|---|
| Cabeceira | X-Shopify-Hmac-Sha256 | x-cairos-firma, en minúscula |
| Algoritmo | HMAC-SHA256 do corpo cru | HMAC-SHA256 da marca de tempo e do corpo cru |
| Formato | base64 | hexadecimal, con t= e v1= |
| Segredo | O da túa aplicación de Shopify | O da subscrición de webhook de Cairos |
Só necesitas a segunda se ademais te subscribes aos sucesos de Cairos, que é o razoable se queres devolver o número de factura ao pedido. Está en webhooks.
O único que comparten é o consello: corpo cru e comparación en tempo constante. Todo o demais cambia.
A peza do medio tamén se pode montar sen código
Recibir un webhook e lanzar unha chamada HTTP é exactamente o que fan estas dúas ferramentas.
Con Make
Un escenario con webhook de entrada e módulo HTTP de saída. Con control de erros e reintentos.
Con n8n
Nodo Webhook, nodo Code para comprobar a sinatura e nodo HTTP Request. E alóxalo onde queiras.
Ou se a túa tenda é WooCommerce
Aí non fai falta peza no medio: o enganche vai dentro de WordPress, en PHP.
Preguntas sobre Shopify
orders/paid. Facturar en orders/create significa facturar pedidos que quizais non cheguen a pagarse.nif do contacto. Sen NIF só se pode emitir factura simplificada.Monta a peza do medio e próbaa hoxe
O código está escrito contra os campos que o servidor acepta de verdade. O único que lle falta é a túa clave, e esa créala ti nun minuto.
orders/paid · Sinatura comprobada · Idempotency-Key por pedido