API de N1

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

EventoCuándo se envíadata
document.importedUn 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_failedEl procesamiento de un archivo cargado falló.Igual que document.imported, con status: "ERROR".
dian_import.completedUna 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.failedUna importación desde la DIAN terminó con error.Igual que dian_import.completed, con status: "ERROR".
export.succeededUn 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.failedLa causación falló de forma definitiva (ya no habrá reintentos automáticos).Igual que export.succeeded, más error (message, code).
dian_event.completedUn evento DIAN (030 acuse, 032 recibo del bien, 033 aceptación) se emitió con éxito.invoice_id, cufe, event, succeeded, error.
dian_event.failedLa emisión de un evento DIAN falló.Igual que dian_event.completed, con succeeded: false y error.
contact.createdSe creó un contacto (tercero).El contacto, con el mismo esquema que GET /contacts/{id}.
contact.updatedSe actualizó un contacto.El contacto, con el mismo esquema que GET /contacts/{id}.
webhook.testEnviaste 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:

EncabezadoContenido
webhook-idIdentificador único del evento (igual al campo id del cuerpo). Se repite en cada reintento.
webhook-timestampMomento del envío en segundos Unix. Cambia en cada reintento.
webhook-signatureFirma 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:

IntentoEspera desde el anterior
1inmediato
21 minuto
35 minutos
430 minutos
52 horas
tras el 5.º fallomarcado 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.