Webhooks firmados: cómo enterarte de que el SRI autorizó, sin hacer polling
El SRI suele resolver una factura entre 5 y 30 segundos después de recibirla.
Consultar GET /comprobantes/:id cada tres segundos funciona mientras emites
diez facturas al día; con quinientas, tu integración pasa el día preguntando por
comprobantes que ya están autorizados.
La alternativa es el webhook: registras una URL HTTPS y Contadeo te hace un
POST cuando el comprobante llega a un estado final. Este artículo cubre qué
llega exactamente en ese POST, cómo se verifica la firma HMAC sobre el cuerpo
crudo, qué frena el reenvío de una entrega capturada, qué pasa cuando tu servidor
no contesta, cómo rotar el secreto y por qué el payload no lleva ni el nombre ni
el correo del comprador.
Ojo: el webhook no trae datos personales del comprador. Solo identificadores, clave de acceso y estado. Si tu sistema necesita el nombre o el correo para avisar a alguien, sale de tu propia base o de un
GET /comprobantes/:idautenticado, no del cuerpo del webhook.
Qué llega exactamente en el POST
Cada entrega es un POST con content-type: application/json y dos cabeceras
propias: el evento y la firma.
POST https://miapp.com/contadeo/webhook
x-contadeo-evento: comprobante.autorizado
X-Contadeo-Signature: t=1787000000000,v1=6f1a…
{
"evento": "comprobante.autorizado",
"ts": 1787000000000,
"tenantId": "…",
"comprobanteId": "…",
"claveAcceso": "49 dígitos",
"tipoComprobante": "01",
"estado": "AUTORIZADO",
"numeroAutorizacion": "…"
}
Los eventos disponibles son comprobante.autorizado, comprobante.rechazado,
comprobante.devuelto y f104.listo (el borrador del F104 del período quedó
listo, con las cifras congeladas y su vencimiento).
El alta es POST /webhooks con {url, eventos?, descripcion?}. Si omites
eventos, te suscribes a todo el catálogo vigente en ese momento: el array
se materializa al crear el webhook. Por eso un webhook guardado antes de que
existiera f104.listo no lo recibe hasta que lo actives con un PATCH. Si solo
te interesan los estados de comprobante, decláralos explícitos.
| Límite | Valor |
|---|---|
| Endpoints por cuenta | 5 |
| Esquema | Solo HTTPS |
| Rol de la API key | webhooks (opt-in al crear la key) |
La firma se verifica sobre el cuerpo crudo, nunca sobre el JSON reparseado
La cabecera tiene la forma X-Contadeo-Signature: t=<timestamp>,v1=<hex>, donde
hex = HMAC_SHA256(secret, "<timestamp>.<cuerpo crudo>")
El secreto (whsec_…) viaja solo en la respuesta del alta del webhook. Y el
cuerpo tiene que ser el crudo: un JSON.parse seguido de JSON.stringify puede
reordenar las claves y la firma deja de coincidir.
import { createHmac, timingSafeEqual } from "node:crypto";
function verificar(secret, firma, cuerpoCrudo) {
const { t, v1 } = Object.fromEntries(
firma.split(",").map((p) => p.split("=")),
);
const esperado = createHmac("sha256", secret)
.update(`${t}.${cuerpoCrudo}`)
.digest("hex");
return timingSafeEqual(Buffer.from(v1, "hex"), Buffer.from(esperado, "hex"));
}
La comparación va en tiempo constante con timingSafeEqual. Un === filtraría
por timing cuántos bytes de la firma acertó quien la está adivinando.
Consejo: en Express, monta la ruta con
express.raw({ type: "application/json" })antes de cualquier parser de JSON. Es el error más común de la primera integración: el middleware global se come el cuerpo y dejareq.bodycomo objeto, así que la firma no valida nunca.
El timestamp firmado es lo que frena el reenvío de una entrega capturada
El t de la cabecera no es decorativo: entra en la cadena que se firma. Eso
significa que quien capture una entrega válida no puede cambiarle la fecha sin
romper la firma, y tú puedes rechazar todo lo que llegue con un t demasiado
lejos de tu reloj.
El SDK oficial contadeo-sdk trae esa verificación hecha en
verificarFirmaWebhook(), con una ventana de tolerancia de 300 segundos por
defecto y ajustable. Fuera de esa ventana la entrega se rechaza aunque la firma
sea correcta. No se pierden avisos por eso: los reintentos se firman con un
timestamp nuevo cada vez.
Si tu servidor no responde 2xx, insistimos ocho veces
Cada entrega se intenta hasta 8 veces con backoff exponencial desde 5 segundos: 5, 10, 20, 40, 80, 160 y 320 segundos entre reintentos. La ventana completa son unos once minutos, no horas.
Dos consecuencias de diseño para tu endpoint:
- Responde 2xx rápido y procesa después. El timeout de cada intento es de 10 segundos. Si tu handler emite un correo, actualiza una suscripción y escribe en tres tablas antes de contestar, vas a comerte reintentos por lentitud.
- Asume entregas repetidas. Un 2xx que llega tarde o una respuesta perdida
producen el mismo evento dos veces. Deduplica por
comprobanteIdyevento.
El historial está en GET /webhooks/entregas?limit=: estado de cada entrega,
número de intentos, código HTTP devuelto y último error. Es lo primero que hay
que mirar cuando "el webhook no llega", porque casi siempre sí llegó y devolvió
un 500.
Rotar el secreto es inmediato, y por eso se planifica
POST /webhooks/:id/rotar-secreto devuelve un secreto nuevo y el anterior deja
de firmar al instante. No hay período de gracia con dos secretos válidos, así
que el orden importa: primero deja tu verificador leyendo el secreto desde una
variable que puedas cambiar en caliente, después rota, después actualiza el
valor. Si lo haces al revés, las entregas de ese minuto llegan con una firma que
tu servidor no reconoce y se van a reintentos.
PATCH /webhooks/:id con {activo: false} pausa las entregas sin borrar el
endpoint, que es lo que quieres durante un despliegue largo. DELETE es baja
inmediata.
Por qué el payload no lleva datos del comprador
Es una decisión de protección de datos, no un olvido. El aviso de autorización
viaja a un servidor tuyo, por internet, y se reintenta hasta ocho veces dejando
registro de cada intento. Meter ahí el nombre, la cédula o el correo del
comprador multiplicaría por ocho los sitios donde ese dato queda escrito, sin
que haga falta para nada: el comprobanteId te deja consultar el comprobante
completo cuando lo necesites, autenticado y contra tu cuenta.
Si lo que quieres es que el comprador reciba su factura, no hace falta que la
mandes tú: pon su correo en infoAdicional al emitir y el RIDE en PDF y el XML
le llegan solos al autorizarse.
"infoAdicional": [{ "nombre": "Email", "valor": "cliente@correo.com" }]
Idempotency-Key = el id del cobro, y no duplicas facturas
El otro patrón que hace falta en un SaaS es el inverso al webhook: que tu sistema no emita dos veces la misma factura cuando la pasarela reintenta el aviso de cobro o tu worker se reinicia a mitad del request.
La receta es una línea: usa el id del cobro como Idempotency-Key.
curl -X POST https://contadeo.com/api/comprobantes/factura \
-H "Authorization: Bearer cdo_…" \
-H "Idempotency-Key: ch_1PqR2s3t" \
-d @factura.json
Lo que hace que funcione:
- Sin caducidad. La clave vive con el comprobante. Un reintento de hace un
mes sigue devolviendo el original, con
reutilizado: true. - Alcance por
(cuenta, emisor, tipo de comprobante). Reutilizarch_1PqR2s3ten la factura y en su nota de crédito no colisiona: son tipos distintos. - Misma clave con cuerpo distinto → 422. El payload se huella con SHA-256. Si la clave llega con otro contenido, la API rechaza en vez de devolverte un comprobante que no corresponde.
Preguntas frecuentes
¿Puedo usar webhooks y polling a la vez? Sí, y para arrancar es lo razonable: el webhook como camino normal y una consulta de respaldo para los comprobantes que lleven demasiado tiempo sin estado final. Ninguno de los dos excluye al otro.
¿Qué API key necesito para registrar un webhook?
Una con el rol webhooks, que es opt-in al crearla. Sin ese rol, la key emite
pero no gestiona endpoints. También se administran desde el panel, en
Configuración → Equipo → Webhooks.
¿Y si mi servidor estuvo caído más de once minutos?
Los reintentos se agotan y la entrega queda como fallida en
GET /webhooks/entregas. Recupera esos comprobantes con
GET /comprobantes?estado= y tu propia marca de qué ya procesaste: por eso vale
la pena guardar el comprobanteId de cada emisión desde el 202.
¿El webhook sirve en el ambiente de Pruebas? Sí. Es la forma más cómoda de probar el circuito completo antes de pasar a Producción, y los comprobantes de Pruebas no consumen el cupo de tu plan.
Cómo empezar
Crea tu cuenta gratis, genera una API key con el rol webhooks en
Configuración → API y registra tu endpoint en Pruebas. La
documentación para integradores trae la sección de webhooks
completa con los eventos y los límites, y la
matriz de capacidades resume lo que aplica la plataforma de
serie.
Contadeo — Facturación electrónica del SRI (Ecuador) por API, panel o IA. Documentación de la API · Integraciones · Crea tu cuenta