Que Cairos te avise, en vez de preguntar cada minuto
Seis sucesos, un POST firmado con HMAC-SHA256 y la marca de tiempo dentro de lo firmado. Lo único que no es opcional de esta página es comprobar esa firma.
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.
Por qué escuchar en vez de preguntar
La alternativa a un webhook es consultar la API cada pocos minutos por si algo ha cambiado. Funciona, y es una mala idea por tres motivos: gastas casi todas las llamadas para enterarte de que no ha pasado nada, te enteras tarde, y eres tú quien tiene que recordar por dónde ibas.
Un webhook le da la vuelta: cuando pasa algo, te lo contamos. Tú pones una dirección pública, nosotros mandamos un POST con el suceso dentro, y tu servidor decide qué hacer.
Diagrama horizontal en tres bloques: «Se emite una factura en Cairos» → «Cairos firma con HMAC-SHA256 y envía POST» → «Tu servidor comprueba la firma y responde 200». Debajo, una flecha de reintento que vuelve al bloque central cuando la respuesta no es 2xx. Estilo de línea, colores del sistema, radio 0.
Los seis sucesos
| Suceso | Cuándo se dispara | Para qué sirve |
|---|---|---|
invoice.created | Se ha creado una factura, todavía en borrador. | Poco útil por sí solo; sirve para llevar un espejo de los borradores. |
invoice.issued | Una factura se ha emitido: ya tiene número y registro de VeriFactu. | El más usado. Es el momento de guardar el número en tu sistema y de mandarle el PDF al cliente. |
invoice.paid | Una factura ha quedado totalmente cobrada. | Cerrar el pedido, dar el acceso, liberar el envío. |
payment.recorded | Se ha registrado un cobro, entero o parcial. | Llega también en los cobros parciales, donde invoice.paid no llega. |
contact.created | Se ha dado de alta un contacto. | Para mantener sincronizada tu agenda con la de Cairos. |
product.updated | Ha cambiado un producto del catálogo: precio, nombre o impuesto. | Refrescar el precio en tu tienda sin consultar el catálogo entero cada noche. |
Si sólo vas a escuchar uno, que sea invoice.issued: es el único momento en el que una factura pasa a existir de verdad, con su número y su registro.
Darse de alta
Con el scope webhooks:manage. Le dices a qué dirección tuya y qué sucesos quieres:
curl -X POST https://erp.cairos.es/api/v1/webhooks \
-H "Authorization: Bearer $CAIROS_CLAVE" \
-H "Content-Type: application/json" \
-d '{
"url": "https://tutienda.example/cairos/avisos",
"sucesos": ["invoice.issued", "invoice.paid"]
}'{
"id": "whk_6Tz8jP",
"url": "https://tutienda.example/cairos/avisos",
"sucesos": ["invoice.issued", "invoice.paid"],
"secreto": "whsec_2f9c…a417"
}El secreto se enseña una sola vez
Igual que las claves de API: se devuelve al crear la suscripción y ya no se vuelve a mostrar. Guárdalo donde guardes la clave. Si lo pierdes, hay que borrar la suscripción y crear otra.
Tu dirección tiene que ser pública y hablar HTTPS. Mientras desarrollas, un túnel local del estilo de los que abren ngrok o cloudflared sirve perfectamente: lo que importa es que nosotros lleguemos.
Qué llega en cada envío
Un POST con este cuerpo y con la firma en las cabeceras:
{
"id": "evt_8Hs2kQ",
"suceso": "invoice.issued",
"creado": "2027-03-02T11:04:19Z",
"datos": {
"id": "fac_5Np2wD",
"numero": "F27/0042",
"estado": "emitida",
"total": 435.60
}
}Content-Type: application/json
Cairos-Suceso: invoice.issued
Cairos-Firma: t=1804243459,v1=8c1f4a…9d3bdatos trae una versión reducida del recurso, con lo que casi siempre hace falta. Si necesitas el documento entero, consulta la API con ese identificador: el webhook es un aviso, no un sustituto del recurso.
Esto es lo que hay que confirmar cuando la API abra
Lo firme del contrato es que la firma es HMAC-SHA256 y que la marca de tiempo va dentro de lo firmado. El nombre exacto de las cabeceras y el separador de la firma son la parte que puede afinarse en la implementación. Si al recibir tu primer suceso no coincide con esto, escríbenos a hola@cairos.es y corregimos esta página el mismo día: preferimos decirlo así a que lo descubras depurando.
Comprobar la firma, que es lo único que no es opcional
Tu dirección es pública: cualquiera que la adivine puede mandarle un POST diciendo que una factura de tres mil euros está pagada. Lo que separa un aviso nuestro de uno inventado es la firma.
Cómo se calcula. Se toma la marca de tiempo t, se junta con el cuerpo crudo separados por un punto, y se calcula el HMAC-SHA256 de esa cadena con el secreto de la suscripción. Eso es v1.
Dos detalles que parecen menores y no lo son:
- El cuerpo crudo, tal y como llegó. No el que sale de convertir el JSON a objeto y volver a serializarlo: eso reordena claves y cambia espacios, y la firma deja de cuadrar. En casi todos los marcos web hay que pedir explícitamente el cuerpo sin procesar.
- La marca de tiempo va dentro de lo firmado, y por eso sirve de algo: sin ella, alguien que capturase un envío válido podría reenviarlo mañana con su firma correcta. Rechaza lo que venga con más de cinco minutos y ese ataque desaparece.
<?php
// WordPress, Laravel o PHP a secas: lo importante es que
// $cuerpo sea el texto recibido, sin decodificar ni volver
// a codificar.
function firma_valida($cuerpo, $cabecera, $secreto) {
$partes = [];
foreach (explode(",", $cabecera) as $trozo) {
$par = explode("=", trim($trozo), 2);
if (count($par) === 2) {
$partes[$par[0]] = $par[1];
}
}
if (empty($partes["t"]) || empty($partes["v1"])) {
return false;
}
// Reenvíos: fuera lo que tenga más de cinco minutos.
if (abs(time() - (int) $partes["t"]) > 300) {
return false;
}
$esperada = hash_hmac(
"sha256",
$partes["t"] . "." . $cuerpo,
$secreto
);
// hash_equals y no ==: comparar en tiempo constante
// evita filtrar la firma byte a byte.
return hash_equals($esperada, $partes["v1"]);
}import crypto from "node:crypto";
export function firmaValida(cuerpo, cabecera, secreto) {
const partes = Object.fromEntries(
String(cabecera)
.split(",")
.map((t) => t.trim().split("="))
);
if (!partes.t || !partes.v1) return false;
// Reenvíos: fuera lo que tenga más de cinco minutos.
const ahora = Math.floor(Date.now() / 1000);
if (Math.abs(ahora - Number(partes.t)) > 300) return false;
const esperada = crypto
.createHmac("sha256", secreto)
.update(partes.t + "." + cuerpo)
.digest("hex");
const a = Buffer.from(esperada);
const b = Buffer.from(partes.v1);
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}# Para comprobar a mano lo que ha llegado, con el cuerpo
# guardado tal cual en cuerpo.json
T=1804243459
printf '%s.%s' "$T" "$(cat cuerpo.json)" \
| openssl dgst -sha256 -hmac "$CAIROS_SECRETO"Y compara en tiempo constante —hash_equals en PHP, timingSafeEqual en Node—. Un == normal tarda un poquito más cuantos más caracteres coincidan, y eso, repetido muchas veces, deja adivinar la firma. Cuesta lo mismo hacerlo bien.
Reintentos, duplicados y cómo no complicarse
Responde 200 rápido y trabaja después. Si tu respuesta tarda, el envío se da por fallido y se reintenta, aunque tú lo estuvieras procesando bien. Lo que hay que hacer es aceptar el aviso, meterlo en una cola y contestar; el trabajo de verdad va aparte.
Cuenta con recibir el mismo suceso dos veces. Si tu servidor tarda, o si responde con un error pasajero, el envío se repite. Eso no es un fallo: es cómo funciona cualquier entrega garantizada. La solución es la misma de siempre: usa id del suceso, que es único y estable entre reintentos. Si ya lo has procesado, ignóralo y responde 200.
Y cuenta con que lleguen desordenados. Es raro, pero un invoice.paid puede adelantar a un invoice.issued. Si el orden importa en tu proceso, mira creado en vez de fiarte del orden de llegada.
| Lo que devuelves | Qué hacemos |
|---|---|
2xx | Entregado. No se vuelve a mandar. |
4xx o 5xx | Fallido. Se reintenta con espera creciente durante horas. |
| Nada, o tarda demasiado | Igual que un error: se reintenta. |
Si una suscripción falla durante mucho tiempo se desactiva y te avisamos por correo. Una dirección rota que se reintenta eternamente no ayuda a nadie.
Los cuatro fallos que se repiten
- Firmar contra el JSON reserializado. Es el número uno con diferencia. El marco web te da el cuerpo ya convertido en objeto y firmas contra la vuelta, que no es idéntica. Hay que pedir el cuerpo crudo.
- No comprobar la firma «de momento». Un extremo público sin comprobar la firma es un extremo que cualquiera puede usar para marcar facturas como pagadas. No hay un «de momento» aceptable aquí.
- Hacer todo el trabajo antes de responder. Generar un PDF, mandar un correo y actualizar tres tablas antes del
200acaba en reintentos y en trabajo repetido. - No guardar el
iddel suceso. Sin eso no hay forma de distinguir un reintento de un suceso nuevo, y todo lo que hagas se hará dos veces.
Los cuatro son el mismo error de fondo: tratar el webhook como una llamada de función en vez de como lo que es, un mensaje que viaja por una red que a veces falla.
Qué suele escuchar cada integración
Tres montajes reales, con el suceso que de verdad les hace falta.
Una tienda
invoice.issued para guardar el número de factura en el pedido y mandarle el PDF al cliente.
Un aviso al equipo
invoice.paid hacia Make o Slack. Enterarse de un cobro el mismo día cambia cómo se persigue lo que falta.
Un espejo de los datos
contact.created y product.updated con n8n, para no volcar el catálogo entero cada noche.
Preguntas sobre webhooks
invoice.created, invoice.issued, invoice.paid, payment.recorded, contact.created y product.updated. Te suscribes a los que quieras.id del suceso, que no cambia entre reintentos, y si ya lo procesaste, respóndele 200 y no hagas nada más.Un extremo público sin firma comprobada no es un extremo: es una puerta
Comprueba la firma, responde rápido, guarda el identificador del suceso. Con esas tres cosas, los webhooks dejan de dar problemas.
HMAC-SHA256 · Cuerpo crudo · Cinco minutos de tolerancia