Skip to content

Webhooks

Cuando un documento cambia de estado, SmartDoc manda un POST a una URL de tu sistema. Es la alternativa a consultar con waitUntilFinal, y en producción es lo que conviene: el polling consume cuota del límite de solicitudes por segundo.

El endpoint que recibe ese POST lo escribís vos, en tu aplicación. El SDK pone el otro lado: verifica la firma y llama al handler que corresponda.

Son tres pasos:

  1. Montar un endpoint HTTP en tu sistema.
  2. Registrar su URL pública en el panel de SmartDoc.
  3. Guardar el secreto que te da el panel y pasárselo al SDK.

1. Montar el endpoint

ts
import { Webhooks } from '@araitek/smartdocjs';

const webhooks = new Webhooks({ secret: process.env.SMARTDOC_WEBHOOK_SECRET });

webhooks.on('invoice.approved', (evento) => {
  console.log(evento.entityId, evento.cdc);
});

webhooks.onAnyError((evento) => {
  avisar(`${evento.eventType}: ${evento.errorCode} ${evento.errorMessage}`);
});

Y se expone en la ruta que prefieras:

ts
import express from 'express';

const app = express();

app.post(
  '/webhooks/smartdoc',
  express.raw({ type: 'application/json' }),
  webhooks.expressHandler(),
);
ts
import Fastify from 'fastify';

const app = Fastify();

app.addContentTypeParser(
  'application/json',
  { parseAs: 'buffer' },
  (_request, body, done) => done(null, body),
);

app.post('/webhooks/smartdoc', webhooks.fastifyHandler());
ts
import { Hono } from 'hono';

const app = new Hono();
const handler = webhooks.fetchHandler();

app.post('/webhooks/smartdoc', (c) => handler(c.req.raw));
ts
// app/webhooks/smartdoc/route.ts
export const POST = webhooks.fetchHandler();
ts
// Servidor mínimo, para ver eventos llegar. No usar en producción.
const server = await webhooks.serve({ port: 3000 });

// Cuando termines:
await server.close();

No hace falta tener instalado ningún framework para usar el resto del SDK.

2. Registrar la URL en el panel

En el panel de SmartDoc, en el menú lateral: Conectividad → Webhooks → Agregar. El formulario pide:

CampoQué va
NombreCómo lo vas a reconocer en la lista, por ejemplo Integración ERP
URLLa dirección pública de tu endpoint. Tiene que empezar con https://
ActivoSi empieza recibiendo eventos
Tipos de eventoA cuáles te suscribís, agrupados por documento

La URL es la de tu servidor, con la ruta que hayas montado en el paso anterior: https://mi-servidor.com/webhooks/smartdoc.

3. Guardar el secreto

Al guardar el formulario, el panel muestra el secreto una sola vez, con un botón para copiarlo. Guardalo donde guardes el resto de tus credenciales; es lo que va en new Webhooks({ secret }).

Si lo perdés, hay que generar otro desde el panel.

Probar en local mientras desarrollás

Durante el desarrollo tu servidor corre en localhost, y esa dirección no sirve como URL registrada: el panel pide una pública y con https://. Para llegar a tu máquina, levantá un túnel:

bash
ngrok http 3000
# o
cloudflared tunnel --url http://localhost:3000

Registrá en el panel la URL pública que devuelva y probá contra ella. Cada endpoint tiene un botón Enviar evento de prueba que manda un webhook.test firmado y muestra con qué respondió tu servidor, así verificás conectividad y firma sin emitir nada.

Al pasar a producción, lo habitual es cambiar esa URL por la del dominio donde quede publicada tu aplicación.

Verificar a mano

Si preferís poner vos el ruteo:

ts
const evento = webhooks.verify(cuerpoCrudo, encabezados);

Devuelve un WebhookEvent o lanza SignatureError.

La firma es HMAC-SHA256 del cuerpo con el secreto del endpoint, en el encabezado X-Webhook-Signature: sha256=<hex>.

El cuerpo tiene que ser el crudo

Si el framework parsea el JSON y lo volvés a serializar, el texto cambia —separadores, orden de claves, indentación— y la firma deja de coincidir.

ts
// ✗ mal: reserializado
webhooks.verify(JSON.stringify(request.body), encabezados);

// ✓ bien: los bytes tal como llegaron
webhooks.verify(request.body, request.headers);          // express.raw()
webhooks.verify(await request.text(), encabezados);      // Request estándar

Los adaptadores del SDK ya toman los bytes antes de que nadie los toque, y si detectan un cuerpo ya parseado lanzan SignatureError explicando cómo montar la ruta.

La comparación se hace con crypto.timingSafeEqual, en tiempo constante.

Protección contra reenvíos

El timestamp viaja dentro del cuerpo firmado, así que no se puede alterar sin invalidar la firma. Por omisión se rechazan entregas de más de cinco minutos:

ts
new Webhooks({ secret, maxAge: 300 });    // por omisión
new Webhooks({ secret, maxAge: null });   // sin límite

Un reintento legítimo trae siempre un timestamp nuevo, así que la ventana no lo afecta.

Handlers idempotentes

