Factuarea APIDevelopers

Webhooks

Notificaciones POST firmadas con HMAC SHA256. Verificación, reintentos exponenciales y rotación de secret.

Los webhooks notifican a tu servidor cuando ocurre un evento en Factuarea (factura pagada, presupuesto aceptado, cliente creado, etc.) sin que tengas que hacer polling. Cada evento se entrega a tu URL mediante un POST HTTPS firmado.

Esta guía cubre los endpoints de un NIF, bajo /v1/companies/{company}/webhook-endpoints. Para recibir los eventos de toda una cuenta en un solo endpoint, consulta Webhooks de cuenta.

No se entregan en modo de prueba. Los eventos generados con una clave fact_test_ (sandbox) se registran pero nunca se entregan a tus endpoints externos, test-event incluido. Una clave de pruebas no puede llamar a los endpoints de webhook de un NIF, ping incluido, porque no puede llevar webhooks:write. POST /v1/companies/{company}/webhook-endpoints/{id}/ping, llamado con una clave live, solo comprueba que tu URL responde: no lleva firma. Consulta Modo de prueba y sandbox. Para ejercitar tu verificación de firma y tu handler real end-to-end, envía en su lugar un test-event con una clave live.

Crear un endpoint

curl -X POST https://api.factuarea.com/v1/companies/{company}/webhook-endpoints \
  -H "Authorization: Bearer $FACTUAREA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://app.mycompany.com/factuarea/webhook",
    "description": "Sync with internal CRM",
    "enabled_events": [
      "invoice.created",
      "invoice.paid",
      "quote.approved"
    ]
  }'

Respuesta, abreviada (el secret se devuelve solo una vez):

{
  "data": {
    "id": "01931b3e-7c4a-7f2e-9a8b-3c5d6e7f8a0b",
    "object": "webhook_endpoint",
    "url": "https://app.mycompany.com/factuarea/webhook",
    "description": "Sync with internal CRM",
    "enabled_events": ["invoice.created", "invoice.paid", "quote.approved"],
    "status": "active",
    "api_version": "2026-09-23",
    "created_at": "2026-09-28T10:23:18Z",
    "secret": "whsec_01HKQS5N8VR7QXJ9K3T6BWPMZA9876543210ABCDEF"
  }
}

enabled_events enumera nombres de evento del catálogo, uno por uno, como mínimo uno. Un comodín como * o invoice.* no es un nombre válido y responde 422, así que nombra cada evento que quieras. El catálogo completo está en Eventos.

Firma HMAC SHA256

Cada entrega incluye estas headers:

Factuarea-Signature: t=1747314060,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
Factuarea-Event-Id: 01931b3e-7c4a-7f2e-9a8b-3c5d6e7f8a0d
Factuarea-Event-Type: invoice.paid
Factuarea-Delivery-Id: 01931b3e-7c4a-7f2e-9a8b-3c5d6e7f8a0c
Idempotency-Key: 01931b3e-7c4a-7f2e-9a8b-3c5d6e7f8a0d
  • t — timestamp UNIX de la entrega (segundos).
  • v1 — HMAC SHA256 de la cadena {t}.{body} con el secret del endpoint, en hex.
  • Idempotency-Key — el header estándar de la industria, con el mismo valor que Factuarea-Event-Id (el UUID v7 estable del evento). Úsalo para deduplicar reentregas con el header que tu stack quizá ya entiende. Consulta Idempotencia por tu parte.

¿Usas un SDK oficial? Sáltate el HMAC manual de abajo — tanto el SDK de TypeScript como el de PHP incluyen un verificador de webhooks que hace por ti la comparación en tiempo constante, la tolerancia del timestamp y la gestión del periodo de gracia de rotación. Consulta Verificar webhooks con el SDK. La receta manual de abajo es para cualquier otro lenguaje.

Para validar:

  1. Extrae t y v1 de Factuarea-Signature.
  2. Calcula signed_payload = t + "." + raw_body (bytes crudos del body, sin reformatear el JSON).
  3. Calcula expected = hmac_sha256(secret, signed_payload) en hex.
  4. Compara v1 == expected usando comparación en tiempo constante.
  5. Comprueba que |now - t| <= 300 (tolerancia de ±5 minutos contra ataques de replay).

