Ir al contenido

Webhooks

Emitir es asíncrono. POST /v1/invoices responde QUEUED con cufe en null: numerar, armar el UBL, firmar y transmitir a la DIAN pasa después, en segundo plano. Un webhook es cómo te enteras del final sin quedarte preguntando.

Consultar GET /v1/documents/:id cada pocos segundos funciona mientras integras. En producción se cae solo, por tres razones:

  • El límite es 300 peticiones por minuto y por IP. Un emisor con 200 documentos abiertos y una consulta cada 5 segundos se lo come entero, y las peticiones que te importan —emitir— empiezan a devolver 429.
  • No sabes cuánto esperar. La DIAN a veces contesta en segundos y a veces no contesta: cuando se agotan los intentos el documento queda en CONTINGENCY, un estado al que no llegas «esperando un poquito más».
  • Hay avisos que no verías nunca. document.email_failed no cambia el estado del documento: la factura sigue VALIDATED y el correo al adquirente no salió. Consultando el estado eso es invisible.

Con webhooks nuestro servidor te llama a ti, una vez por evento y por suscripción interesada, y reintenta si no le contestas.

POST/v1/webhooks

Authorization: Bearer $TOKEN
Content-Type: application/json
Campo Tipo Obligatorio Descripción
url string Sí A dónde te llamamos. En producción tiene que ser https.
events string[] Sí Al menos uno de la lista de eventos. Un nombre que no esté en la lista rechaza toda la petición.
Ventana de terminal
curl -X POST https://sandbox.facturacion.misofk.com/v1/webhooks \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"url": "https://mi-erp.example.com/hooks/misofk",
"events": ["document.validated", "document.rejected", "document.email_failed"]
}'

Respuesta 201:

{
"id": "4f9a3c1e-77b2-4d0a-9f31-6b8e2c5a1d04",
"url": "https://mi-erp.example.com/hooks/misofk",
"events": ["document.validated", "document.rejected", "document.email_failed"],
"secret": "whsec_3xK9pQ2mVn7RtY4wZ8bL1cJ6hF0sD5aG"
}
Campo Tipo Descripción
id uuid Identificador de la suscripción. Es el que va en DELETE /webhooks/:id.
url string La URL registrada.
events string[] Los eventos a los que quedó suscrita.
secret string La clave con la que firmamos cada envío. Empieza por whsec_.

Si ya existe una suscripción con esa URL, no se crea una segunda: se le reescriben los events, se reactiva y se devuelve el mismo secreto. Está hecho así para que tu despliegue pueda registrar su webhook cada vez que arranca sin ir dejando suscripciones repetidas detrás.

Consecuencia práctica: events se reemplaza, no se suma. Si mandas ["document.validated"] sobre una suscripción que ya oía cuatro eventos, te quedan tres sin oír.

El webhook lo llama nuestro servidor, no tu navegador. Sin revisar el destino, cualquiera con cuenta podría usarnos de puente hacia la red interna del despliegue. Por eso la URL se revisa antes de guardarla, y un destino no permitido devuelve 422 con code: VALIDATION:

Caso message
No parsea como URL No es una URL válida
Esquema distinto de http: o https: El esquema X no sirve para un webhook
http en producción La URL tiene que ser https
Host interno «<host>» es una dirección interna. El webhook lo llama nuestro servidor, así que tiene que apuntar a un sitio alcanzable desde internet.

Cuenta como host interno: localhost y *.localhost, 127.*, 0.*, 10.*, 192.168.*, 172.16.*–172.31.*, 169.254.*, [::1], las IPv6 fc..: y fd..:, *.internal, *.local, y cualquier host sin punto.

Dos detalles que ahorran tiempo:

  • En sandbox y QA sí se admite http. El único ambiente que exige https es producción. Aun así, en sandbox tampoco vale localhost: la llamada sale de nuestra red. Para probar contra tu máquina usa un túnel público.
  • Se revisa el nombre que escribiste, no a dónde resuelve. Un dominio público apuntando a una IP privada pasa el filtro. No lo tomes como una garantía de aislamiento de tu lado.

GET/v1/webhooks

Las suscripciones de tu emisor, sin secret:

