Webhooks: entérate cuando la DIAN responde
La DIAN puede tardar en responder. En lugar de consultar el estado una y otra vez, configura un webhook: FactuROA hace un POST a tu URL cuando cada documento llega a su estado final.
Configurar la URL
curl -X PUT "https://api.facturoa.com.co/api/v1/account/webhook" \
-H "Authorization: Bearer $FACTUROA_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "url": "https://erp.example.com/facturoa" }'La respuesta trae el secret (whsec_…) para verificar la firma. Se muestra solo cuando se genera: la primera vez o al enviar "rotate_secret": true. Guárdalo como un secreto de tu servidor. Detalles en la referencia.
- Solo
https. No se entrega a IPs privadas, loopback ni link-local, y no se siguen redirecciones. - Para dejar de recibir avisos: DELETE /account/webhook.
Eventos
| Evento | Cuándo llega |
|---|---|
document.accepted | La DIAN aceptó el documento. Ya puedes descargar el PDF y el XML. |
document.rejected | La DIAN lo rechazó; los motivos vienen en data.dian.errors. |
document.failed | No se pudo enviar después de los reintentos. |
El cuerpo trae el evento y, en data, el mismo documento que devuelve GET /documents/{id}:
{
"event": "document.accepted",
"sent_at": "2026-11-09T15:00:00Z",
"data": {
"id": "0199a3d4-7c1e-7b2a-9f10-5c3e8d2a1b40",
"external_id": "pedido-77",
"document_type": "invoice",
"full_number": "FE1024",
"status": "accepted"
}
}Verificar la firma
Cada aviso trae los encabezados X-Facturoa-Event y X-Facturoa-Signature: t=<unix>,v1=<hex>, donde v1 es el HMAC-SHA256 de "<t>.<cuerpo>" con tu secreto (incluido el prefijo whsec_). Para aceptar un aviso:
- Calcula el HMAC sobre el cuerpo crudo, exactamente como llegó.
- Compáralo con
v1en tiempo constante. - Rechaza avisos con
tde hace más de unos minutos, para evitar reenvíos.
import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_SECONDS = 300;
// rawBody: el cuerpo tal cual llegó (string o Buffer), antes de parsear el JSON.
export function verifyFacturoaSignature(rawBody, header, secret) {
const parts = Object.fromEntries(header.split(",").map((part) => part.split("=")));
const timestamp = Number(parts.t);
if (!timestamp || Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false;
const expected = createHmac("sha256", secret).update(`${parts.t}.`).update(rawBody).digest();
const received = Buffer.from(parts.v1 ?? "", "hex");
return received.length === expected.length && timingSafeEqual(received, expected);
}Reintentos y duplicados
Responde con un 2xx en pocos segundos y procesa el aviso después. Cualquier otra respuesta, o un error de red, se reintenta a 1 min, 5 min, 15 min, 1 h, 3 h, 6 h, 12 h y 24 h; después se abandona.