Una misma entrega puede llegar más de una vez, sobre todo si tu servidor respondió tarde. El SDK deduplica por el id de entrega:

ts
new Webhooks({ secret, dedupe: true, dedupeSize: 1000 });   // por omisión

Esa deduplicación es en memoria: si corrés varias instancias o el proceso se reinicia seguido, conviene deduplicar también en tu base contra evento.id.

Escribí handlers idempotentes de todas formas

Tu handler puede correr más de una vez para el mismo hecho, y ningún filtro por id lo evita del todo. Escribir el CDC dos veces es inofensivo; mandar el mail o cobrar dos veces, no.

ts
webhooks.on(Event.INVOICE_APPROVED, async (evento) => {
  const venta = await ventaDe(evento);
  if (venta.estadoFiscal === 'aprobada') return;   // ya se procesó
  // ...
});

Identificar el documento

entityId se numera por tipo de documento

El recibo 20 y la nota de débito 20 existen a la vez. Si guardás solo el número y buscás por él, vas a cruzar documentos distintos.

La clave correcta es el par [entityType, entityId], que el SDK expone listo para usar:

ts
webhooks.onAny(async (evento) => {
  const [entidad, numero] = evento.documentKey;    // ['receipt', 20]
  await Documento.findOne({ smartdocEntity: entidad, smartdocId: numero });
});

Guardar el CDC

Guardá el CDC apenas llega

El CDC viene en el evento de aprobación, y es lo que hace falta para emitir un recibo o una nota sobre ese documento. Guardalo vinculado a la operación en tu base:

ts
webhooks.on(Event.INVOICE_APPROVED, async (evento) => {
  const [entidad, numero] = evento.documentKey;

  const venta = await Venta.findOne({ smartdocEntity: entidad, smartdocId: numero });
  venta.cdc = evento.cdc;
  venta.estadoFiscal = 'aprobada';
  await venta.save();
});

Después, para cobrar esa venta:

ts
await sd.receipts.create({
  // ...
  associatedDocument: AssociatedDocument.electronic(venta.cdc),
});

Los recibos no tienen CDC propio: evento.cdc viene en undefined.

Responder rápido

Diez segundos

Si tu servidor tarda más, la entrega se marca fallida y se reintenta aunque la hayas procesado bien. Encolá el trabajo pesado y respondé de inmediato.

ts
webhooks.on('invoice.approved', async (evento) => {
  await cola.encolar('procesarFactura', evento.entityId);   // vuelve enseguida
});

El SDK espera a que el handler termine antes de responder, así que un await largo adentro del handler cuenta contra esos diez segundos.

Los reintentos son hasta cinco, con backoff de 5, 10, 20 y 40 segundos.

Si un handler falla

El SDK reporta el error y responde 200 igual. Reintentar no arregla un error de programación: solo lo repite. Lo que corresponde es mirar el log.

Para mandar esos errores a donde los mires:

ts
new Webhooks({
  secret,
  onHandlerError: (error, evento) => {
    logger.error({ error, entrega: evento.id, tipo: evento.eventType });
  },
});

Cada evento es el estado actual

No llegan todas las transiciones

Cuando aparece un evento nuevo de un documento, los anteriores que seguían pendientes de entrega se descartan. Podés no ver los estados intermedios.

No construyas una máquina de estados que espere cada paso. Tratá cada evento como "este documento está ahora en este estado".

23 eventos, con formato {entidad}.{subtipo}: seis entidades por cuatro subtipos, menos receipt.error, que no existe.

SubtipoSignificado
approvedEl documento quedó válido fiscalmente
cancelledSe solicitó o se completó la anulación
pendingEstado intermedio; no es final
errorQuedó en error; el motivo está en errorCode y errorMessage

receipt.approved no significa aprobado por la DNIT

El recibo no se envía a la DNIT. Ese evento quiere decir que el recibo se generó y su KuDE está disponible, con el documento en estado generated.

Para registrar varios de una:

ts
import { Event } from '@araitek/smartdocjs';

webhooks.on([Event.INVOICE_ERROR, Event.CREDIT_NOTE_ERROR], manejar);
webhooks.onAnyError(manejar);     // los cinco *.error
webhooks.onAny(manejar);          // todos

Qué trae el evento

ts
webhooks.onAny((evento) => {
  evento.id;            // id de la entrega
  evento.eventType;     // 'invoice.approved'
  evento.entityType;    // 'invoice'
  evento.entityId;      // id del documento en la API
  evento.timestamp;     // Date
  evento.status;        // 'approved_by_set'
  evento.cdc;           // undefined en recibos
  evento.isApproved;
  evento.isError;
  evento.isFinal;
  evento.errorCode;     // undefined si el documento no está en error
  evento.errorMessage;
});

Está garantizado un subconjunto de evento.data: id, status, los datos del emisor, el timbrado, el establecimiento y el punto de expedición. El resto —invoiceNumber, creditNoteNumber…— puede cambiar sin aviso.

Si necesitás el detalle completo, usá el evento como disparador y traé el documento:

ts
webhooks.on('invoice.approved', async (evento) => {
  const factura = await evento.fetch(sd);     // GET /invoices/{id}
  await guardar(factura.raw);
});