[
{
"id": "4f9a3c1e-77b2-4d0a-9f31-6b8e2c5a1d04",
"companyId": "9c2b6e10-3a45-4f88-b7d1-0e5a9f3c7a21",
"url": "https://mi-erp.example.com/hooks/misofk",
"events": ["document.validated", "document.rejected"],
"isActive": true,
"createdAt": "2026-09-01T14:22:03.118Z",
"updatedAt": "2026-09-01T14:22:03.118Z"
}
]

DELETE/v1/webhooks/:id

{ "deleted": true }

Con un id que no existe, o que es de otro emisor, responde 404 — Suscripción no encontrada.

Estos son todos. No hay más:

Evento Cuándo se dispara
document.validated La DIAN validó el documento. Ya hay cufe y full_number.
document.rejected La DIAN lo rechazó. Trae las reglas que no pasó.
document.failed Reventó armando o firmando el documento, antes de llegar a la DIAN.
document.contingency La DIAN no respondió y se agotaron los intentos. El documento queda en contingencia con el consecutivo ya consumido.
document.email_failed El documento está bien, pero el correo al adquirente no salió.

Un documento puede generar varios eventos: document.validated primero y document.email_failed después es la secuencia normal cuando el adquirente no tiene correo registrado.

Todos los eventos llegan con la misma envoltura. Lo que cambia es data:

{
"event": "document.validated",
"data": { },
"sent_at": "2026-09-04T15:04:05.123Z"
}
Campo Tipo Descripción
event string Uno de los cinco de la tabla anterior.
data object Depende del evento. Ver abajo.
sent_at string ISO-8601 UTC Cuándo se creó el aviso, no cuándo se hizo este intento.

sent_at no cambia entre reintentos: el cuerpo que recibes en el intento 4 es byte por byte el mismo del intento 1. Es lo que hace que la firma siga cuadrando y que puedas descartar repetidos comparando el X-Delivery-Id.

Cabecera Valor
Content-Type application/json
X-Signature sha256=<hex> — ver Verificar la firma
X-Webhook-Event El nombre del evento, igual que event en el cuerpo
X-Delivery-Id UUID de la entrega. El mismo en todos los reintentos.
{
"event": "document.validated",
"data": {
"id": "b1d7e4c2-9a30-4f56-8c11-2e7d5a9b3f80",
"reference_code": "FV-2026-00412",
"full_number": "SETP990000123",
"cufe": "8f2c1ad47b9e05c36a1df8402be7539c14ad6e02f7b8391c5da0e64f27bc8130a95e7d21c6f403b8e19d75ac2f60b843",
"qr_url": "https://catalogo-vpfe.dian.gov.co/document/searchqr?documentkey=8f2c1ad47b9e05c36a1df8402be7539c14ad6e02f7b8391c5da0e64f27bc8130a95e7d21c6f403b8e19d75ac2f60b843"
},
"sent_at": "2026-09-04T15:04:05.123Z"
}
Campo de data Tipo Descripción
id uuid El id del documento en nuestro API.
reference_code string Tu referencia, la que mandaste al emitir.
full_number string Prefijo y consecutivo, unidos.
cufe string El CUFE que calculó y firmó el servidor.
qr_url string La URL de consulta pública en la DIAN.

La DIAN contestó que el documento no es válido.

{
"event": "document.rejected",
"data": {
"id": "b1d7e4c2-9a30-4f56-8c11-2e7d5a9b3f80",
"reference_code": "FV-2026-00412",
"full_number": "SETP990000123",
"status_code": "99",
"errors": [
{ "rule": "FAJ43b", "message": "Rechazo: El valor del CUFE no coincide" },
{ "message": "Regla de validación no superada" }
]
},
"sent_at": "2026-09-04T15:04:05.123Z"
}
Campo de data Tipo Descripción
id uuid El documento.
reference_code string Tu referencia.
full_number string El número que se alcanzó a consumir.
status_code string El código que devolvió la DIAN.
errors array Las reglas que no pasó, tal como las manda la DIAN.

Cada elemento de errors es { rule?, message }: message siempre viene, rule solo cuando la DIAN identificó la regla. errors nunca llega vacío: si la DIAN rechaza sin detallar reglas, ponemos un único elemento con su descripción, o con el texto La DIAN respondió <código> sin explicación cuando ni eso mandó. Así no tienes que programar el caso de la lista vacía.

