Cairos
Facturación
Programa de facturaciónPresupuestosFacturas recurrentesGastos y proveedoresCobros y tesorería
Contabilidad e impuestos
ContabilidadModelos de HaciendaLibros registroInmovilizadoIGIC y Canarias
Operaciones
Inventario y almacenesCRMControl horarioProyectosSubvenciones y ayudas
Cumplimiento
VeriFactuTicketBAIFactura electrónicaToda la normativaSeguridad y datos
Por tipo de negocio
AutónomosPymesAsesorías y gestoríasExtranjeros en EspañaStartupsComercios y tiendas
Por sector
Hostelería y restaurantesConstrucción y reformasServicios profesionalesComercio electrónicoTodos los sectores
Por forma jurídica
AsociacionesFundacionesCooperativasClubes deportivosTodas las formas jurídicas
Cambiar de programa
ComparativasAlternativa a HoldedMigrar tus datos
Herramientas gratis
Plantilla de facturaCalculadora de IVACalculadora de IRPFTodas las herramientas
Aprender
GuíasGlosarioCalendario fiscalBlog
Desarrolladores
API y documentaciónEmpezar en cinco minutosReferencia de recursosWebhooks
Ayuda
Centro de ayudaContacto
Precios
Empieza gratis Iniciar sesión
Webhooks

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.

HMAC-SHA256Marca de tiempo firmadaReintentos con espera creciente

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.

developers-webhook.svg · 1200×520 px

Cairos firma el envío con el secreto de la suscripción. Tu servidor comprueba la firma antes de mirar el contenido.

Los seis sucesos

SucesoCuándo se disparaPara qué sirve
invoice.createdSe ha creado una factura, todavía en borrador.Poco útil por sí solo; sirve para llevar un espejo de los borradores.
invoice.issuedUna 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.paidUna factura ha quedado totalmente cobrada.Cerrar el pedido, dar el acceso, liberar el envío.
payment.recordedSe ha registrado un cobro, entero o parcial.Llega también en los cobros parciales, donde invoice.paid no llega.
contact.createdSe ha dado de alta un contacto.Para mantener sincronizada tu agenda con la de Cairos.
product.updatedHa 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
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"]
  }'
201 Created
{
  "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:

POST a tu dirección
{
  "id": "evt_8Hs2kQ",
  "suceso": "invoice.issued",
  "creado": "2027-03-02T11:04:19Z",
  "datos": {
    "id": "fac_5Np2wD",
    "numero": "F27/0042",
    "estado": "emitida",
    "total": 435.60
  }
}
Cabeceras
Content-Type: application/json
Cairos-Suceso: invoice.issued
Cairos-Firma: t=1804243459,v1=8c1f4a…9d3b

datos 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"]);
}

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 devuelvesQué hacemos
2xxEntregado. No se vuelve a mandar.
4xx o 5xxFallido. Se reintenta con espera creciente durante horas.
Nada, o tarda demasiadoIgual 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

  1. 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.
  2. 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í.
  3. Hacer todo el trabajo antes de responder. Generar un PDF, mandar un correo y actualizar tres tablas antes del 200 acaba en reintentos y en trabajo repetido.
  4. No guardar el id del 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.

Preguntas sobre webhooks

Seis: invoice.created, invoice.issued, invoice.paid, payment.recorded, contact.created y product.updated. Te suscribes a los que quieras.
Con la firma. Se calcula el HMAC-SHA256 de la marca de tiempo y el cuerpo crudo unidos por un punto, usando el secreto de la suscripción, y se compara en tiempo constante con la que llega en la cabecera. Hay ejemplo en PHP y en JavaScript en esta página.
Para que un envío válido capturado hoy no se pueda reenviar mañana. Sin fecha dentro de la firma, la firma seguiría siendo correcta siempre. Con ella, basta rechazar lo que tenga más de cinco minutos.
Sí, y hay que contar con ello: si tu servidor tarda o falla, el envío se reintenta. Guarda el id del suceso, que no cambia entre reintentos, y si ya lo procesaste, respóndele 200 y no hagas nada más.
Se reintenta con espera creciente durante horas. Si sigue fallando mucho tiempo, la suscripción se desactiva y se avisa por correo.
Sí. La dirección tiene que ser pública y hablar HTTPS. Mientras desarrollas, un túnel hacia tu equipo sirve igual.

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

Soporte