Webhooks
Los webhooks permiten que Host Factura notifique a tu sistema cuando ocurre algo relevante (un CFE
emitido, la respuesta de DGI, un cobro confirmado, una suscripción que declina) sin que tengas que hacer
polling. Tu servidor recibe un POST con el payload firmado; vos respondés 2xx en menos de 10 segundos.
Suscripciones
Sección titulada «Suscripciones»Se crean desde el panel (Configuración → Webhooks). Por API (alcance webhooks:gestionar, sólo para
cuentas proveedoras) se pueden listar, probar y dar de baja:
GET /api/v1/webhooks → lista (nunca incluye el secret)POST /api/v1/webhooks/{id}/probar → 202, entrega de prueba (un solo intento)DELETE /api/v1/webhooks/{id} → 204, baja lógicaNo hay edición: para cambiar una suscripción, se da de baja y se crea de nuevo desde el panel, lo que de paso
rota el secreto. El DELETE es baja lógica (las entregas ya hechas la referencian y el historial tiene
que sobrevivir).
Cada suscripción define:
| Campo | Descripción |
|---|---|
url | URL de destino. HTTPS obligatorio. Máx. 2048 caracteres. |
eventos | Lista de tipos de evento a recibir (al menos uno). Ver Eventos disponibles. |
sucursalId | null = todos los eventos de la cuenta. UUID = solo eventos de esa sucursal. |
descripcion | Etiqueta libre (máx. 255 caracteres). |
activo | true / false. Una suscripción inactiva no recibe entregas. |
incluirMetadata | true / false (default false). Adjunta los metadatos personalizados del CFE en el evento cfe.emitido. Ver Metadatos en el evento cfe.emitido. |
Límite: máximo 20 suscripciones activas por cuenta.
Estructura del evento (envelope)
Sección titulada «Estructura del evento (envelope)»Todos los eventos comparten el mismo envelope:
{ "id": "019612e4-...", "tipo": "cfe.emitido", "creadoEn": "2025-11-14T10:30:00.000-03:00", "cuentaId": "uuid-de-la-cuenta", "sucursalId": "uuid-de-la-sucursal", "datos": { ... }}| Campo | Tipo | Descripción |
|---|---|---|
id | UUID v7 | Identificador estable del evento; igual en todos los reintentos de la misma entrega. |
tipo | string | Tipo de evento (ver tabla de eventos). |
creadoEn | ISO 8601 | Timestamp con offset America/Montevideo. |
cuentaId | UUID | Cuenta que originó el evento. |
sucursalId | UUID | null | Sucursal del evento, o null para eventos de cuenta entera. |
datos | object | Payload específico según el tipo de evento. |
Eventos disponibles
Sección titulada «Eventos disponibles»CFE emitido
Sección titulada «CFE emitido»Tipo: cfe.emitido — Se dispara cuando un CFE es emitido exitosamente.
{ "cfeId": "uuid", "tipo": 101, "serie": "A", "numero": 1234, "estadoDgi": "AC", "codigoSucursalDgi": 1}codigoSucursalDgi es el código de sucursal ante DGI (CdgDGISucur); no confundir con sucursalId del
envelope, que es el UUID interno de Host Factura.
Metadatos en el evento cfe.emitido
Sección titulada «Metadatos en el evento cfe.emitido»Si tu suscripción tiene incluirMetadata: true, el payload de cfe.emitido (y solo ese evento —
cfe.actualizado nunca lo incluye) agrega el campo metadata con los
metadatos personalizados capturados al emitir:
{ "cfeId": "uuid", "tipo": 101, "serie": "A", "numero": 1234, "estadoDgi": "AC", "codigoSucursalDgi": 1, "metadata": { "numero_transaccion": "TXN-8827461", "metodo_pago": "tarjeta", "referencia_externa": "sub_9F2K1" }}CFE actualizado
Sección titulada «CFE actualizado»Tipo: cfe.actualizado — Se dispara cuando cambia el estado de un CFE ante DGI (por ejemplo, cuando
llega la respuesta definitiva de aceptación o rechazo).
{ "cfeId": "uuid", "tipo": 101, "serie": "A", "numero": 1234, "estadoDgi": "AC", "codigoSucursalDgi": 1, "estado": "aceptado", "estadoSobre": "...", "motivoDgi": null}Campo estado | Significado |
|---|---|
aceptado | DGI aceptó el CFE. |
rechazado | DGI lo rechazó (ver motivoDgi). |
observado | DGI lo observó (ver motivoDgi). |
pendiente | Aceptado localmente, respuesta definitiva de DGI pendiente. |
CFE recibido creado
Sección titulada «CFE recibido creado»Tipo: cfe_recibido.creado — Se dispara cuando se registra un CFE recibido de otro emisor (intercambio electrónico).
{ "cfeRecibidoId": "uuid", "tipo": 101, "serie": "A", "numero": 5000, "rutEmisor": "21234567890", "moneda": "UYU", "total": 1220.00, "pendienteAceptacion": true, "aceptadoComercial": null, "estadoDgi": "A", "anulado": false}CFE recibido actualizado
Sección titulada «CFE recibido actualizado»Tipo: cfe_recibido.actualizado — Se dispara cuando cambia el estado comercial o de DGI de un CFE recibido.
{ "cfeRecibidoId": "uuid", "tipo": 101, "serie": "A", "numero": 5000, "rutEmisor": "21234567890", "moneda": "UYU", "total": 1220.00, "pendienteAceptacion": false, "aceptadoComercial": true, "estadoDgi": "A", "anulado": false, "tipoActualizacion": "comercial"}Campo tipoActualizacion | Cuándo se produce |
|---|---|
"comercial" | El receptor aceptó o rechazó el CFE comercialmente. |
"dgi" | DGI actualizó el estado del CFE recibido. |
El cfeRecibidoId de los dos eventos es el {id} de las operaciones de CFE recibidos:
con él traés el detalle (GET /v1/cfe/recibidos/{id}), el PDF (/pdf), los datos de impresión
(/representacion-impresa) y el XML firmado (/xml). La credencial necesita el alcance recibidos:leer.
Reporte diario
Sección titulada «Reporte diario»Tipos: reporte_diario.aceptado, reporte_diario.rechazado, reporte_diario.error
{ "reporteDiarioId": "uuid", "fecha": "2025-11-14", "estado": "aceptado", "nombreArchivo": "reporte_20251114.xml"}Alertas operativas
Sección titulada «Alertas operativas»CAE por vencer
Sección titulada «CAE por vencer»Tipo: cae.por_vencer — Se dispara cuando un CAE está próximo a su fecha de vencimiento.
{ "caeId": "uuid", "tipoCfe": 101, "serie": "A", "numeroDesde": 1, "numeroHasta": 999, "fechaVencimiento": "2026-01-31", "diasRestantes": 15}CAE por agotarse
Sección titulada «CAE por agotarse»Tipo: cae.por_agotarse — Se dispara cuando el stock de numeración de un CAE está casi agotado.
{ "caeId": "uuid", "tipoCfe": 101, "serie": "A", "numeroDesde": 1, "numeroHasta": 999, "porcentajeUsado": 92.5, "restantes": 75}Certificado por vencer
Sección titulada «Certificado por vencer»Tipo: certificado.por_vencer — Se dispara cuando el certificado digital (CVA) está próximo a vencer.
{ "cvaId": "uuid", "ruc": "21234567890", "fechaVencimiento": "2026-03-15", "diasRestantes": 30}Recurrente emitido
Sección titulada «Recurrente emitido»Tipo: recurrente.emitido — Se dispara cuando se emite automáticamente un CFE de una plantilla
recurrente.
{ "cfeId": "uuid", "tipo": 101, "serie": "A", "numero": 1234}Eventos de cobros
Sección titulada «Eventos de cobros»Disponibles en las cuentas con el módulo de cobros habilitado.
| Tipo | Qué significa |
|---|---|
pago.confirmado | La plata entró. Es el evento con el que se entrega el producto |
pago.fallido | Rechazo definitivo. No hay plata |
pago.anulado | Reversa total o parcial. origen: "proveedor" = se hizo fuera de Host Factura |
pago.facturado | Salió el comprobante fiscal del cobro. diferencia distinta de "0.00" = no cuadra con el importe cobrado |
pago.pendiente_facturacion | Plata cobrada SIN comprobante. Alerta operativa |
pago.en_verificacion | Estado ambiguo: el cobro puede estar hecho. Ver abajo |
link.anulado | El link se anuló antes de cobrarse |
link.expirado | El link venció sin cobrarse (lo marca el job de expiración) |
suscripcion.cobro_exitoso | Se cobró un período. periodo es su clave de deduplicación |
suscripcion.cobro_fallido | Declinó un período. politicaCobroFallido dice qué se hizo con la facturación |
suscripcion.pausada | Dejó de cobrar períodos |
suscripcion.reanudada | Volvió a cobrar |
suscripcion.cancelada | Baja definitiva. recurrenteDeshabilitado indica si se apagó la recurrente vinculada |
tarjeta.enrolada | Se guardó una tarjeta del cliente |
tarjeta.baja | Se dio de baja una tarjeta |
pago.confirmado
Sección titulada «pago.confirmado»{ "pagoId": "9b1c3d4e-5f60-4a7b-8c9d-0e1f2a3b4c5d", "solicitudId": "8f14e45f-ceea-467a-9f5a-2b1c3d4e5f60", "clienteId": "7e6d5c4b-3a2f-4e1d-9c8b-7a6f5e4d3c2b", "monto": "1234.56", "moneda": "UYU", "canal": "link", "paymentToken": "pt_abc123", "authorizationCode": "123456", "marca": "VISA", "mascara": "450995******3345"}pago.facturado
Sección titulada «pago.facturado»{ "pagoId": "9b1c3d4e-5f60-4a7b-8c9d-0e1f2a3b4c5d", "politicaFiscal": "facturar_al_cobrar", "cfeEmitidoId": "1f2a3b4c-5d6e-4f7a-8b9c-0d1e2f3a4b5c", "tipo": 101, "serie": "A", "numero": 1042, "total": "1234.56", "moneda": "UYU", "montoCobrado": "1234.56", "diferencia": "0.00"}pago.pendiente_facturacion
Sección titulada «pago.pendiente_facturacion»{ "pagoId": "9b1c3d4e-5f60-4a7b-8c9d-0e1f2a3b4c5d", "monto": "1234.56", "moneda": "UYU", "intentos": 3, "reintentable": false, "error": "No hay CAE vigente para el tipo 101 serie A"}pago.en_verificacion
Sección titulada «pago.en_verificacion»{ "solicitudId": "8f14e45f-ceea-467a-9f5a-2b1c3d4e5f60", "monto": "1234.56", "moneda": "UYU", "canal": "tarjeta_registrada", "etapa": "captura", "httpStatusProveedor": 504, "idempotencyKey": "orden-12345-cobro-1"}tarjeta.enrolada
Sección titulada «tarjeta.enrolada»{ "id": "3a2f1e0d-9c8b-4a7f-8e6d-5c4b3a2f1e0d", "clienteId": "7e6d5c4b-3a2f-4e1d-9c8b-7a6f5e4d3c2b", "mascara": "589656******2345", "brandCode": "CABAL", "brand": "Cabal", "dueDate": "0930", "recurringEligible": true, "activo": true}recurringEligible: false significa que la tarjeta no sirve para cobro desatendido: una suscripción con
esa tarjeta declina en cada período. Nunca viaja el PAN completo ni el CVV.
El payload de cada evento, tipado y con ejemplo, está en la Referencia OpenAPI.
Headers de la entrega
Sección titulada «Headers de la entrega»Cada POST que Host Factura envía a tu endpoint incluye los siguientes headers:
| Header | Descripción |
|---|---|
Content-Type | application/json |
User-Agent | HostFactura-Webhooks/1.0 |
X-HostFactura-Event | Tipo del evento (ej. cfe.emitido). |
X-HostFactura-Event-Id | UUID del evento (estable entre reintentos). |
X-HostFactura-Delivery | UUID único del intento de entrega. |
X-HostFactura-Attempt | Número de intento (empieza en 1). |
X-HostFactura-Signature | Firma HMAC-SHA256 del cuerpo. Ver Verificar la firma. |
Verificar la firma
Sección titulada «Verificar la firma»Cada entrega incluye el header X-HostFactura-Signature con el formato:
t=1731574200,v1=a3f8c...Donde t es un timestamp Unix (segundos) y v1 es el HMAC-SHA256 en hexadecimal.
Para verificar:
- Extraé el timestamp
ty el valorv1del header. - Armá el string a firmar:
"<t>.<cuerpo raw del POST>". - Calculá el HMAC-SHA256 sobre ese string usando tu
secrettal cual, como texto. - Comparalo con
v1usando comparación en tiempo constante. - Verificá que el timestamp no sea más antiguo de 5 minutos (protección contra replay).
import express from "express";import { createHmac, timingSafeEqual } from "node:crypto";
const app = express();const SECRET = process.env.HOSTFACTURA_WEBHOOK_SECRET;const TOLERANCIA_SEGUNDOS = 300;
// `raw`, no `json`: la firma se calcula sobre los bytes exactos que llegaron.app.post("/hooks/hostfactura", express.raw({ type: "application/json" }), (req, res) => { const rawBody = req.body.toString("utf8");
if (!verificarFirma(rawBody, req.get("X-HostFactura-Signature"), SECRET)) { return res.sendStatus(401); }
const evento = JSON.parse(rawBody);
// 2xx primero, trabajo después: el timeout de entrega es de 10 segundos. res.sendStatus(200); encolar(evento).catch((e) => console.error("webhook", evento.id, e));});
function verificarFirma(rawBody, header, secret) { if (!header) return false;
const partes = Object.fromEntries( header.split(",").map((p) => { const i = p.indexOf("="); return [p.slice(0, i).trim(), p.slice(i + 1).trim()]; }) ); const { t, v1 } = partes; if (!t || !v1) return false;
// El secret va TAL CUAL: no se decodifica de base64url. const esperado = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
const a = Buffer.from(esperado, "hex"); const b = Buffer.from(v1, "hex"); if (a.length !== b.length || !timingSafeEqual(a, b)) return false;
const edad = Math.floor(Date.now() / 1000) - Number.parseInt(t, 10); return Number.isFinite(edad) && edad >= -60 && edad < TOLERANCIA_SEGUNDOS;}<?phpdeclare(strict_types=1);
const TOLERANCIA_SEGUNDOS = 300;
function verificarFirmaHostFactura(string $rawBody, ?string $header, string $secret): bool{ if ($header === null || $header === '') { return false; }
$partes = []; foreach (explode(',', $header) as $pieza) { $pos = strpos($pieza, '='); if ($pos === false) { continue; } $partes[trim(substr($pieza, 0, $pos))] = trim(substr($pieza, $pos + 1)); }
if (!isset($partes['t'], $partes['v1'])) { return false; }
// El secret va TAL CUAL: NO usar base64_decode(). $esperado = hash_hmac('sha256', $partes['t'] . '.' . $rawBody, $secret);
if (!hash_equals($esperado, $partes['v1'])) { return false; }
$edad = time() - (int) $partes['t'];
return $edad >= -60 && $edad < TOLERANCIA_SEGUNDOS;}
$rawBody = file_get_contents('php://input');$firma = $_SERVER['HTTP_X_HOSTFACTURA_SIGNATURE'] ?? null;$secret = getenv('HOSTFACTURA_WEBHOOK_SECRET') ?: '';
if (!verificarFirmaHostFactura($rawBody, $firma, $secret)) { http_response_code(401); exit;}
$evento = json_decode($rawBody, true, 512, JSON_THROW_ON_ERROR);
// 2xx primero; el trabajo va a una cola.http_response_code(200);if (function_exists('fastcgi_finish_request')) { fastcgi_finish_request();}
encolarEvento($evento);Reintentos y backoff
Sección titulada «Reintentos y backoff»Si tu endpoint no responde 2xx en 10 segundos, o devuelve un error HTTP, Host Factura reintenta con
backoff exponencial:
| Intento | Espera aprox. |
|---|---|
| 1 | 5 segundos |
| 2 | 30 segundos |
| 3 | 5 minutos |
| 4 | 30 minutos |
| 5 | 2 horas |
| 6 | 12 horas |
Después del intento 6, la entrega queda en estado DEAD y no se reintenta automáticamente. Podés reintentarla manualmente desde el panel (Configuración → Webhooks → Deliveries).
Un 3xx cuenta como fallo: no se siguen redirects (seguirlos sería perder la firma y habilitar SSRF por
Location). Configurá la URL final, no una que redirija.
Idempotencia del receptor
Sección titulada «Idempotencia del receptor»Deduplicá por X-HostFactura-Event-Id (= id del envelope), insertándolo antes de procesar:
CREATE TABLE webhooks_recibidos ( event_id uuid PRIMARY KEY, tipo text NOT NULL, recibido_en timestamptz NOT NULL DEFAULT now(), procesado_en timestamptz);const { rowCount } = await db.query( "INSERT INTO webhooks_recibidos (event_id, tipo) VALUES ($1, $2) ON CONFLICT DO NOTHING", [evento.id, evento.tipo]);if (rowCount === 0) return; // ya visto: es un reintentoDos cosas que no sirven como clave: X-HostFactura-Delivery (cambia en cada intento) y el contenido de
datos (dos eventos legítimos pueden traer casi lo mismo).
Probar un webhook
Sección titulada «Probar un webhook»POST /api/v1/webhooks/{id}/probarEncola una entrega sintética webhook.test para verificar URL, firma y conectividad de punta a punta antes
de que ocurra un hecho real. También se puede disparar desde el panel, en el detalle de la suscripción.
Responde 202: la entrega la hace el dispatcher en su próximo tick, no ese request. Se envía con un solo intento (una prueba que reintenta con backoff durante 12 horas no prueba nada) y cada prueba es un evento nuevo, así que no se deduplica contra la anterior.
El payload tiene la misma estructura de envelope que los eventos reales, con datos mínimo — y su forma no
es un contrato.
Consideraciones de seguridad
Sección titulada «Consideraciones de seguridad»- Solo HTTPS. No se aceptan URLs con protocolo
http://. - Sin redirecciones. Host Factura no sigue redirects; si tu URL redirige, la entrega falla.
- Timeout de 10 s. Tu endpoint debe responder en menos de 10 segundos. Procesá el evento de forma
asíncrona si necesitás más tiempo; respondé
200de inmediato y encolá el trabajo. - IPs privadas bloqueadas. No se aceptan URLs que resuelvan a rangos RFC 1918 (10.x, 172.16-31.x, 192.168.x), loopback ni metadata de nube (169.254.169.254).
Errores de la gestión
Sección titulada «Errores de la gestión»| Code | HTTP | Cuándo |
|---|---|---|
WEBHOOK_NO_ENCONTRADO | 404 | No existe esa suscripción en tu cuenta |
Más los errores globales, incluido
403 API_ALCANCE_INSUFICIENTE si la credencial no tiene webhooks:gestionar, y
403 API_ALCANCE_NO_DISPONIBLE si la cuenta de la credencial no es proveedora.
El historial de entregas (deliveries), con su estado, intentos y último error, se consulta desde el panel (Configuración → Webhooks → Deliveries), que es también desde donde se reintenta una entrega
DEAD.