Solicitar acceso

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

EventoCuándo llega
document.acceptedLa DIAN aceptó el documento. Ya puedes descargar el PDF y el XML.
document.rejectedLa DIAN lo rechazó; los motivos vienen en data.dian.errors.
document.failedNo 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:

  1. Calcula el HMAC sobre el cuerpo crudo, exactamente como llegó.
  2. Compáralo con v1 en tiempo constante.
  3. Rechaza avisos con t de 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.