El armado o el firmado se rompieron de nuestro lado, antes de hablar con la DIAN.

{
"event": "document.failed",
"data": {
"id": "b1d7e4c2-9a30-4f56-8c11-2e7d5a9b3f80",
"reference_code": "FV-2026-00412",
"error": "Cannot read properties of undefined (reading 'taxAmount')"
},
"sent_at": "2026-09-04T15:04:05.123Z"
}

error es el mensaje técnico de la excepción. Sirve para que nos escribas citándolo; no lo muestres al usuario final.

Se agotaron los intentos contra la DIAN y el documento pasó a contingencia.

{
"event": "document.contingency",
"data": {
"id": "b1d7e4c2-9a30-4f56-8c11-2e7d5a9b3f80",
"reference_code": "FV-2026-00412",
"full_number": "SETP990000123",
"error": "timeout of 30000ms exceeded"
},
"sent_at": "2026-09-04T15:04:05.123Z"
}

El consecutivo ya se consumió: el documento existe y tiene número, lo que falta es la validación de la DIAN. No vuelvas a emitirlo con otro reference_code, que ahí sí duplicas.

El documento quedó válido y el correo al adquirente no salió.

{
"event": "document.email_failed",
"data": {
"id": "b1d7e4c2-9a30-4f56-8c11-2e7d5a9b3f80",
"full_number": "SETP990000123",
"reference_code": "FV-2026-00412",
"email_to": "cliente@ejemplo.com",
"email_error": "El adquirente no tiene correo registrado"
},
"sent_at": "2026-09-04T15:04:05.123Z"
}
Campo de data Tipo Descripción
id uuid El documento.
full_number string Prefijo y consecutivo.
reference_code string Tu referencia.
email_to string | null El correo del adquirente, o null si no tiene.
email_error string El motivo.

Motivos que lo disparan:

email_error Qué significa
Sin AttachedDocument: no hay qué enviar Falta el contenedor de entrega.
El adquirente no tiene correo registrado El customer se emitió sin email.
SMTP no configurado en el servidor El emisor no tiene correo saliente configurado.
El mensaje que devolvió el servidor SMTP Rebote, buzón lleno, dominio que no existe.
Entrega fallida: <mensaje> Se agotaron los intentos de la etapa de entrega.

Este evento no invalida la factura. El documento sigue VALIDATED y su CUFE vale. Es la señal para reenviarlo con POST /v1/documents/:id/resend-email, indicando otro correo si hace falta. El mismo motivo lo puedes leer en el detalle del documento, en email_error.

Cada llamada trae X-Signature con el HMAC del cuerpo. Si no lo verificas, tu endpoint acepta un document.validated de cualquiera que adivine la URL.

X-Signature: sha256=<hmac en hexadecimal>
  1. La firma se calcula sobre los bytes exactos que mandamos. Si tu framework parsea el JSON y tú lo vuelves a serializar para firmarlo, el resultado puede no coincidir carácter por carácter —basta un espacio o un orden de claves distinto— y rechazarás envíos buenos.

  2. Clave: el secret que te devolvió POST /webhooks, en texto plano, tal cual, con el prefijo whsec_ incluido. No lo decodifiques de base64 ni le quites el prefijo.

    Mensaje: el cuerpo crudo. Salida en hexadecimal en minúsculas, con sha256= por delante.

  3. timingSafeEqual, hash_equals, hmac.compare_digest. Un == normal corta en el primer byte distinto, y esa diferencia de microsegundos deja adivinar la firma byte a byte.

  4. Si la firma no cuadra, responde 401 y termina. Nada de JSON.parse antes de verificar, ni de registrar el contenido, ni de buscar el documento por el id que viene adentro.

