Skip to content

Webhooks salientes

Emitia envía un POST a tu URL cada vez que ocurre un evento: DIAN aceptó, DIAN rechazó, email rebotó, etc. Tú registras el endpoint, guardas el secret, y verificas cada payload con HMAC-SHA256.


Terminal window
curl -X POST https://api.emitia.co/v1/webhook-endpoints \
-H "Authorization: Bearer sk_test_TU_CLAVE_AQUI" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Emitia-Version: 2026-01-01" \
-d '{
"url": "https://tuapp.co/hooks/emitia",
"subscribed_events": [
"invoice.accepted_by_dian",
"invoice.rejected_by_dian",
"invoice.failed"
],
"description": "Producción — facturas"
}'

La respuesta 201 incluye el campo secret con prefijo whsec_:

{
"id": "we_01HX...",
"object": "webhook_endpoint",
"url": "https://tuapp.co/hooks/emitia",
"description": "Producción — facturas",
"subscribed_events": [
"invoice.accepted_by_dian",
"invoice.rejected_by_dian",
"invoice.failed"
],
"is_active": true,
"livemode": false,
"metadata": {},
"api_version": "2026-01-01",
"created_at": "2026-01-15T10:30:00-05:00",
"updated_at": "2026-01-15T10:30:00-05:00",
"secret": "whsec_..."
}

Emitia emite exactamente 21 event types, definidos en packages/shared/src/event-types.ts (fuente canónica). Usa estos strings verbatim en subscribed_events.

Event typeCuándo se dispara
invoice.accepted_by_dianDIAN aceptó la factura
invoice.rejected_by_dianDIAN rechazó la factura
invoice.accepted_contingencyEmitida en contingencia Tipo 04
invoice.failedError interno no DIAN
credit_note.accepted_by_dianDIAN aceptó la nota crédito
credit_note.rejected_by_dianDIAN rechazó la nota crédito
credit_note.accepted_contingencyNota crédito en contingencia Tipo 04
credit_note.failedError interno no DIAN
debit_note.accepted_by_dianDIAN aceptó la nota débito
debit_note.rejected_by_dianDIAN rechazó la nota débito
debit_note.accepted_contingencyNota débito en contingencia Tipo 04
debit_note.failedError interno no DIAN
Event typeCuándo se dispara
invoice.email.deliveredEmail al adquirente entregado
invoice.email.bouncedEmail rebotó (dirección inválida)
invoice.email.complainedAdquirente marcó como spam
credit_note.email.deliveredEmail de nota crédito entregado
credit_note.email.bouncedEmail de nota crédito rebotó
credit_note.email.complainedEmail de nota crédito marcado spam
debit_note.email.deliveredEmail de nota débito entregado
debit_note.email.bouncedEmail de nota débito rebotó
debit_note.email.complainedEmail de nota débito marcado spam

Cada POST a tu endpoint lleva estos headers:

Emitia-Event-Id: evt_01HX...
Emitia-Event-Type: invoice.accepted_by_dian
Emitia-Signature: t=1716480600,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
Emitia-Webhook-Id: we_01HX...
User-Agent: Emitia/1.0 (+https://emitia.co/webhooks)
Content-Type: application/json

Y el body:

{
"id": "evt_01HX...",
"object": "event",
"type": "invoice.accepted_by_dian",
"created": "2026-01-15T10:30:04-05:00",
"livemode": false,
"account": "ten_01HX...",
"data": {
"object": {
"id": "inv_01HX...",
"object": "invoice",
"status": "accepted_by_dian",
"cufe": "fe9c3a1b...",
"pdf_url": "https://files.emitia.co/..."
}
}
}

Emitia firma cada payload con HMAC-SHA256 usando tu whsec_.... Rechaza cualquier evento que no pase la verificación o que llegue fuera de la ventana de tolerancia (default: 300 segundos).

import Emitia from '@emitia/node';
import type { Request, Response } from 'express';
const emitia = new Emitia(process.env.EMITIA_KEY!);
const webhookSecret = process.env.EMITIA_WEBHOOK_SECRET!; // tu whsec_...
export async function webhookHandler(req: Request, res: Response) {
const signature = req.headers['emitia-signature'] as string;
let event;
try {
// constructEvent lanza SignatureVerificationError si la firma es inválida
event = emitia.webhooks.constructEvent(
req.body, // Buffer o string raw (sin parsear)
signature,
webhookSecret,
);
} catch (err) {
console.error('Firma inválida:', err);
return res.status(400).send('Webhook signature verification failed');
}
switch (event.type) {
case 'invoice.accepted_by_dian':
await handleInvoiceAccepted(event.data.object);
break;
case 'invoice.rejected_by_dian':
await handleInvoiceRejected(event.data.object);
break;
// ...resto de event types
}
res.json({ received: true });
}

El livemode del endpoint se deriva de la API key con la que se registró:

API key usadalivemodeRecibe eventos de
sk_test_...falseFacturas creadas en modo test
sk_live_...trueFacturas creadas en modo live

El aislamiento es garantizado por el sistema: los eventos test nunca llegan a endpoints live, y viceversa.


Terminal window
# Listar (paginación cursor)
curl "https://api.emitia.co/v1/webhook-endpoints?limit=10" \
-H "Authorization: Bearer sk_test_TU_CLAVE_AQUI"
# Consultar uno
curl "https://api.emitia.co/v1/webhook-endpoints/we_01HX..." \
-H "Authorization: Bearer sk_test_TU_CLAVE_AQUI"

O con el SDK:

const list = await emitia.webhookEndpoints.list({ limit: 10 });
const endpoint = await emitia.webhookEndpoints.retrieve('we_01HX...');

Las respuestas de listado y retrieve nunca incluyen el campo secret.

const updated = await emitia.webhookEndpoints.update('we_01HX...', {
subscribed_events: ['invoice.accepted_by_dian', 'credit_note.accepted_by_dian'],
is_active: true,
});

Hard delete — el endpoint queda permanentemente inhabilitado:

const deleted = await emitia.webhookEndpoints.del('we_01HX...');
// { id: 'we_01HX...', object: 'webhook_endpoint', deleted: true }

La rotación es instantánea y sin grace period: el secret viejo deja de ser válido de inmediato. Los webhooks en vuelo firmados con el secret anterior fallarán verificación.

const rotated = await emitia.webhookEndpoints.rotateSecret('we_01HX...');
// rotated.secret → nuevo "whsec_..." (único momento en que se devuelve)

O con curl:

Terminal window
curl -X POST "https://api.emitia.co/v1/webhook-endpoints/we_01HX.../rotate-secret" \
-H "Authorization: Bearer sk_test_TU_CLAVE_AQUI"

Respuesta 201 con el nuevo secret.


  • Motor: BullMQ con backoff exponencial.
  • Intentos: 4 en total (1 inicial + 3 reintentos).
  • Primer reintento: ~30 segundos después del fallo.
  • Condición de éxito: tu endpoint responde 2xx dentro del timeout.
  • Guard SSRF: Emitia valida el destino DNS antes de cada entrega (no se entregan webhooks a IPs internas o de loopback).

Tu endpoint debe responder 200299 rápido (antes de procesar la lógica de negocio). Si el procesamiento es lento, responde 200 inmediatamente y encolá el trabajo asíncronamente.