Durante el periodo de gracia de una rotación de secret la header lleva dos valores v1 — uno por cada secret activo (t=...,v1=<current>,v1=<previous>). Acepta la petición si coincide cualquiera de los v1. Consulta Rotación de secret.

function verifyFactuareaSignature(
    string $payload,
    string $signatureHeader,
    string $secret,
    int $toleranceSeconds = 300,
): bool {
    $timestamp = null;
    $signatures = [];
    foreach (explode(',', $signatureHeader) as $kv) {
        [$k, $v] = explode('=', $kv, 2);
        if ($k === 't') {
            $timestamp = (int) $v;
        } elseif ($k === 'v1') {
            $signatures[] = $v;
        }
    }
    if ($timestamp === null || $signatures === []) {
        return false;
    }
    if (abs(time() - $timestamp) > $toleranceSeconds) {
        return false;
    }
    $expected = hash_hmac('sha256', $timestamp.'.'.$payload, $secret);

    foreach ($signatures as $candidate) {
        if (hash_equals($expected, $candidate)) {
            return true;
        }
    }

    return false;
}

// In the webhook handler:
$payload = file_get_contents('php://input');
$header = $_SERVER['HTTP_FACTUAREA_SIGNATURE'] ?? '';
$secret = getenv('FACTUAREA_WEBHOOK_SECRET');

if (! verifyFactuareaSignature($payload, $header, $secret)) {
    http_response_code(401);
    exit;
}

$event = json_decode($payload, true);
handleEvent($event);

http_response_code(200);
const crypto = require('crypto');