const crypto = require('crypto');
const express = require('express');
const app = express();
const SECRET = process.env.MISOFK_WEBHOOK_SECRET; // "whsec_..."
app.post(
'/hooks/misofk',
express.raw({ type: 'application/json' }), // el cuerpo CRUDO, sin parsear
(req, res) => {
const recibida = req.get('X-Signature') || ''; // "sha256=<hex>"
const esperada =
'sha256=' +
crypto.createHmac('sha256', SECRET).update(req.body).digest('hex');
const a = Buffer.from(recibida);
const b = Buffer.from(esperada);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).end();
}
const evento = JSON.parse(req.body.toString('utf8'));
// evento.event, evento.data, evento.sent_at
// req.get('X-Delivery-Id') identifica el envío: guárdalo para no repetir
res.status(200).end(); // cualquier 2xx cierra la entrega
},
);

timingSafeEqual lanza si los dos búferes miden distinto, por eso se compara la longitud antes.

Éxito Cualquier código 2xx. La entrega queda cerrada y no se repite.
Fallo Cualquier otro código HTTP, o error de red, o que tardes más de 15 segundos en responder.
Intentos 5 en total, con espera creciente que arranca en 1 minuto.
Después La entrega queda como EXHAUSTED y no se vuelve a intentar.

Las redirecciones se siguen solas, hasta 21 saltos, y lo que se evalúa es el código del destino final: un 3xx nunca llega a verse. Si tu endpoint redirige a un sitio que responde 200, la entrega queda como DELIVERED y no se reintenta. Solo si se pasa de 21 saltos cuenta como error de red.

Los envíos salen en paralelo, hasta ocho a la vez, y cada suscripción interesada genera su propio envío. Dos eventos del mismo documento pueden llegarte cruzados, y uno que se reintentó llega después de otro más nuevo.

Decide por el contenido, no por el orden de llegada: si te llega document.validated de un documento que ya marcaste como validado, ignóralo.

GET/v1/webhooks/deliveries

El historial de envíos de tu emisor, del más reciente al más viejo. Es por dónde se empieza cuando «no me está llegando nada».

Parámetro Tipo Descripción
limit query, entero Cuántas filas. Por defecto 50, tope 200.

Cada fila:

Campo Tipo Descripción
id uuid El valor que viajó en X-Delivery-Id.
subscriptionId uuid A qué suscripción iba.
documentId uuid | null El documento al que se refiere.
event string El evento.
payload object El cuerpo exacto que mandamos.
attempts int Intentos consumidos.
lastStatusCode int | null El último HTTP que devolviste. null si fue error de red o se agotó el timeout.
status string PENDING, DELIVERED o EXHAUSTED.
createdAt / updatedAt string ISO-8601

Con esto se diagnostica solo: lastStatusCode en 500 es tu manejador reventando; en 401, tu verificación de firma rechazando lo bueno; en null con attempts alto, tu servidor no es alcanzable o tarda más de 15 segundos.

Responde rápido y procesa aparte. El timeout son 15 segundos, y ese reloj corre mientras tu manejador emite el PDF, actualiza inventario y manda un correo. Verifica la firma, guarda el evento en una cola o una tabla, devuelve 200 y haz el trabajo después. Un 200 significa «recibido», no «terminado» — es lo mismo que hace este API contigo.

Sé idempotente. El mismo evento te va a llegar dos veces algún día: tu servidor devolvió 200 pero se cortó la conexión antes de que lo viéramos, y reintentamos. La regla es guardar el X-Delivery-Id antes de actuar y salir sin hacer nada si ya estaba. Como el id es el mismo en todos los reintentos de un envío, una columna con índice único basta.

Responde 2xx aunque el contenido no te sirva. Un evento de un documento que tu sistema no conoce no es un fallo de entrega: registra y devuelve 200. Si contestas 500, gastas los cinco intentos y ensucias el historial con algo que no se va a arreglar reintentando.

Verifica la firma siempre, también en sandbox. Es donde se descubre que estabas firmando el JSON reserializado, no el cuerpo crudo. Descubrirlo en producción es descubrirlo rechazando facturas reales.

Suscríbete a los cinco eventos, aunque solo actúes sobre dos. Los tres de fallo —rejected, failed, contingency— son los que te dicen que algo necesita a una persona. Guárdalos aunque al principio solo los registres en un log.

Un endpoint por ambiente. Sandbox y producción son emisores distintos con secretos distintos. Apuntar los dos a la misma URL termina en facturas de prueba tratadas como reales.