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 sí 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-3c5d6e7f8a0ct— timestamp UNIX de la entrega (segundos).v1— HMAC SHA256 de la cadena{t}.{body}con elsecretdel 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:
- Extrae
tyv1deFactuarea-Signature. - Calcula
signed_payload = t + "." + raw_body(bytes crudos del body, sin reformatear el JSON). - Calcula
expected = hmac_sha256(secret, signed_payload)en hex. - Compara
v1 == expectedusando comparación en tiempo constante. - 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 '', 200Verificar 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:
| Intento | Espera tras el anterior |
|---|---|
| 1 | inmediato |
| 2 | 1 minuto |
| 3 | 5 minutos |
| 4 | 30 minutos |
| 5 | 2 horas |
| 6 | 12 horas |
| 7 | 1 día |
| 8 | 3 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,202o204→ entregadelivered. - Cualquier otro status → entrega
failed, se programa el siguiente reintento. - El body es irrelevante. No lo procesamos — solo se guardan
response_statusyduration_msen 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:
- Llama a
/rotate_secret→ obtén el nuevosecret. - Despliega el nuevo secret en tu entorno.
- Tu handler acepta cualquiera de los
v1durante el periodo de gracia (los helpers de verificación de arriba ya recorren todos losv1). - 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 '', 200Lista 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.annulledquote.created,quote.approved,quote.rejected,quote.convertedproforma.accepted,proforma.converted_to_invoicedelivery_note.signedfacturae.face_submitted,facturae.face_status_changed,facturae.face_cancellation_requestedclient.created,client.updated