Contenido de esta página

¿Integras tu propio sistema? Emite los seis comprobantes del SRI desde tu código con la API de Contadeo: idempotencia, webhooks firmados y ambiente de Pruebas que no gasta cupo.

Empezar gratis →

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/:id autenticado, 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 deja req.body como 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 comprobanteId y evento.

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). Reutilizar ch_1PqR2s3t en 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