function verifySignature(payload, header, secret, tolerance = 300) {
  let timestamp = null;
  const signatures = [];
  for (const kv of header.split(',')) {
    const [k, v] = kv.split('=');
    if (k === 't') timestamp = Number(v);
    else if (k === 'v1') signatures.push(v);
  }
  if (timestamp === null || signatures.length === 0) return false;
  if (Math.abs(Date.now() / 1000 - timestamp) > tolerance) return false;

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${timestamp}.${payload}`)
    .digest('hex');

  return signatures.some((candidate) =>
    crypto.timingSafeEqual(
      Buffer.from(expected, 'hex'),
      Buffer.from(candidate, 'hex')
    )
  );
}

// Express:
app.post('/factuarea/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const payload = req.body.toString('utf8');
  if (!verifySignature(payload, req.header('Factuarea-Signature'), process.env.WHSEC)) {
    return res.status(401).end();
  }
  const event = JSON.parse(payload);
  handleEvent(event);
  res.status(200).end();
});
import hmac, hashlib, time
from flask import request, abort

def verify(payload: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    timestamp = None
    signatures = []
    for kv in header.split(','):
        k, v = kv.split('=', 1)
        if k == 't':
            timestamp = int(v)
        elif k == 'v1':
            signatures.append(v)
    if timestamp is None or not signatures:
        return False
    if abs(time.time() - timestamp) > tolerance:
        return False
    signed = f"{timestamp}.".encode() + payload
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(expected, candidate) for candidate in signatures)

@app.post('/factuarea/webhook')
def webhook():
    if not verify(request.get_data(), request.headers.get('Factuarea-Signature', ''), WHSEC):
        abort(401)
    event = request.get_json()
    handle_event(event)
    return '', 200

Verificar con el SDK oficial

Los SDK de TypeScript y PHP envuelven los cinco pasos de arriba — comparación en tiempo constante, tolerancia de ±5 minutos y periodo de gracia de rotación — en una sola llamada. Pasa el body crudo de la petición, la header Factuarea-Signature y el secret del endpoint:

import { Factuarea, WebhookSignatureError, SIGNATURE_HEADER } from "@factuarea/sdk";

const factuarea = new Factuarea({ apiKey: process.env.FACTUAREA_API_KEY! });

// Express, with express.raw({ type: "application/json" }) on the route:
app.post("/webhooks/factuarea", (req, res) => {
  try {
    const event = factuarea.webhooks.verify(
      req.body.toString("utf8"),
      req.headers[SIGNATURE_HEADER.toLowerCase()] as string,
      process.env.FACTUAREA_WEBHOOK_SECRET!,
    );
    if (event.type === "invoice.paid") { /* … */ }
    res.sendStatus(200);
  } catch (e) {
    if (e instanceof WebhookSignatureError) return res.sendStatus(400);
    throw e;
  }
});

Una tolerancia personalizada (en segundos) es el cuarto argumento opcional: factuarea.webhooks.verify(body, header, secret, { toleranceSeconds: 600 }).

use Factuarea\Sdk\Custom\Webhooks\WebhookVerifier;
use Factuarea\Sdk\Custom\Webhooks\WebhookSignatureException;

$verifier = new WebhookVerifier();
$rawBody = file_get_contents('php://input');
$signature = $_SERVER['HTTP_FACTUAREA_SIGNATURE'] ?? '';

try {
    $event = $verifier->verify($rawBody, $signature, getenv('FACTUAREA_WEBHOOK_SECRET'));
    // $event is the decoded, authenticated payload
    if (($event['type'] ?? null) === 'invoice.paid') { /* … */ }
    http_response_code(200);
} catch (WebhookSignatureException $e) {
    http_response_code(400);
}

Ambos verificadores aceptan las dos firmas v1 durante el periodo de gracia de una rotación de secret (consulta Rotación de secret), de modo que una rotación nunca descarta una entrega.

Reintentos

Si tu endpoint responde con un 5xx, un 408, 425 o 429, o no responde en 10 segundos, Factuarea reintenta con back-off exponencial. Cualquier otro 4xx, y una URL que Factuarea se niega a llamar por motivos de seguridad, terminan la entrega como failed_permanently sin reintento.

IntentoEspera tras el anterior
1inmediato
21 minuto
35 minutos
430 minutos
52 horas
612 horas
71 día
83 días

Cada espera varía hasta un ±10 %. Tras el intento final la entrega pasa a failed_permanently y deja de reintentarse. Las entregas permanecen visibles en GET /v1/companies/{company}/webhook-endpoints/{id}/deliveries durante 90 días desde que se crearon, y los eventos que hay detrás se conservan 30 días. Puedes reintentar manualmente una entrega failed o failed_permanently mediante POST /v1/companies/{company}/webhook-endpoints/{id}/deliveries/{delivery}/replay o desde el dashboard, mientras su evento siga almacenado: pasados 30 días el replay responde 422 business_rule_violation con el subcode replay_event_expired. Una entrega en cualquier otro estado responde el mismo 422 con el subcode replay_delivery_not_retryable.

Body de la entrega

El body de cada entrega es el sobre firmado del evento:

{
  "id": "01931b3e-7c4a-7f2e-9a8b-3c5d6e7f8a0d",
  "type": "invoice.paid",
  "api_version": "2026-09-23",
  "created": 1774002600,
  "livemode": true,
  "test": false,
  "correlation_id": "01931b3e-7c4a-7f2e-9a8b-3c5d6e7f8a99",
  "data": {
    "type": "invoice.paid",
    "object": {
      "id": "01931b3e-7c4a-7f2e-9a8b-3c5d6e7f8a42",
      "object": "invoice",
      "number": "FAC-2026-00042",
      "status": "paid",
      "total": "1210.00",
      "currency": "EUR"
    },
    "amount": "1210.00",
    "payment_date": "2026-03-20",
    "payment_method": "bank_transfer",
    "payment_reference": "TRF-2026-0042"
  }
}

La factura de data.object se abrevia aquí a seis de sus campos. Estos son los campos del sobre:

  • id — el identificador del evento (UUID v7). Es también la header Factuarea-Event-Id.
  • type — el nombre del evento, como en el catálogo.
  • api_version — la versión de payload por fecha en la que está construido data: la que tiene fijada tu endpoint, o la actual. Siempre está presente (null solo para eventos antiguos emitidos antes de sellar las versiones).
  • created — cuándo se creó el evento, como timestamp Unix en segundos.
  • livemode y test — ver abajo.
  • correlation_id — un UUID v7 que enlaza los eventos provocados por una misma operación, o null cuando no hay ninguna. La clave siempre está presente.
  • data — { "type", "object" }. object es el snapshot completo del recurso afectado tal y como estaba cuando ocurrió el evento, idéntico a lo que devolvía un GET de ese recurso en ese momento, y está congelado: los cambios posteriores del recurso no reescriben los eventos pasados. Las claves propias del evento van junto a object, como amount y payment_date en invoice.paid.

El body no lleva object: "event" ni aggregate_id: son del objeto de evento que lees con GET /v1/companies/{company}/events, que lleva los mismos id, type, created y data.

El campo test está siempre presente: true solo para una entrega de prueba enviada con test-event (ver abajo), false para eventos reales. Es ortogonal a livemode: test indica si esta entrega es de prueba, mientras que livemode refleja el entorno de la key (producción vs sandbox). Una entrega de prueba puede emitirse en cualquiera de los dos, así que livemode: true, test: true es válido.

Versión del payload

Cada endpoint tiene un api_version que fija la forma de los snapshots de recurso que recibe en data.object. Las versiones de payload admitidas son 2026-05-22, 2026-10-01 y 2026-10-05 (la más reciente), y el api_version de cada sobre entregado es la versión de esa entrega.

api_version del endpointFacturas en data.object
2026-10-05El vocabulario de 2026-10-01, con todos los importes como cadena decimal y la fecha de cobro como paid_recorded_at y paid_date (paid_at y paid_on desaparecen). La reciben los endpoints creados con la versión REST 2026-10-01 o movidos aquí con api_version.
2026-10-01status: "issued" para una factura emitida, con issued_at, sent_at (primera entrega), sent_via e is_sent, sobre los importes numéricos y paid_at/paid_on de 2026-05-22.
2026-05-22 (todo endpoint que existía antes de 2026-10-01, y los creados sin api_version con una versión REST anterior)El vocabulario anterior: status: "sent", sent_at igual al instante de emisión, sin issued_at, is_sent ni sent_via.

Un endpoint creado sin api_version queda fijado al crearse. Sin cabecera Factuarea-Version ni versión fijada en la clave, o desde la aplicación, se queda en 2026-05-22; si la petición que lo crea usa la versión REST 2026-10-01, toma la versión de payload más reciente (2026-10-05). Para empezar con el vocabulario nuevo, créalo con Factuarea-Version: 2026-10-01 o con "api_version": "2026-10-01".

El evento guardado no se reescribe nunca: la proyección se hace al entregar. Pasa un endpoint al vocabulario nuevo con PATCH /v1/companies/{company}/webhook-endpoints/{webhook_endpoint} y "api_version": "2026-10-01" cuando tu receptor acepte issued. Consulta Versionado.

Respuesta esperada

  • Cualquier status 2xx → entrega delivered, sea cual sea el body.
  • Un 5xx, un 408, 425 o 429, o ninguna respuesta en 10 segundos → entrega failed, se programa el siguiente reintento.
  • Cualquier otro 4xx → entrega failed_permanently, sin reintento.
  • El body es irrelevante. No lo procesamos — el log de entregas guarda response_status, duration_ms y una copia truncada del body (response_body_truncated) para que puedas depurar.

Replay desde el dashboard

Developers > Webhooks > Deliveries te permite reintentar una entrega a mano, igual que el replay de la API: funciona con las entregas failed y failed_permanently, mientras su evento siga almacenado, y encola una entrega nueva del mismo evento con el mismo body. La entrega original conserva su historial.

Entregas de prueba

Dos endpoints te permiten ejercitar tu receptor sin esperar a un evento real — resuelven problemas distintos:

  • POST /v1/companies/{company}/webhook-endpoints/{id}/ping envía una petición sintética { "type": "webhook.ping" } a tu URL y devuelve el resultado en su propia respuesta (success, http_status, response_body, error_message y duration_ms). No va firmada, nunca entra en el log de eventos, no se guarda como entrega y no es un tipo de evento real. Úsalo para confirmar que tu URL es accesible. Necesita una clave live: una clave fact_test_ no puede llamarlo, porque no puede llevar webhooks:write.
  • POST /v1/companies/{company}/webhook-endpoints/{id}/test-event dispara una entrega de prueba de un tipo de evento real del catálogo, marcada con "test": true en el envelope. Registra un Event real (visible en GET /v1/companies/{company}/events) y encola un WebhookDelivery firmado y reintentado exactamente igual que una entrega de producción — así validas tu handler real end-to-end. Su data.object es un marcador de posición con un id inventado, no un snapshot de tus datos. Desde el sandbox se registra y nunca se entrega.
curl -X POST https://api.factuarea.com/v1/companies/{company}/webhook-endpoints/{id}/test-event \
  -H "Authorization: Bearer $FACTUAREA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "type": "invoice.paid" }'

type es opcional: omítelo para usar el primer evento suscrito del endpoint. Si lo indicas, debe ser uno de los enabled_events del endpoint (si no, 422 con el subcode event_not_subscribed). La entrega llega solo a este endpoint, nunca a los demás endpoints suscritos al mismo tipo.

Rotación de secret (dual-signing)

curl -X POST https://api.factuarea.com/v1/companies/{company}/webhook-endpoints/{id}/rotate-secret \
  -H "Authorization: Bearer $FACTUAREA_API_KEY"

Devuelve el nuevo secret. Durante 24 horas (el instante previous_secret_valid_until de la respuesta) ambos secrets son válidos: cada entrega se firma dos veces en la misma header Factuarea-Signature — un v1 por secret (t=...,v1=<current>,v1=<previous>). Tras la ventana, el secret antiguo queda invalidado.

Te permite desplegar el nuevo secret con cero tiempo de inactividad:

  1. Llama a /rotate-secret → obtén el nuevo secret.
  2. Despliega el nuevo secret en tu entorno.
  3. Tu handler acepta cualquiera de los v1 durante el periodo de gracia (los helpers de verificación de arriba ya recorren todos los v1).
  4. Tras la ventana, solo el nuevo secret está en uso.

Idempotencia por tu parte

Cada evento incluye un campo id (UUID v7, único) que nunca cambia: los reintentos automáticos de una entrega, un replay manual y las copias que reciben otros endpoints llevan todos el mismo id — y la header Idempotency-Key lleva ese mismo valor exacto, así que puedes deduplicar desde la header sin parsear el body. El id es también el mismo en los dos ejes: un endpoint de empresa y un endpoint de cuenta que admiten el mismo evento de un NIF reciben el mismo id en sus dos sobres. Guarda los ids que hayas procesado y responde 2xx sin actuar si ya has procesado uno.

Deduplicar entregas de webhooks tiene la receta completa: una clave única sobre el id, la firma verificada primero, cuánto tiempo conservar los ids y qué vuelve a enviar el mismo id.

Lista de acceso de IP (opcional)

Si tu endpoint corre detrás de un firewall que filtra por IP, puedes restringir las IPs de origen mediante ip_allowlist al crear el endpoint. Factuarea entrega desde un pool de IPs estables documentadas en el dashboard.

Valida la firma HMAC, no la IP — las IPs pueden cambiar con 30 días de aviso, las firmas no.

Eventos disponibles

El catálogo completo lo devuelve GET /v1/event-catalog y está documentado en Eventos. Ejemplos clave:

  • invoice.created, invoice.updated, invoice.issued, invoice.marked_sent, invoice.unsent, invoice.paid, invoice.annulled
  • quote.created, quote.approved, quote.rejected, quote.converted
  • proforma.accepted, proforma.converted_to_invoice
  • delivery_note.signed
  • facturae.face_submitted, facturae.face_status_changed, facturae.face_cancellation_requested
  • client.created, client.updated
  • task.created, task.status_changed, task.completed, task.assigned, task_comment.created, task_time_entry.invoiced, project.created

invoice.sent es un alias deprecado de invoice.issued: se emite en el mismo instante y con el mismo data.object, así que un endpoint suscrito a los dos recibe dos entregas. Suscribe los endpoints nuevos a invoice.issued. Una factura se emite cuando recibe su número definitivo; pasa a enviada (invoice.marked_sent) cuando el cliente la recibe. Consulta Trabajar con facturas.

Eventos de tareas y proyectos

Los 21 eventos de tareas, comentarios, tiempo imputado y proyectos se entregan como cualquier otro, y su data.object lleva el recurso completo. task.completed es un subconjunto de task.status_changed: completar una tarea entrega los dos, así que un endpoint suscrito a ambos recibe dos entregas. Consulta Eventos para ver la lista completa y las claves extra de cada uno.

Para escuchar un solo proyecto, filtra por el id de proyecto que lleva cada evento de tarea, comentario y tiempo: en data.object.project_id, o en data.project_id para los borrados y los comentarios. En los eventos project.* el proyecto es data.object.id.

function projectIdOf(event) {
  if (event.type.startsWith("project.")) return event.data.object.id;
  return event.data.project_id ?? event.data.object.project_id;
}

const isTaskEvent = /^(task|task_comment|task_time_entry|project)\./.test(event.type);
if (isTaskEvent && projectIdOf(event) === WATCHED_PROJECT_ID) {
  await enqueue({ eventId: event.id, type: event.type, object: event.data.object });
}

Como con cualquier otro evento, deduplica por el id del evento.

Eventos de pedidos

Suscríbete a order.invoiced y order.refunded para recibir snapshots completos de la factura o rectificativa en data.object. Ambos incluyen data.store.uuid, data.store.provider y data.order.external_id; las devoluciones añaden data.refund.scope y data.refund.reason_code. Usa el id del evento para deduplicar entregas y correlation_id para relacionar operaciones. Consulta el procesamiento de tiendas y el trigger de n8n.

if (event.type === "order.invoiced" || event.type === "order.refunded") {
  const invoice = event.data.object;
  const storeId = event.data.store.uuid;
  const orderId = event.data.order.external_id;
  await saveOrderDocument({ eventId: event.id, storeId, orderId, invoice });
}

Webhooks de cuenta

Todo lo anterior trata de los endpoints de un solo NIF. Conservan su contrato, y siguen siendo la herramienta adecuada cuando integras un único NIF.

Un endpoint de webhook de cuenta es un recurso distinto, bajo /v1/accounts/{account}/webhooks. Un solo endpoint recibe los eventos de cada NIF de tu cuenta, los de las cuentas que gestionas y los eventos account.* de la propia cuenta, con su propio secret de firma, su propio tope por tier del plan y su propio historial de entregas. Firma, reintenta y rota su secret como un endpoint de empresa, así que el código de verificación de esta página le sirve sin cambios. Webhooks de cuenta explica qué recibe y quién puede gestionarlo, y su tabla de equivalencias empareja cada operación con su gemela de empresa, por ejemplo replay con retry, rotate-secret con secret y test-event con test.

Los eventos account.* no están en el catálogo de eventos de empresa: un endpoint de empresa no puede suscribirse a ellos y responde 422. Un evento de un NIF puede llegar a la vez a un endpoint de empresa y a uno de cuenta, y su id es el mismo en los dos sobres: consulta Idempotencia por tu parte.

Endpoints de empresa sobre un NIF alcanzado por una relación

Nadie crea ni gestiona endpoints de webhook de empresa sobre un NIF que tu cuenta alcanza por una relación con otra cuenta, sea porque otra cuenta te lo cedió o porque pertenece a una cuenta que gestionas. Las once operaciones de …/webhook-endpoints responden 403 forbidden_action antes de leer o cambiar nada, con cualquier nivel de acceso, y las tools MCP de esas operaciones responden lo mismo. Sobre tus NIF propios conservan su contrato.

Los registros de peticiones de ese NIF y su log de eventos, GET /v1/companies/{company}/events, están cerrados igual: consulta Listar eventos vía API. Para seguir los NIF de las cuentas que gestionas, usa un endpoint de cuenta con el filtro managed o all. Webhooks de cuenta tiene el detalle.

En esta página

¿Te echamos una mano?Contactar con soporte