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.
Registrar un endpoint
Section titled “Registrar un endpoint”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" }'import Emitia from '@emitia/node';
const emitia = new Emitia(process.env.EMITIA_KEY!);
const endpoint = await emitia.webhookEndpoints.create( { url: 'https://tuapp.co/hooks/emitia', subscribed_events: [ 'invoice.accepted_by_dian', 'invoice.rejected_by_dian', 'invoice.failed', ], description: 'Producción — facturas', }, { idempotencyKey: crypto.randomUUID() },);
// endpoint.secret → "whsec_..." (solo disponible en create y rotate-secret).// Guárdalo en tu gestor de secretos (Vault, AWS Secrets Manager, env cifrado).// NUNCA lo escribas en logs ni lo commitees.await saveToSecretManager('EMITIA_WEBHOOK_SECRET', endpoint.secret);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_..."}Catálogo de event types
Section titled “Catálogo de event types”Emitia emite exactamente 21 event types, definidos en
packages/shared/src/event-types.ts (fuente canónica). Usa estos strings
verbatim en subscribed_events.
Resultados DIAN
Section titled “Resultados DIAN”| Event type | Cuándo se dispara |
|---|---|
invoice.accepted_by_dian | DIAN aceptó la factura |
invoice.rejected_by_dian | DIAN rechazó la factura |
invoice.accepted_contingency | Emitida en contingencia Tipo 04 |
invoice.failed | Error interno no DIAN |
credit_note.accepted_by_dian | DIAN aceptó la nota crédito |
credit_note.rejected_by_dian | DIAN rechazó la nota crédito |
credit_note.accepted_contingency | Nota crédito en contingencia Tipo 04 |
credit_note.failed | Error interno no DIAN |
debit_note.accepted_by_dian | DIAN aceptó la nota débito |
debit_note.rejected_by_dian | DIAN rechazó la nota débito |
debit_note.accepted_contingency | Nota débito en contingencia Tipo 04 |
debit_note.failed | Error interno no DIAN |
Entrega de email
Section titled “Entrega de email”| Event type | Cuándo se dispara |
|---|---|
invoice.email.delivered | Email al adquirente entregado |
invoice.email.bounced | Email rebotó (dirección inválida) |
invoice.email.complained | Adquirente marcó como spam |
credit_note.email.delivered | Email de nota crédito entregado |
credit_note.email.bounced | Email de nota crédito rebotó |
credit_note.email.complained | Email de nota crédito marcado spam |
debit_note.email.delivered | Email de nota débito entregado |
debit_note.email.bounced | Email de nota débito rebotó |
debit_note.email.complained | Email de nota débito marcado spam |
Estructura del payload
Section titled “Estructura del payload”Cada POST a tu endpoint lleva estos headers:
Emitia-Event-Id: evt_01HX...Emitia-Event-Type: invoice.accepted_by_dianEmitia-Signature: t=1716480600,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bdEmitia-Webhook-Id: we_01HX...User-Agent: Emitia/1.0 (+https://emitia.co/webhooks)Content-Type: application/jsonY 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/..." } }}Verificar la firma
Section titled “Verificar la firma”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 });}import crypto from 'node:crypto';
function verifyEmitiaSignature( rawBody: string | Buffer, signatureHeader: string, secret: string, toleranceSec = 300,): boolean { const parts = Object.fromEntries( signatureHeader.split(',').map(s => s.split('=')), ); const t = parseInt(parts.t, 10); const v1 = parts.v1;
if (Math.abs(Date.now() / 1000 - t) > toleranceSec) { throw new Error('Timestamp fuera de la ventana de tolerancia'); }
const payload = typeof rawBody === 'string' ? rawBody : rawBody.toString('utf8'); const expected = crypto .createHmac('sha256', secret) .update(`${t}.${payload}`) .digest('hex');
return crypto.timingSafeEqual( Buffer.from(expected, 'hex'), Buffer.from(v1, 'hex'), );}Modo test vs. live
Section titled “Modo test vs. live”El livemode del endpoint se deriva de la API key con la que se registró:
| API key usada | livemode | Recibe eventos de |
|---|---|---|
sk_test_... | false | Facturas creadas en modo test |
sk_live_... | true | Facturas creadas en modo live |
El aislamiento es garantizado por el sistema: los eventos test nunca llegan a endpoints live, y viceversa.
Gestión del endpoint
Section titled “Gestión del endpoint”Listar y consultar
Section titled “Listar y consultar”# Listar (paginación cursor)curl "https://api.emitia.co/v1/webhook-endpoints?limit=10" \ -H "Authorization: Bearer sk_test_TU_CLAVE_AQUI"
# Consultar unocurl "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.
Actualizar
Section titled “Actualizar”const updated = await emitia.webhookEndpoints.update('we_01HX...', { subscribed_events: ['invoice.accepted_by_dian', 'credit_note.accepted_by_dian'], is_active: true,});Eliminar
Section titled “Eliminar”Hard delete — el endpoint queda permanentemente inhabilitado:
const deleted = await emitia.webhookEndpoints.del('we_01HX...');// { id: 'we_01HX...', object: 'webhook_endpoint', deleted: true }Rotar el secret
Section titled “Rotar el secret”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:
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.
Reintentos y entrega
Section titled “Reintentos y entrega”- 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
2xxdentro 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 200—299 rápido (antes de procesar la lógica de
negocio). Si el procesamiento es lento, responde 200 inmediatamente y encolá
el trabajo asíncronamente.