Shopify: un webhook, un servicio en medio y una factura
Shopify no puede llamar a Cairos por su cuenta, y eso es una buena noticia: la clave de tu API no puede vivir en una tienda pública. Aquí está la pieza que va en medio, entera.
La API está en marcha. Esto es lo que hay hoy y lo que no
Funciona. Las rutas de esta documentación están desplegadas y respondiendo en https://erp.cairos.es/api/v1/. Los ejemplos de estas páginas se pueden copiar y ejecutar. La especificación completa está en openapi.json, que es lo que importan Make y n8n.
Las claves te las creas tú, desde Desarrolladores en tu cuenta de Cairos. Empieza con una cai_test_: trabaja contra tus datos de verdad pero no registra en VeriFactu ni envía correos, así que puedes montar tu integración sin ensuciar una serie de facturación.
Y lo que todavía NO hay, dicho sin adornos: ningún conector oficial. Ni app de Shopify, ni plugin de WooCommerce, ni módulo publicado en Make o n8n. Con la API y su especificación se pueden construir —para eso están las guías de esta sección— pero construirlos es trabajo, y ese trabajo no está hecho.
Si montas algo con esto, escríbenos a hola@cairos.es. Nos interesa especialmente lo que te falte del contrato: es lo que decide qué se amplía primero.
No hay app de Cairos en Shopify
No está publicada en su tienda de aplicaciones y no hay fecha para que lo esté. Lo que hay es la API y esta página, que te dice exactamente qué enganchar y con qué código. Es menos cómodo que instalar algo y es lo que hay hoy; decirlo así te ahorra buscar un conector que no existe.
Por qué hace falta algo en medio
Shopify no puede llamar a Cairos por su cuenta. No hay un sitio donde pegar una clave de API ajena y decirle «cuando se pague un pedido, manda este JSON»: lo que Shopify sabe hacer es avisar por webhook a una dirección tuya.
Así que el montaje son tres piezas y no dos:
| Pieza | Qué hace |
|---|---|
| Shopify | Manda un webhook orders/paid a tu dirección, firmado con el secreto de tu aplicación. |
| Un servicio tuyo | Comprueba la firma de Shopify, traduce el pedido y llama a Cairos con tu clave. Cabe en una función sin servidor de veinte líneas. |
| Cairos | Crea el contacto, la factura, la emite y registra el cobro. |
La pieza del medio es también donde vive tu clave de Cairos, y eso es exactamente lo que se quiere: una clave de API nunca puede estar en la tienda, porque la tienda es pública.
Si prefieres no montar nada tú, esa pieza del medio la pueden hacer Make o n8n: son justo eso, un sitio donde recibir un webhook y lanzar una llamada HTTP.
El NIF, otra vez
Igual que en WooCommerce y por el mismo motivo: el proceso de compra de Shopify es internacional y no pregunta el NIF. Hay que añadirlo, con un campo del proceso de compra o con un atributo del carrito, y llega en el pedido como propiedad o como atributo.
Y una cosa más que se olvida: los pedidos de Shopify se editan después de creados. Si tu servicio factura al recibir orders/paid y alguien edita el pedido media hora más tarde, la factura ya está emitida y no se puede tocar. Lo correcto entonces es una rectificativa, no intentar cuadrar la factura con el pedido.
El servicio de en medio
Dos comprobaciones antes de tocar nada, y las dos son obligatorias: que el aviso viene de Shopify de verdad, y que ese pedido no está facturado ya.
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;
const contacto = await cairos("/contactos", {
nombre: pedido.billing_address.company || pedido.billing_address.name,
nif: (pedido.note_attributes || [])
.find((a) => a.name === "nif")?.value,
email: pedido.email,
tipo: "cliente",
}, llave + "-contacto");
const lineas = pedido.line_items.map((l) => ({
concepto: l.title,
cantidad: l.quantity,
precio: Number(l.price),
iva: 21,
}));
const factura = await cairos("/facturas", {
contacto_id: contacto.id,
serie: "WEB",
fecha: pedido.created_at.slice(0, 10),
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),
medio: "tarjeta",
}, llave + "-cobro");
// Responder rápido: Shopify reintenta si tardas.
return Response.json({ numero: emitida.numero });
}Fíjate en que la llave de idempotencia sale del identificador del pedido de Shopify. Ésa es la pieza que hace que un reintento de Shopify —que los hay, y son normales— no acabe en una segunda factura.
Dos firmas distintas, y conviene no mezclarlas
En este montaje hay dos comprobaciones de firma y no son la misma:
| Shopify → tu servicio | Cairos → tu servicio | |
|---|---|---|
| Cabecera | X-Shopify-Hmac-Sha256 | Cairos-Firma |
| Algoritmo | HMAC-SHA256 del cuerpo crudo | HMAC-SHA256 de la marca de tiempo y el cuerpo crudo |
| Formato | base64 | hexadecimal, con t= y v1= |
| Secreto | El de tu aplicación de Shopify | El de la suscripción de webhook de Cairos |
Sólo necesitas la segunda si además te suscribes a los sucesos de Cairos, que es lo razonable si quieres devolver el número de factura al pedido. Está en webhooks.
Lo único que comparten es el consejo: cuerpo crudo y comparación en tiempo constante. Todo lo demás cambia.
La pieza de en medio también se puede montar sin código
Recibir un webhook y lanzar una llamada HTTP es exactamente lo que hacen estas dos herramientas.
Con Make
Un escenario con webhook de entrada y módulo HTTP de salida. Con control de errores y reintentos.
Con n8n
Nodo Webhook, nodo Code para comprobar la firma y nodo HTTP Request. Y lo alojas donde quieras.
O si tu tienda es WooCommerce
Ahí no hace falta pieza en medio: el enganche va dentro de WordPress, en PHP.
Preguntas sobre Shopify
orders/paid. Facturar en orders/create significa facturar pedidos que quizá no lleguen a pagarse.nif del contacto. Sin NIF sólo se puede emitir factura simplificada.Monta la pieza de en medio y déjala esperando
El código está escrito contra el contrato definitivo. Cuando la API abra, sólo hay que poner la clave.
orders/paid · Firma comprobada · Idempotency-Key por pedido