Factuarea API
Conceptos clave

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.

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. Para ejercitar la verificación de firma de tu receptor en sandbox, usa el endpoint dedicado POST /v1/webhook_endpoints/{id}/ping, que se entrega. Consulta Modo de prueba y sandbox.

Crear un endpoint

curl -X POST https://api.factuarea.com/v1/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 (el secret se devuelve solo una vez):

{
  "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": "enabled",
  "secret": "whsec_01HKQS5N8VR7QXJ9K3T6BWPMZA9876543210ABCDEF",
  "created_at": "2026-05-15T10:23:18Z"
}

Para suscribirte a todos los eventos, pasa "enabled_events": ["*"]. 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
  • t — timestamp UNIX de la entrega (segundos).
  • v1 — HMAC SHA256 de la cadena {t}.{body} con el secret del endpoint, en hex.

¿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 status que no es 2xx o no responde dentro del timeout_seconds del endpoint (por defecto 10 s), Factuarea reintenta con back-off exponencial:

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

Tras el intento final la entrega pasa a failed_permanently y deja de reintentarse. Permanece visible en GET /v1/webhook_endpoints/{id}/deliveries durante 30 días, y puedes reintentarla manualmente mediante POST /v1/webhook_endpoints/{id}/deliveries/{delivery_id}/replay o desde el dashboard.

Body de la entrega

El body de la entrega es el propio objeto de evento:

{
  "id": "01931b3e-7c4a-7f2e-9a8b-3c5d6e7f8a0d",
  "object": "event",
  "type": "invoice.paid",
  "api_version": "2026-05-22",
  "data": {
    "invoice": { "id": "01931b3e-7c4a-7f2e-9a8b-3c5d6e7f8a03" }
  }
}

El campo data contiene una referencia ligera al recurso afectado — recupéralo desde su propio endpoint para obtener la representación completa. El api_version está siempre presente en los eventos entregados (null solo para eventos antiguos emitidos antes de sellar las versiones).

Respuesta esperada

  • Status 200, 201, 202 o 204 → entrega delivered.
  • Cualquier otro status → entrega failed, se programa el siguiente reintento.
  • El body es irrelevante. No lo procesamos — solo se guardan response_status y duration_ms en el log de entregas.

Replay desde el dashboard

Developers > Webhooks > Deliveries te permite reintentar manualmente cualquier entrega, incluso las failed_permanently. Un reintento manual reinicia el contador y deja una entrada en el log de auditoría.

Rotación de secret (dual-signing)

curl -X POST https://api.factuarea.com/v1/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). Los reintentos del mismo evento siempre llevan el mismo id. Persiste los ids que hayas procesado (tabla webhook_events_processed) y devuelve 200 sin actuar si ya lo has procesado.

event_id = event['id']
if db.exists('webhook_events_processed', id=event_id):
    return '', 200
process(event)
db.insert('webhook_events_processed', id=event_id, processed_at=now())
return '', 200

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.sent, 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

En esta página