Saltearse al contenido

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.

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ógica

No 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:

CampoDescripción
urlURL de destino. HTTPS obligatorio. Máx. 2048 caracteres.
eventosLista de tipos de evento a recibir (al menos uno). Ver Eventos disponibles.
sucursalIdnull = todos los eventos de la cuenta. UUID = solo eventos de esa sucursal.
descripcionEtiqueta libre (máx. 255 caracteres).
activotrue / false. Una suscripción inactiva no recibe entregas.
incluirMetadatatrue / 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.

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": { ... }
}
CampoTipoDescripción
idUUID v7Identificador estable del evento; igual en todos los reintentos de la misma entrega.
tipostringTipo de evento (ver tabla de eventos).
creadoEnISO 8601Timestamp con offset America/Montevideo.
cuentaIdUUIDCuenta que originó el evento.
sucursalIdUUID | nullSucursal del evento, o null para eventos de cuenta entera.
datosobjectPayload específico según el tipo de evento.

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.

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"
}
}

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 estadoSignificado
aceptadoDGI aceptó el CFE.
rechazadoDGI lo rechazó (ver motivoDgi).
observadoDGI lo observó (ver motivoDgi).
pendienteAceptado localmente, respuesta definitiva de DGI pendiente.

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
}

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 tipoActualizacionCuá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.


Tipos: reporte_diario.aceptado, reporte_diario.rechazado, reporte_diario.error

{
"reporteDiarioId": "uuid",
"fecha": "2025-11-14",
"estado": "aceptado",
"nombreArchivo": "reporte_20251114.xml"
}

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
}

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
}

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
}

Tipo: recurrente.emitido — Se dispara cuando se emite automáticamente un CFE de una plantilla recurrente.

{
"cfeId": "uuid",
"tipo": 101,
"serie": "A",
"numero": 1234
}

Disponibles en las cuentas con el módulo de cobros habilitado.

TipoQué significa
pago.confirmadoLa plata entró. Es el evento con el que se entrega el producto
pago.fallidoRechazo definitivo. No hay plata
pago.anuladoReversa total o parcial. origen: "proveedor" = se hizo fuera de Host Factura
pago.facturadoSalió el comprobante fiscal del cobro. diferencia distinta de "0.00" = no cuadra con el importe cobrado
pago.pendiente_facturacionPlata cobrada SIN comprobante. Alerta operativa
pago.en_verificacionEstado ambiguo: el cobro puede estar hecho. Ver abajo
link.anuladoEl link se anuló antes de cobrarse
link.expiradoEl link venció sin cobrarse (lo marca el job de expiración)
suscripcion.cobro_exitosoSe cobró un período. periodo es su clave de deduplicación
suscripcion.cobro_fallidoDeclinó un período. politicaCobroFallido dice qué se hizo con la facturación
suscripcion.pausadaDejó de cobrar períodos
suscripcion.reanudadaVolvió a cobrar
suscripcion.canceladaBaja definitiva. recurrenteDeshabilitado indica si se apagó la recurrente vinculada
tarjeta.enroladaSe guardó una tarjeta del cliente
tarjeta.bajaSe dio de baja una tarjeta
{
"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"
}
{
"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"
}
{
"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"
}
{
"solicitudId": "8f14e45f-ceea-467a-9f5a-2b1c3d4e5f60",
"monto": "1234.56",
"moneda": "UYU",
"canal": "tarjeta_registrada",
"etapa": "captura",
"httpStatusProveedor": 504,
"idempotencyKey": "orden-12345-cobro-1"
}
{
"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.


Cada POST que Host Factura envía a tu endpoint incluye los siguientes headers:

HeaderDescripción
Content-Typeapplication/json
User-AgentHostFactura-Webhooks/1.0
X-HostFactura-EventTipo del evento (ej. cfe.emitido).
X-HostFactura-Event-IdUUID del evento (estable entre reintentos).
X-HostFactura-DeliveryUUID único del intento de entrega.
X-HostFactura-AttemptNúmero de intento (empieza en 1).
X-HostFactura-SignatureFirma HMAC-SHA256 del cuerpo. Ver 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:

  1. Extraé el timestamp t y el valor v1 del header.
  2. Armá el string a firmar: "<t>.<cuerpo raw del POST>".
  3. Calculá el HMAC-SHA256 sobre ese string usando tu secret tal cual, como texto.
  4. Comparalo con v1 usando comparación en tiempo constante.
  5. Verificá que el timestamp no sea más antiguo de 5 minutos (protección contra replay).
server.js
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;
}

Si tu endpoint no responde 2xx en 10 segundos, o devuelve un error HTTP, Host Factura reintenta con backoff exponencial:

IntentoEspera aprox.
15 segundos
230 segundos
35 minutos
430 minutos
52 horas
612 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.

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 reintento

Dos 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).

POST /api/v1/webhooks/{id}/probar

Encola 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.

  • 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é 200 de 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).
CodeHTTPCuándo
WEBHOOK_NO_ENCONTRADO404No 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.