Tema
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:
- Montar un endpoint HTTP en tu sistema.
- Registrar su URL pública en el panel de SmartDoc.
- 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:
| Campo | Qué va |
|---|---|
| Nombre | Cómo lo vas a reconocer en la lista, por ejemplo Integración ERP |
| URL | La dirección pública de tu endpoint. Tiene que empezar con https:// |
| Activo | Si empieza recibiendo eventos |
| Tipos de evento | A 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:3000Registrá 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ándarLos 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ímiteUn 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ónEsa 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".
El catálogo
23 eventos, con formato {entidad}.{subtipo}: seis entidades por cuatro subtipos, menos receipt.error, que no existe.
| Subtipo | Significado |
|---|---|
approved | El documento quedó válido fiscalmente |
cancelled | Se solicitó o se completó la anulación |
pending | Estado intermedio; no es final |
error | Quedó 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); // todosQué 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);
});