Webhooks
Eventos disponibles, formato de entrega, verificación de la firma, reintentos y desactivación automática.
Los webhooks avisan a tu sistema cuando ocurre algo en N1, sin necesidad de consultar la API en bucle. Se administran por empresa en /v1/companies/{company_id}/webhooks (scope webhooks:manage, rol admin) o desde Configuración → API del panel.
Eventos
| Evento | Cuándo se envía | data |
|---|---|---|
document.imported | Un archivo cargado (PDF, XML, ZIP o correo) se procesó y sus documentos ya existen. | Archivo importado: id, status, type, file_name, dian_importation_id, documents (kind, id, cufe). |
document.import_failed | El procesamiento de un archivo cargado falló. | Igual que document.imported, con status: "ERROR". |
dian_import.completed | Una importación desde la DIAN terminó con éxito. | Importación: id, status, source, document_type, begin_date, end_date, found_count, electronic_invoices_count, credit_notes_count, skipped_count. |
dian_import.failed | Una importación desde la DIAN terminó con error. | Igual que dian_import.completed, con status: "ERROR". |
export.succeeded | Un documento se causó correctamente en el software contable. | Exportación: id, integration, export_kind, status, document (kind, id, cufe), external_reference (id, number, name), completed_at. |
export.failed | La causación falló de forma definitiva (ya no habrá reintentos automáticos). | Igual que export.succeeded, más error (message, code). |
dian_event.completed | Un evento DIAN (030 acuse, 032 recibo del bien, 033 aceptación) se emitió con éxito. | invoice_id, cufe, event, succeeded, error. |
dian_event.failed | La emisión de un evento DIAN falló. | Igual que dian_event.completed, con succeeded: false y error. |
contact.created | Se creó un contacto (tercero). | El contacto, con el mismo esquema que GET /contacts/{id}. |
contact.updated | Se actualizó un contacto. | El contacto, con el mismo esquema que GET /contacts/{id}. |
webhook.test | Enviaste una prueba con POST /webhooks/{id}/test o desde el panel. | message. |
Al crear un endpoint puedes indicar la lista de eventos a los que se suscribe; una lista vacía significa todos los eventos (incluidos los que se agreguen en el futuro). Los reintentos de causación no generan eventos intermedios: solo recibes export.failed cuando la exportación se agota.
Entrega
Cada evento es un POST a tu URL con Content-Type: application/json y este cuerpo:
{
"id": "0d9f4d8e-5b2a-4c1e-9f3a-2b7c1d6e8a90",
"type": "export.succeeded",
"created_at": "2026-01-31T15:04:05.123456+00:00",
"company_id": 12,
"data": {
"id": 9021,
"integration": "siigo",
"export_kind": "electronic_invoice",
"status": "succeeded",
"document": { "kind": "electronic_invoice", "id": 48213, "cufe": "a1b2c3…" },
"external_reference": { "id": "77341", "number": "FC-1-2044", "name": null },
"completed_at": "2026-01-31T15:04:04.981221+00:00"
}
}
Encabezados de cada entrega:
| Encabezado | Contenido |
|---|---|
webhook-id | Identificador único del evento (igual al campo id del cuerpo). Se repite en cada reintento. |
webhook-timestamp | Momento del envío en segundos Unix. Cambia en cada reintento. |
webhook-signature | Firma v1,<base64>: HMAC-SHA256 sobre "{webhook-id}.{webhook-timestamp}.{cuerpo}" con el secreto del endpoint. |
Responde 2xx en menos de 10 segundos. Lo que devuelvas en el cuerpo se ignora; si necesitas procesar en segundo plano, encola el evento y responde de inmediato. No se siguen redirecciones.
Verificar la firma
El secreto (whsec_…) se muestra una sola vez al crear el endpoint o al rotarlo con POST /webhooks/{id}/rotate-secret. Sigue la especificación Standard Webhooks, así que puedes usar cualquiera de sus bibliotecas oficiales. Verifica siempre sobre el cuerpo crudo de la solicitud, antes de parsear el JSON; las bibliotecas también rechazan marcas de tiempo con más de cinco minutos de diferencia (protección contra replay).
Python (pip install standardwebhooks):
from standardwebhooks import Webhook, WebhookVerificationError
wh = Webhook("whsec_…")
def handle(request):
try:
event = wh.verify(request.body, request.headers)
except WebhookVerificationError:
return HttpResponse(status=400)
if event["type"] == "export.succeeded":
...
return HttpResponse(status=204)
Node (npm install standardwebhooks):
import { Webhook } from "standardwebhooks";
import express from "express";
const wh = new Webhook("whsec_…");
const app = express();
app.post("/n1/webhooks", express.raw({ type: "application/json" }), (req, res) => {
let event;
try {
event = wh.verify(req.body, req.headers);
} catch (err) {
return res.status(400).send("firma inválida");
}
if (event.type === "export.succeeded") {
// ...
}
res.sendStatus(204);
});
Si prefieres no usar una biblioteca: quita el prefijo whsec_, decodifica el resto en base64 para obtener la clave, calcula HMAC-SHA256(clave, "{webhook-id}.{webhook-timestamp}.{cuerpo}"), codifícalo en base64 y compáralo (en tiempo constante) con cada valor v1,<firma> del encabezado webhook-signature; puede haber varias firmas separadas por espacio durante una rotación de secreto.
Verificación manual en Python, sobre el cuerpo crudo y en tiempo constante:
import base64
import hashlib
import hmac
import time
SECRET = base64.b64decode("whsec_…".removeprefix("whsec_"))
TOLERANCIA_SEGUNDOS = 5 * 60
def verificar(cuerpo_crudo: bytes, headers) -> bool:
webhook_id = headers["webhook-id"]
webhook_timestamp = headers["webhook-timestamp"]
if abs(time.time() - int(webhook_timestamp)) > TOLERANCIA_SEGUNDOS:
return False
contenido = f"{webhook_id}.{webhook_timestamp}.".encode() + cuerpo_crudo
esperada = base64.b64encode(hmac.new(SECRET, contenido, hashlib.sha256).digest()).decode()
for firma in headers["webhook-signature"].split(" "):
version, _, valor = firma.partition(",")
if version == "v1" and hmac.compare_digest(valor, esperada):
return True
return False
Verificación manual en Node, con el cuerpo crudo de la solicitud:
import crypto from "node:crypto";
const SECRET = Buffer.from("whsec_…".replace(/^whsec_/, ""), "base64");
const TOLERANCIA_SEGUNDOS = 5 * 60;
export function verificar(cuerpoCrudo, headers) {
const webhookId = headers["webhook-id"];
const webhookTimestamp = headers["webhook-timestamp"];
const ahora = Math.floor(Date.now() / 1000);
if (Math.abs(ahora - Number(webhookTimestamp)) > TOLERANCIA_SEGUNDOS) {
return false;
}
const contenido = Buffer.concat([
Buffer.from(`${webhookId}.${webhookTimestamp}.`),
cuerpoCrudo,
]);
const esperada = crypto.createHmac("sha256", SECRET).update(contenido).digest();
return headers["webhook-signature"]
.split(" ")
.some((firma) => {
const [version, valor] = firma.split(",");
if (version !== "v1" || !valor) return false;
const recibida = Buffer.from(valor, "base64");
return (
recibida.length === esperada.length &&
crypto.timingSafeEqual(recibida, esperada)
);
});
}
Reintentos
Si tu endpoint no responde 2xx (error de red, timeout de 10 s o estado distinto de 2xx), N1 reintenta con esperas crecientes:
| Intento | Espera desde el anterior |
|---|---|
| 1 | inmediato |
| 2 | 1 minuto |
| 3 | 5 minutos |
| 4 | 30 minutos |
| 5 | 2 horas |
| tras el 5.º fallo | marcado como failed; no se reintenta |
Cada intento conserva el mismo webhook-id y actualiza webhook-timestamp. El historial de los últimos 30 días está en GET /webhooks/{id}/deliveries con status (pending, delivered, failed), attempts, next_attempt_at, last_status_code y last_error.
Desactivación automática
El endpoint cuenta fallos consecutivos (consecutive_failures); cada entrega exitosa lo pone en cero. Tras 20 fallos consecutivos el endpoint pasa a enabled: false, las entregas pendientes se marcan como failed y no se envían eventos nuevos. Corrige el problema y vuelve a activarlo con PATCH /webhooks/{id} { "enabled": true }, lo que reinicia el contador; los eventos ocurridos mientras estuvo desactivado no se reenvían.
Idempotencia y orden
Un mismo evento puede llegar más de una vez (por ejemplo, si respondiste 2xx pero la respuesta no llegó a tiempo). Guarda el webhook-id de los eventos ya procesados y descarta los repetidos. El orden de llegada no está garantizado: para saber el estado actual de un recurso, consulta la API con el id que trae el evento en vez de confiar en la secuencia de webhooks.