Webhooks
La API de Emitia es asincrónica: enviar una factura a la DIAN puede tardar desde milisegundos hasta minutos (la DIAN tiene incidentes). Los webhooks son el mecanismo para que tu servidor reciba el resultado sin polling.
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" \ -H "Content-Type: application/json" \ -H "Emitia-Version: 2026-01-01" \ -d '{ "url": "https://tuapp.co/hooks/emitia", "subscribed_events": ["invoice.accepted_by_dian", "invoice.rejected_by_dian"], "description": "Producción" }'La respuesta incluye el secret con prefijo whsec_ — guárdalo de forma segura,
se muestra solo una vez:
{ "id": "we_01HX…", "object": "webhook_endpoint", "url": "https://tuapp.co/hooks/emitia", "subscribed_events": ["invoice.accepted_by_dian", "invoice.rejected_by_dian"], "is_active": true, "livemode": false, "secret": "whsec_...", "created_at": "2026-01-15T10:30:00-05:00"}Estructura del evento
Section titled “Estructura del evento”{ "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/…" } }}Eventos disponibles (21 en total)
Section titled “Eventos disponibles (21 en total)”Emitia emite exactamente 21 event types. Usa estos strings verbatim en
subscribed_events.
Resultados DIAN
Section titled “Resultados DIAN”| Evento | Cuándo |
|---|---|
invoice.accepted_by_dian | DIAN aceptó la factura |
invoice.rejected_by_dian | DIAN rechazó (ver dian_errors en el objeto) |
invoice.accepted_contingency | Emitida en contingencia Tipo 04 |
invoice.failed | Error interno no DIAN |
credit_note.accepted_by_dian | Nota crédito aceptada por DIAN |
credit_note.rejected_by_dian | Nota crédito rechazada por DIAN |
credit_note.accepted_contingency | Nota crédito en contingencia Tipo 04 |
credit_note.failed | Error interno no DIAN |
debit_note.accepted_by_dian | Nota débito aceptada por DIAN |
debit_note.rejected_by_dian | Nota débito rechazada por DIAN |
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”| Evento | Cuándo |
|---|---|
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 |
Headers de entrega
Section titled “Headers de entrega”Cada entrega incluye 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/jsonVerificación de firma HMAC
Section titled “Verificación de firma HMAC”¿Cómo funciona?
Section titled “¿Cómo funciona?”- Emitia toma el timestamp de entrega (
t) y el cuerpo del request. - Concatena:
{t}.{body_raw}. - Calcula HMAC-SHA256 de esa cadena usando tu
whsec_.... - Envía el resultado en el header
Emitia-Signature: t={t},v1={hex}.
Tu servidor debe reproducir el mismo cálculo y comparar con v1 usando comparación
en tiempo constante (para evitar timing attacks).
Snippets de verificación
Section titled “Snippets de verificación”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 { 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': // Actualizar estado en tu base de datos console.log('Factura aceptada:', event.data.object.id); break; case 'invoice.rejected_by_dian': console.error('Factura rechazada:', event.data.object.id); break; }
res.status(200).json({ received: true });}import crypto from 'crypto';
/** * Verifica la firma HMAC del webhook de Emitia. * * @param rawBody - El cuerpo del request como string sin parsear (Buffer.toString()) * @param signature - El valor del header Emitia-Signature * @param secret - El whsec_... del endpoint * @param toleranceSec - Tolerancia en segundos (default: 300 = 5 min) */function verifyEmitiaWebhook( rawBody: string, signature: string, secret: string, toleranceSec = 300,): boolean { // Parsear "t=1716480600,v1=5257a869..." const parts = Object.fromEntries( signature.split(',').map((s) => s.split('=')), ); const t = parseInt(parts['t'] ?? '', 10); const v1 = parts['v1'];
if (!t || !v1) return false;
// Verificar que el timestamp no sea muy antiguo (anti-replay) if (Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
// Calcular HMAC esperado const expected = crypto .createHmac('sha256', secret) .update(`${t}.${rawBody}`) .digest('hex');
// Comparar en tiempo constante (anti-timing attack) return crypto.timingSafeEqual( Buffer.from(expected, 'hex'), Buffer.from(v1, 'hex'), );}
// --- Ejemplo con Express ---import express from 'express';const app = express();
app.post('/hooks/emitia', express.raw({ type: 'application/json' }), (req, res) => { const signature = req.headers['emitia-signature'] as string; const isValid = verifyEmitiaWebhook( req.body.toString(), signature, process.env.EMITIA_WEBHOOK_SECRET!, );
if (!isValid) { return res.status(401).json({ error: 'Firma inválida' }); }
const event = JSON.parse(req.body.toString());
if (event.type === 'invoice.accepted_by_dian') { // Actualizar estado en tu base de datos console.log('Factura aceptada:', event.data.object.id); }
res.status(200).json({ received: true });});<?php
/** * Verifica la firma HMAC del webhook de Emitia. * * @param string $rawBody Cuerpo del request sin parsear * @param string $signature Valor del header Emitia-Signature * @param string $secret whsec_... del endpoint * @param int $toleranceSec Tolerancia en segundos (default: 300 = 5 min) */function verifyEmitiaWebhook( string $rawBody, string $signature, string $secret, int $toleranceSec = 300): bool { // Parsear "t=1716480600,v1=5257a869..." parse_str(str_replace(',', '&', $signature), $parts); $t = isset($parts['t']) ? (int) $parts['t'] : null; $v1 = $parts['v1'] ?? null;
if ($t === null || $v1 === null) { return false; }
// Verificar timestamp (anti-replay) if (abs(time() - $t) > $toleranceSec) { return false; }
// Calcular HMAC esperado $expected = hash_hmac('sha256', "{$t}.{$rawBody}", $secret);
// Comparar en tiempo constante return hash_equals($expected, $v1);}
// --- Ejemplo con un endpoint PHP puro ---$rawBody = file_get_contents('php://input');$signature = $_SERVER['HTTP_EMITIA_SIGNATURE'] ?? '';$secret = getenv('EMITIA_WEBHOOK_SECRET');
if (!verifyEmitiaWebhook($rawBody, $signature, $secret)) { http_response_code(401); echo json_encode(['error' => 'Firma inválida']); exit;}
$event = json_decode($rawBody, true);
if ($event['type'] === 'invoice.accepted_by_dian') { $invoiceId = $event['data']['object']['id']; // Actualizar estado en tu base de datos error_log("Factura aceptada: {$invoiceId}");}
http_response_code(200);echo json_encode(['received' => true]);import hashlibimport hmacimport jsonimport osimport time
def verify_emitia_webhook( raw_body: bytes, signature: str, secret: str, tolerance_sec: int = 300,) -> bool: """ Verifica la firma HMAC del webhook de Emitia.
Args: raw_body: Cuerpo del request como bytes (sin decodificar) signature: Valor del header Emitia-Signature secret: whsec_... del endpoint tolerance_sec: Tolerancia en segundos para el timestamp """ # Parsear "t=1716480600,v1=5257a869..." parts = dict(item.split("=", 1) for item in signature.split(",")) t = int(parts.get("t", 0)) v1 = parts.get("v1", "")
if not t or not v1: return False
# Verificar timestamp (anti-replay) if abs(time.time() - t) > tolerance_sec: return False
# Calcular HMAC esperado payload = f"{t}.{raw_body.decode('utf-8')}" expected = hmac.new( secret.encode("utf-8"), payload.encode("utf-8"), hashlib.sha256, ).hexdigest()
# Comparar en tiempo constante return hmac.compare_digest(expected, v1)
# --- Ejemplo con FastAPI ---from fastapi import FastAPI, Header, HTTPException, Request
app_api = FastAPI()
@app_api.post("/hooks/emitia")async def webhook( request: Request, emitia_signature: str = Header(alias="emitia-signature"),): raw_body = await request.body() secret = os.environ["EMITIA_WEBHOOK_SECRET"]
if not verify_emitia_webhook(raw_body, emitia_signature, secret): raise HTTPException(status_code=401, detail="Firma inválida")
event = json.loads(raw_body)
if event["type"] == "invoice.accepted_by_dian": invoice_id = event["data"]["object"]["id"] print(f"Factura aceptada: {invoice_id}")
return {"received": True}Política de reintentos
Section titled “Política de reintentos”Si tu endpoint responde con un código distinto de 2xx, Emitia reintentará la entrega
con backoff exponencial:
| Intento | Espera |
|---|---|
| 1 | Inmediato |
| 2 | ~30 segundos |
| 3 | ~5 minutos |
| 4 | ~30 minutos |
Próximos pasos
Section titled “Próximos pasos”- Conceptos: Idempotencia — para las peticiones que crean facturas
- Conceptos: Ambientes — webhooks en sandbox vs producción
- Guías: Webhooks — registro de endpoint, rotación de secret y gestión completa