Que Cairos te avise, en vez de preguntar cada minuto
Un POST firmado con HMAC-SHA256, con la marca de tiempo dentro de lo firmado, cada vez que pasa algo que te interesa. 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 info@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 sucesos
Los nombres son contrato: están escritos dentro de integraciones ajenas a las que no podemos avisar, así que no se renombran nunca. Si hace falta otra cosa, se añade un suceso nuevo, y añadir no rompe a nadie.
| Suceso | Cuándo se dispara | Para qué sirve |
|---|---|---|
invoice.created | Se ha creado una factura, normalmente 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 la referencia 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. |
invoice.cancelled | Una factura se ha anulado o ha ido a la papelera. | Deshacer en tu sistema lo que disparó su emisión. |
payment.recorded | Se ha registrado un cobro, entero o parcial. | Llega también en los cobros parciales, donde invoice.paid no llega. |
quote.accepted | El cliente ha aceptado un presupuesto. | Montar el «cuando acepten, crea la factura» sin preguntar cada hora. |
quote.rejected | Y el que faltaba: lo ha rechazado. | Parar el seguimiento. Sin él, el «no» era la única respuesta que nunca llegaba a la automatización. |
delivery.created | Se ha creado un albarán. | Avisar al almacén, o al transportista, sin esperar a que haya factura. |
subscription.invoiced | Una suscripción ha generado su factura del periodo. | Cobrar la cuota, dar de alta el acceso del mes, avisar al cliente. |
purchase_order.created | Se ha hecho un pedido a un proveedor. | Mandárselo, o meterlo en el tablero de lo que está por llegar. |
contact.created | Se ha dado de alta un cliente o un proveedor. | Para mantener sincronizada tu agenda con la de Cairos. |
contact.updated | Ha cambiado un contacto. | Lo mismo, con los cambios. |
contact.deleted | Se ha borrado un contacto. Llega con lo que tenía justo antes. | Es tu única oportunidad de guardarlo: después ya no se puede consultar. |
product.updated | Ha cambiado un producto del catálogo, o se acaba de crear. | Refrescar el precio en tu tienda sin volcar el catálogo entero cada noche. |
product.deleted | Se ha borrado un producto, con su último estado dentro. | Retirarlo del escaparate. |
stock.below_minimum | Las existencias de un artículo han cruzado su mínimo. | Avisar a compras. Salta al cruzar el umbral, no en cada venta posterior. |
expense.created | Se ha registrado un gasto. | Llevarlo a tu control de tesorería. |
expense.approved | Un gasto ha quedado aprobado. | Pagarlo, o mandarlo a la pasarela que lo paga. |
deal.created | Una oportunidad nueva en el embudo. | Avisar al comercial que la tiene que atender. |
deal.stage_changed | Una oportunidad se ha movido de etapa. | Sincronizar un tablero externo. Trae la etapa anterior y la nueva. |
deal.won | Se ha ganado una oportunidad. | El aviso al equipo. Va aparte del anterior para no tener que saber cómo llama cada empresa a su última columna. |
deal.lost | Se ha perdido, con su motivo. | Contar por qué se pierde, que es lo que sirve para vender mejor. |
member.created | Alta de un socio. | Darle acceso, mandarle la bienvenida. |
donation.received | Se ha registrado un donativo. | El agradecimiento del mismo día, que es el que se lee. |
store.order.imported | Un pedido de una tienda conectada se ha convertido en factura. | Va aparte de invoice.created para que quien sólo mira las ventas de la tienda no tenga que filtrar todas las facturas de la empresa. |
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.
Esta tabla es de hoy, y la lista crece. La viva se pide a la API —GET /api/v1/eventos— y viene con la explicación de cada suceso y un cuerpo de ejemplo. Es un endpoint y no un párrafo por un motivo práctico: n8n y Zapier lo leen para rellenar el desplegable de «¿qué suceso?» y para enseñar campos antes de que haya llegado ningún aviso de verdad.
Y hay uno más que no sale ahí porque no se suscribe uno a él: webhook.test, el que dispara el botón de probar. Llega igual, con {"prueba": true} dentro.
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"],
"descripcion": "Tienda · guardar la referencia en el pedido",
"modo_aviso": "firma"
}'{
"id": "clx3k2p9y0005qz8hu1v2w3x4",
"url": "https://tutienda.example/cairos/avisos",
"sucesos": ["invoice.issued", "invoice.paid"],
"modo_aviso": "firma",
"activo": true,
"fallos_seguidos": 0,
"secreto": "whsec_2f9c…a417",
"firma": {
"cabecera": "x-cairos-firma",
"formato": "t=<marca>,v1=<hmac hex>",
"se_firma": "<marca> + \".\" + <cuerpo crudo>"
},
"sucesos_disponibles": ["invoice.created", "invoice.issued", "…"]
}Fíjate en que la respuesta trae cómo comprobar la firma dentro: la cabecera en la que viaja, su formato y qué texto exacto se firma. Es a propósito, para que quien monta esto no tenga que volver a esta página.
Y sucesos: ["*"] vale para todos, si prefieres filtrar en tu lado.
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 —y por eso existe el PATCH: añadir un suceso ya no obliga a recrear, que era la forma más tonta de perder un secreto—.
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. El sobre es siempre el mismo —id, suceso, creado y datos— y lo que cambia de un suceso a otro es sólo lo de dentro de datos:
{
"id": "clx3k2p9y0006qz8hy5z6a7b8",
"suceso": "invoice.issued",
"creado": "2027-03-02T11:04:19.204Z",
"datos": {
"factura": {
"id": "clx3k2p9y0001qz8h5e6f7g8h",
"serie": "F27",
"numero": 42,
"referencia": "F27-0042",
"estado": "sent",
"total": 435.60,
"pendiente": 435.60
}
}
}datos lleva el recurso dentro de una clave con su nombre, no suelto: {"factura": …}, {"contacto": …}, {"producto": …}. Hay sucesos que traen dos —payment.recorded manda cobro y factura, porque lo que casi siempre se quiere saber es cuánto queda y no sólo cuánto entró—, y ésa es justo la razón de que la clave esté ahí.
Content-Type: application/json
x-cairos-suceso: invoice.issued
x-cairos-envio: clx3k2p9y0006qz8hy5z6a7b8
x-cairos-intento: 1
x-cairos-firma: t=1804243459,v1=8c1f4a…9d3bVan en minúscula, y no es un descuido: las cabeceras HTTP no distinguen mayúsculas de minúsculas, pero tu código sí si buscas la clave a mano en un diccionario. Si tu marco web te da las cabeceras tal cual llegaron, busca x-cairos-firma en minúscula.
x-cairos-envio es el identificador del envío y es el mismo en todos los reintentos —también viene dentro, como id—, así que es lo que hay que guardar para no procesar dos veces lo mismo. x-cairos-intento te dice por cuál vas.
El recurso que llega dentro es el mismo que devuelve la API, pero el webhook es un aviso: si necesitas algo que no viene —las líneas de la factura, por ejemplo, que en los listados tampoco vienen— consulta la ruta con ese identificador.
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.
Y si tu herramienta no sabe calcular un HMAC
Hay una salida, y va dicha con su precio delante. Al crear el webhook se pide modo_aviso: "token" y entonces llega también una cabecera x-cairos-token con el secreto en claro, que sólo hay que comparar con lo que guardaste. Un if de una línea, sin criptografía.
{
"url": "https://hook.example/cairos",
"sucesos": ["*"],
"modo_aviso": "token"
}Es más débil, y por dos motivos concretos: el secreto viaja entero en cada petición —así que cualquiera que vea un registro de tu servidor se lo lleva— y no caduca, así que un aviso capturado hoy sirve mañana. La firma no tiene ninguno de los dos problemas, porque lo que viaja es un cálculo con la hora dentro.
Existe porque la alternativa real no era «HMAC bien hecho»: era Make, Zapier y n8n sin nodo de código sin comprobar nada, que es peor. Y no sustituye a nada: la cabecera de firma sigue yendo igual, así que el día que puedas comprobarla cambias el modo con un PATCH y no tocas la URL ni pierdes el secreto.
Reintentos, duplicados y cómo no complicarse
Responde 200 rápido y trabaja después. Hay diez segundos de espera: si tardas más, 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: guarda el id del envío —el mismo que llega en x-cairos-envio—, que no cambia 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 seis veces con esperas que crecen: un minuto, cinco, media hora, dos horas, seis y un día. |
| Nada, o tarda más de diez segundos | Igual que un error: se reintenta. |
El último intento cae más de un día después del primero, y eso es deliberado: da tiempo a que alguien despliegue el arreglo un lunes por la mañana. Agotados los seis, el envío queda como abandoned.
Y tras quince fallos seguidos, el webhook se desactiva solo. A esas alturas no es un bache: es que esa URL ya no existe. Los envíos que quedaran esperando se abandonan, queda anotado en el registro de actividad de la empresa y hay que volver a activarlo a mano —no llega ningún correo—, así que si montas algo serio, vigila fallos_seguidos y activo en GET /webhooks/{id}.
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
GET /api/v1/eventos. Te suscribes a los que quieras, o a "*" para todos.id del envío —el mismo que llega en la cabecera x-cairos-envio—, 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