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.
Por qué no consultar en bucle
Sección titulada «Por qué no consultar en bucle»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_failedno cambia el estado del documento: la factura sigueVALIDATEDy 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.
Registrar un webhook
Sección titulada «Registrar un webhook»POST/v1/webhooks
Authorization: Bearer $TOKENContent-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. |
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_. |
Registrar dos veces la misma URL no duplica
Sección titulada «Registrar dos veces la misma URL no duplica»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.
A dónde puede apuntar la URL
Sección titulada «A dónde puede apuntar la URL»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 exigehttpses producción. Aun así, en sandbox tampoco valelocalhost: 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.
Ver y borrar suscripciones
Sección titulada «Ver y borrar suscripciones»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.
Los eventos
Sección titulada «Los eventos»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.
El sobre
Sección titulada «El sobre»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.
Cabeceras de la llamada
Sección titulada «Cabeceras de la llamada»| 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. |
La carga útil de cada evento
Sección titulada «La carga útil de cada evento»document.validated
Sección titulada «document.validated»{ "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. |
document.rejected
Sección titulada «document.rejected»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.
document.failed
Sección titulada «document.failed»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.
document.contingency
Sección titulada «document.contingency»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.
document.email_failed
Sección titulada «document.email_failed»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.
Verificar la firma
Sección titulada «Verificar la firma»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>-
Guarda el cuerpo crudo
Sección titulada «Guarda el cuerpo crudo»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.
-
Calcula
Sección titulada «Calcula HMAC-SHA256»HMAC-SHA256Clave: el
secretque te devolvióPOST /webhooks, en texto plano, tal cual, con el prefijowhsec_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. -
Compara en tiempo constante
Sección titulada «Compara en tiempo constante»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. -
Solo después, lee el cuerpo
Sección titulada «Solo después, lee el cuerpo»Si la firma no cuadra, responde
401y termina. Nada deJSON.parseantes de verificar, ni de registrar el contenido, ni de buscar el documento por elidque 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.
<?php$secreto = getenv('MISOFK_WEBHOOK_SECRET'); // "whsec_..."$cuerpo = file_get_contents('php://input'); // crudo$recibida = $_SERVER['HTTP_X_SIGNATURE'] ?? '';$esperada = 'sha256=' . hash_hmac('sha256', $cuerpo, $secreto);
if (!hash_equals($esperada, $recibida)) { http_response_code(401); exit;}
$evento = json_decode($cuerpo, true);// $evento['event'], $evento['data'], $evento['sent_at']// $_SERVER['HTTP_X_DELIVERY_ID'] para descartar repetidoshttp_response_code(200);php://input se lee una sola vez: guárdalo en una variable, no lo
llames dos veces.
import hashlibimport hmacimport os
from flask import Flask, abort, request
app = Flask(__name__)SECRETO = os.environ['MISOFK_WEBHOOK_SECRET'].encode() # b"whsec_..."
@app.post('/hooks/misofk')def misofk(): cuerpo = request.get_data() # bytes crudos, sin parsear recibida = request.headers.get('X-Signature', '') esperada = 'sha256=' + hmac.new(SECRETO, cuerpo, hashlib.sha256).hexdigest()
if not hmac.compare_digest(esperada, recibida): abort(401)
evento = request.get_json() # evento['event'], evento['data'], evento['sent_at'] # request.headers['X-Delivery-Id'] para descartar repetidos return '', 200request.get_data() sin argumentos devuelve los bytes sin tocar.
Reintentos
Sección titulada «Reintentos»| É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.
El orden no está garantizado
Sección titulada «El orden no está garantizado»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.
Ver qué se ha enviado
Sección titulada «Ver qué se ha enviado»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.
Buenas prácticas
Sección titulada «Buenas prácticas»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.
Después de esto
Sección titulada «Después de esto»- Primera factura en 10 minutos — de dónde sale el documento que dispara estos eventos
- Errores — la forma de las respuestas de error y qué significa cada
code - Autenticación — el token que llevan
POST /webhooksy compañía