Guía
Autoservicio en el portalWebhooks
En vez de pollear GET /dte/{id} hasta que el estado se estabilice, recibís un POST firmado en tu servidor cada vez que pasa algo: un documento cambia de estado ante el SII, llega un DTE de un proveedor, o se te están acabando los folios.
Ya no hay que escribir a soporte para dar de alta un webhook. Todo —crear la suscripción, elegir eventos, generar y rotar el secreto, probar el endpoint, ver el historial de cada intento y reintentar lo que falló— se hace en Portal → Webhooks.
Lo que sigue siendo por pantalla y no por código: no hay rutas bajo /api/public/v1/ para administrar suscripciones. Tu integración recibe webhooks; darlos de alta es una tarea de configuración, no de runtime.
Si un aviso no llega —porque tu endpoint estuvo caído más de 31 horas, o porque la suscripción se apagó sola— el estado real sigue estando en GET /dte/{id}. Un receptor bien hecho trata el webhook como “llegó antes” y deja el polling como red de seguridad.
Catálogo de eventos
Son 17 tipos y el catálogo es cerrado: un tipo que no esté acá no se publica. Al suscribirte elegís una lista de tipos, o * para recibir todos, incluidos los que agreguemos más adelante. Los nombres siguen la forma dominio.hecho y van en pasado: el webhook informa algo que ya ocurrió. Agregar un tipo nuevo es retrocompatible; renombrar uno no lo es, y por eso no lo hacemos sin versionar.
Emisión
El documento propio, antes de que el SII opine. Sirven para numerar la venta sin esperar el ciclo tributario completo.
| tipo | Cuándo se dispara | Qué trae en datos |
|---|---|---|
| dte.emitido | El documento existe: folio asignado, XML firmado y persistido. Se publica antes de ir al SII. | documentoId, tipoDte, folio, montoTotal, fechaEmision, rutReceptor y razonSocialReceptor. estadoSii viene 0 (Pendiente) y trackId null: todavía no hay sobre. En una NC/ND llega además la referencia (codRef + documentoReferenciado). |
| dte.anulado | Se anuló un documento con una nota de crédito 61 (codRef 1) y el original quedó marcado como anulado. | El documento ORIGINAL, con estadoSii 5 (Anulado). codRef y documentoReferenciado llegan en null —ver el aviso de abajo— y el folio de la nota de crédito va en detalle, legible. |
Ciclo SII
El recorrido del documento ante el SII. Son los que reemplazan al polling: en vez de repetir la consulta, esperás el aviso.
| tipo | Cuándo se dispara | Qué trae en datos |
|---|---|---|
| dte.enviado | El SII recibió el sobre y devolvió trackId. A partir de acá conviene esperar el estado final. | trackId ya con valor y estadoSii 1 (Enviado). |
| dte.aceptado | El SII aceptó el documento. Estado terminal. | estadoSii 2 y detalle con lo que respondió el SII. |
| dte.aceptado_con_reparos | El SII lo aceptó pero observó algo. Es válido tributariamente: el documento existe y tiene folio. | estadoSii 4 y detalle con el reparo. Vale la pena revisarlo a mano. |
| dte.rechazado | El SII rechazó el documento. Estado terminal. | estadoSii 3 y detalle con el motivo del rechazo. |
| dte.error_envio | El sobre no se pudo subir (sin token, error HTTP, timeout). No es terminal: el documento sigue emitido y el envío se reintenta solo. | estadoSii 6, trackId null y detalle con el error del intento. |
Recepción de proveedores
Documentos que otros te emiten a vos, y los acuses que respondés. Acá rutEmisor es el proveedor y rutReceptor sos vos: es el único grupo donde los roles se invierten.
| tipo | Cuándo se dispara | Qué trae en datos |
|---|---|---|
| recepcion.documento_recibido | Llegó un DTE de un proveedor y quedó persistido, después del dedupe. | tipoDte, folio, montoTotal y fechaEmision del documento del proveedor. rutEmisor es el proveedor; rutReceptor y razonSocialReceptor son tu empresa. |
| recepcion.documento_aceptado | Se envió el acuse comercial de aceptación del documento del proveedor. | El documento del proveedor que se aceptó. |
| recepcion.documento_rechazado | Se envió el acuse comercial de rechazo. | El documento del proveedor, con el motivo en detalle. |
| recepcion.documento_reclamado | Se reclamó el documento dentro de los 8 días hábiles. Todavía no se publica: ver «Eventos declarados que aún no se publican». | — |
| recepcion.acuse_tacito | Venció el plazo legal sin acuse y quedó registrada la aceptación tácita. Lo dispara un proceso programado, no una acción tuya. | El documento del proveedor que quedó aceptado por vencimiento. |
| recepcion.recibo_mercaderia | Se registró el recibo de mercaderías de la Ley 19.983. | El documento del proveedor sobre el que se firmó el recibo. |
Operativos
Avisos de infraestructura tributaria: lo que se te va a acabar antes de que te deje sin emitir. No hablan de un documento, así que casi todo el bloque útil viaja en extra.
| tipo | Cuándo se dispara | Qué trae en datos |
|---|---|---|
| caf.folios_bajos | Quedan pocos folios disponibles para un tipo de DTE. El umbral es configurable, y un barrido diario lo evalúa. | tipoDte del CAF y extra con foliosDisponibles, tipoDte y umbral. folio, trackId y estadoSii en null. |
| caf.por_vencer | Un CAF vence pronto: hay que reobtener folios antes de quedarse sin timbrar. El aviso es por CAF, no por tipo. | tipoDte y extra con folioDesde, folioHasta, fechaVencimiento (AAAA-MM-DD), diasRestantes y cafId. |
| certificado.por_vencer | El certificado digital de la empresa vence pronto. Todavía no se publica: ver «Eventos declarados que aún no se publican». | — |
Prueba
Un solo tipo, sintético, para verificar el destino sin esperar a que ocurra algo real.
| tipo | Cuándo se dispara | Qué trae en datos |
|---|---|---|
| webhook.prueba | Lo dispara el simulador del portal contra una suscripción puntual. No se suscribe: se dispara. | Datos de relleno con la forma completa del payload, y extra con {"prueba": true}. Su clave de idempotencia siempre es única, así que cada prueba llega como un evento nuevo y nunca se deduplica contra la anterior. |
documentoReferenciado apunta siempre en la misma direcciónEs el documento al que referencia el DTE del evento, nunca al revés. Una NC o una ND traen ahí su documento original; un dte.anulado —cuyo DTE es el original, que no referencia a nadie— lo trae en null, igual que codRef.
Si leés el par codRef + documentoReferenciadode forma genérica —“este evento corrige a aquel documento”— esta convención te da siempre el sentido correcto. La relación nota de crédito → original te llega igual, y estructurada, en los eventos de la propia nota; en dte.anulado el folio de la nota va en detalle, legible.
webhook.prueba no se suscribe: se disparaEs el único tipo del catálogo que no aparece en el selector de eventos. Y cuando probás una suscripción, esa suscripción lo recibe siempre, aunque su lista de eventos sea sólo dte.aceptado: si no fuera así, el botón “Probar” no serviría justo en las suscripciones acotadas, que son las que más ganas dan de probar. Si lo declarás en la lista de eventos de una suscripción, el alta responde 400.
Eventos declarados que aún no se publican
Acá sólo se describe lo que existe. Dos tipos del catálogo se pueden elegir en el selector, pero hoy no los publica nadie. Si te suscribís a ellos no vas a recibir nada, y no hay ningún error que te lo avise.
| tipo | Estado real |
|---|---|
| certificado.por_vencer | Sin productor. El tipo existe en el catálogo y se puede suscribir, pero todavía no lo publica nadie. Mientras tanto, la fecha de vencimiento del certificado se mira en el portal. |
| recepcion.documento_reclamado | Sin punto de publicación. El tipo está declarado, pero el reclamo al SII no pasa hoy por un camino que publique el evento. |
Los dos van a empezar a llegar sin aviso previo el día que se implementen —que un tipo empiece a publicarse es retrocompatible—, así que si ya los tenés suscritos, tu receptor debería ignorarlos sin romperse en vez de asumir que nunca llegan.
El cuerpo que recibís
Se manda con Content-Type: application/json. Es exactamente el cuerpo que se firma. La forma es la misma para todos los tipos: lo que cambia es qué campos vienen con valor y qué campos vienen null.
{
"id": "0f4c9d2e-3b71-4a55-9c18-2f6e0b7d4a11",
"tipo": "dte.aceptado",
"version": 1,
"ocurridoEn": "2026-08-26T12:30:00Z",
"ambiente": "Produccion",
"empresaRut": "76665850-4",
"datos": {
"documentoId": "8a1f5c30-77d2-4e19-b6c4-5d09e2f31a88",
"tipoDte": 33,
"folio": 1234,
"trackId": 987654321,
"estadoSii": 2,
"estadoSiiNombre": "Aceptado",
"detalle": "Aceptado por el SII",
"codRef": null,
"codRefNombre": null,
"documentoReferenciado": null,
"rutEmisor": "76665850-4",
"rutReceptor": "96790240-3",
"razonSocialReceptor": "Cliente Ejemplo S.A.",
"montoTotal": 119000,
"fechaEmision": "2026-08-26",
"extra": {}
}
}version sigue en 1 y los campos nuevos son aditivosrutEmisor, rutReceptor, razonSocialReceptor, montoTotal, fechaEmision y extra se agregaron sin subir la versión: agregar claves a un objeto JSON no rompe a nadie. Tu parser tiene que ignorar lo que no conoce en vez de fallar, porque vamos a seguir agregando.
| Campo | Qué es |
|---|---|
| id | Identificador del evento. Estable entre reintentos: es la clave por la que tenés que deduplicar. |
| tipo | Uno de los tipos del catálogo. Viaja también en X-Comges-Event-Type. |
| version | Versión del formato del cuerpo. Hoy siempre 1. |
| ocurridoEn | Momento real del hecho, en UTC (ISO 8601). No es el momento del intento de entrega. |
| ambiente | Certificacion o Produccion. Una suscripción vive en un solo ambiente. |
| empresaRut | RUT de tu empresa, la dueña de la suscripción. En los eventos de recepción sigue siendo el tuyo, no el del proveedor. |
| datos.documentoId | En emisión y ciclo SII, el mismo id que devolvió la emisión y que usás en GET /dte/{id}. En los eventos recepcion.* es el id del documento recibido, que esa ruta no conoce; en los caf.* es un ancla de deduplicación, no un documento. null si el evento no tiene sujeto. |
| datos.tipoDte | Código SII del tipo, como número (33, 34, 39, 41, 52, 56, 61, 110, 111, 112). |
| datos.folio | Folio del documento. |
| datos.trackId | Identificador del sobre en el SII. null mientras no haya sobre subido. |
| datos.estadoSii | Código numérico del estado (tabla más abajo). |
| datos.estadoSiiNombre | El mismo estado en texto. |
| datos.detalle | Texto legible: lo que respondió el SII, el motivo del error, o la descripción del aviso operativo. En dte.anulado trae el folio de la nota de crédito que anuló el documento. Puede venir null. |
| datos.codRef | Solo cuando el DTE del evento referencia a otro (NC y ND): 1 anula el documento completo, 2 corrige texto, 3 corrige monto (devolución parcial). null en el resto. |
| datos.codRefNombre | El mismo codRef en texto: Anula documento, Corrige texto, Corrige monto. |
| datos.documentoReferenciado | El documento al que apunta el DTE del evento: { "tipoDte": "33", "folio": "1234" }. null cuando no referencia a nadie. Ojo con los tipos — ver el aviso de abajo. |
| datos.rutEmisor | Quién emitió el documento. En emisión y ciclo SII sos vos; en los eventos recepcion.* es el proveedor. |
| datos.rutReceptor | Quién lo recibe. En emisión es tu cliente; en los eventos recepcion.* sos vos. |
| datos.razonSocialReceptor | Razón social del receptor, para no tener que ir a buscarla. |
| datos.montoTotal | Monto total del documento, en la moneda del documento, como número. |
| datos.fechaEmision | Fecha de emisión, AAAA-MM-DD. Es una fecha de negocio, sin hora ni zona: no la parsees como instante o vas a mostrar el día anterior. |
| datos.extra | Diccionario abierto con lo propio de cada evento que no habla de un documento (caf.*, certificado.*, webhook.prueba). Llega {} cuando no hay nada que agregar. Leelo defensivamente: puede crecer sin previo aviso. |
documentoReferenciado, tipoDte y folio son stringsSe serializan como { "tipoDte": "33", "folio": "1234" }, entre comillas, mientras que datos.tipoDte y datos.folio del nivel de arriba son números. La asimetría es real y está congelada: en JavaScript, evento.datos.documentoReferenciado.tipoDte === 33 da false. Compará como texto, o convertí explícitamente.
codRefSin ese campo no podés distinguir “NC 61 aceptada porque anularon la factura” (codRef: 1) de “NC 61 aceptada porque devolvieron tres unidades” (codRef: 3).
Códigos de estadoSii
| Código | Nombre |
|---|---|
| 0 | Pendiente |
| 1 | Enviado |
| 2 | Aceptado |
| 3 | Rechazado |
| 4 | AceptadoConReparos |
| 5 | Anulado |
| 6 | ErrorEnvio |
Recepción: los RUT se invierten
Es el punto contraintuitivo de todo el contrato, y conviene leerlo dos veces. En los eventos recepcion.* el documento no es tuyo: te lo emitió un proveedor. Entonces datos.rutEmisor es el proveedor y datos.rutReceptor sos vos —igual que razonSocialReceptor, que trae tu razón social, no la del proveedor—.
empresaRut en cambio nunca cambia de dueño: siempre es el RUT de la empresa que tiene la suscripción. Es el campo por el que ruteás si atendés varias empresas con un mismo endpoint. Si escribiste el receptor pensando sólo en emisión y asumís que rutEmisor === empresaRut, el primer recepcion.documento_recibido te va a romper esa suposición en silencio.
{
"id": "c81d4a90-5f27-4b6e-9d33-0a71e5c8b402",
"tipo": "recepcion.documento_recibido",
"version": 1,
"ocurridoEn": "2026-08-26T09:14:05Z",
"ambiente": "Produccion",
"empresaRut": "76665850-4",
"datos": {
"documentoId": "1d9b7f44-2c60-4a8e-b0f1-6e3d5a90c7b2",
"tipoDte": 33,
"folio": 55120,
"trackId": null,
"estadoSii": null,
"estadoSiiNombre": null,
"detalle": "Documento recibido de un proveedor",
"codRef": null,
"codRefNombre": null,
"documentoReferenciado": null,
"rutEmisor": "96790240-3",
"rutReceptor": "76665850-4",
"razonSocialReceptor": "Mi Empresa SpA",
"montoTotal": 476000,
"fechaEmision": "2026-08-25",
"extra": {}
}
}documentoId de un evento recepcion.* no sirve en GET /dte/{id}Es el identificador del documento recibido, que vive en el módulo de recepción; esa ruta consulta tus emisiones y va a responder 404. Para cruzarlo contra tus registros usá la terna rutEmisor + tipoDte + folio.
Eventos operativos: todo lo propio va en extra
caf.* y certificado.* no hablan de un documento: no hay folio, ni estado, ni montos —y el documentoId que ves no apunta a ningún documento, es un ancla de deduplicación—. La información específica viaja en datos.extra, un diccionario abierto. En los eventos de documento llega vacío ({}), nunca ausente.
{
"id": "2b6f0c81-9e34-4d17-8a52-c470b1f9e335",
"tipo": "caf.folios_bajos",
"version": 1,
"ocurridoEn": "2026-08-26T12:20:00Z",
"ambiente": "Produccion",
"empresaRut": "76665850-4",
"datos": {
"documentoId": "6f1c0a55-2d38-4b71-9e04-8a5c3d2f7b19",
"tipoDte": 33,
"folio": null,
"trackId": null,
"estadoSii": null,
"estadoSiiNombre": null,
"detalle": "Quedan 12 folio(s) disponibles para el tipo 33 (umbral de aviso: 50).",
"codRef": null,
"codRefNombre": null,
"documentoReferenciado": null,
"rutEmisor": null,
"rutReceptor": null,
"razonSocialReceptor": null,
"montoTotal": null,
"fechaEmision": null,
"extra": {
"foliosDisponibles": 12,
"tipoDte": 33,
"umbral": 50
}
}
}Las claves de extra dependen del tipo: caf.folios_bajos manda foliosDisponibles, tipoDte y umbral; caf.por_vencer manda folioDesde, folioHasta, fechaVencimiento, diasRestantes, tipoDte y cafId. Leelo con ?. o su equivalente: es el único bloque del payload que puede sumar claves nuevas sin que cambie nada más.
documentoId de un evento caf.* no es un documentoEs un identificador sintético que usamos para no avisarte lo mismo dos veces el mismo día. No lo consultes en GET /dte/{id}: no existe. El resto de los campos de documento —folio, trackId, estadoSii, montoTotal, fechaEmision, rutEmisor, rutReceptor— llegan en null.
El dedupe de caf.folios_bajos es por tramo de severidad y por día: si por la mañana quedaban 12 folios y por la tarde quedan 0, te llegan los dos avisos. Lo que no te llega es el mismo tramo repetido dentro del día.
Headers
POST /webhooks/comges HTTP/1.1
Content-Type: application/json
X-Comges-Signature: t=1774704312,v1=66188afa87cae22d8c7e2f642da1df88a459ade615cbf6c8e7303b2ab27165fd
X-Comges-Timestamp: 1774704312
X-Comges-Event-Id: 0f4c9d2e-3b71-4a55-9c18-2f6e0b7d4a11
X-Comges-Event-Type: dte.aceptado
X-Comges-Delivery-Id: 5b2e7d10-9c44-4f0a-8e31-77a6b0c9d123
X-Comges-Delivery-Attempt: 1| Header | Contenido |
|---|---|
| X-Comges-Signature | t=<unix>,v1=<hex minúscula> |
| X-Comges-Timestamp | Segundos Unix UTC. Es el mismo valor que el t= de la firma, y entra en el material firmado. |
| X-Comges-Event-Id | Id del evento. Estable entre reintentos — deduplicá por acá. |
| X-Comges-Event-Type | El tipo del evento, para rutear sin parsear el cuerpo. |
| X-Comges-Delivery-Id | Id de la entrega. Cambia por suscripción, no por intento. |
| X-Comges-Delivery-Attempt | Número de intento. 1 en la primera entrega. |
A estos se suman los headers extra que hayas configurado en la suscripción.
Verificar la firma
Tres reglas que no se pueden saltar:
- Usá los bytes crudos del request, no el JSON re-serializado. Cualquier cambio de espaciado o de orden de claves rompe la comparación. Es el error número uno: el framework parsea el cuerpo por vos, vos volvés a serializarlo para firmar, y no coincide nunca. En Express necesitás
express.raw; en ASP.NET Core, leer elBodyantes del binder; en PHP,php://input. - El timestamp entra en el material firmado. Sin él, cualquiera que capture una entrega podría reproducirla para siempre. Con él, podés rechazar todo lo que tenga más de N minutos (5 es un valor razonable) y la firma sólo sirve dentro de esa ventana.
- Compará en tiempo constante (
crypto.timingSafeEqual,hmac.compare_digest,CryptographicOperations.FixedTimeEquals,hash_equals). Un===sobre strings filtra información por el tiempo de respuesta.
Es el mismo esquema que usa Stripe, así que si ya tenés código para eso, sirve tal cual.
import crypto from 'node:crypto'
import express from 'express'
const app = express()
const SECRETO = process.env.COMGES_WEBHOOK_SECRET
// Importante: body CRUDO. Con express.json() ya perdiste los bytes originales
// y la firma no va a coincidir nunca.
app.post('/webhooks/comges', express.raw({ type: 'application/json' }), (req, res) => {
const firma = req.header('X-Comges-Signature') ?? ''
const partes = Object.fromEntries(firma.split(',').map((p) => p.split('=', 2)))
const t = Number(partes.t)
const recibida = partes.v1 ?? ''
// 1) Ventana de tolerancia: descarta reproducciones viejas.
if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > 300) {
return res.status(400).send('timestamp fuera de ventana')
}
// 2) HMAC-SHA256 sobre "{t}.{cuerpoCrudo}".
const esperada = crypto
.createHmac('sha256', SECRETO)
.update(`${t}.${req.body.toString('utf8')}`)
.digest('hex')
// 3) Comparación en tiempo constante.
const a = Buffer.from(esperada, 'utf8')
const b = Buffer.from(recibida, 'utf8')
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).send('firma inválida')
}
const evento = JSON.parse(req.body.toString('utf8'))
// 4) Deduplicar: la entrega es at-least-once.
if (yaProcesado(evento.id)) return res.sendStatus(200)
// 5) Responder rápido; el trabajo pesado va aparte.
encolarParaProcesar(evento)
res.sendStatus(200)
})Vector de prueba fijo
Para verificar tu implementación sin recibir nada, acá va un caso cerrado. Si tu código produce este mismo hexadecimal, tu verificación está bien; si no, el problema es tuyo y no de la entrega. Sirve tal cual como test unitario del receptor.
| Dato | Valor |
|---|---|
| Secreto | whsec_documentacion_vector_de_prueba |
| X-Comges-Timestamp | 1774704312 |
| Cuerpo | 568 bytes UTF-8, una sola línea, sin salto final (el bloque de abajo). |
| Material firmado | 1774704312. + el cuerpo, concatenados sin nada en medio. |
| v1 esperado | 66188afa87cae22d8c7e2f642da1df88a459ade615cbf6c8e7303b2ab27165fd |
| Header completo | X-Comges-Signature: t=1774704312,v1=66188afa87cae22d8c7e2f642da1df88a459ade615cbf6c8e7303b2ab27165fd |
El cuerpo es el mismo ejemplo dte.aceptado de más arriba, pero tal como viaja: compacto, sin saltos de línea y sin espacios entre claves.
SECRETO='whsec_documentacion_vector_de_prueba'
TIMESTAMP='1774704312'
CUERPO='{"id":"0f4c9d2e-3b71-4a55-9c18-2f6e0b7d4a11","tipo":"dte.aceptado","version":1,"ocurridoEn":"2026-08-26T12:30:00Z","ambiente":"Produccion","empresaRut":"76665850-4","datos":{"documentoId":"8a1f5c30-77d2-4e19-b6c4-5d09e2f31a88","tipoDte":33,"folio":1234,"trackId":987654321,"estadoSii":2,"estadoSiiNombre":"Aceptado","detalle":"Aceptado por el SII","codRef":null,"codRefNombre":null,"documentoReferenciado":null,"rutEmisor":"76665850-4","rutReceptor":"96790240-3","razonSocialReceptor":"Cliente Ejemplo S.A.","montoTotal":119000,"fechaEmision":"2026-08-26","extra":{}}}'
printf '%s' "$TIMESTAMP.$CUERPO" | openssl dgst -sha256 -hmac "$SECRETO" -r
# 66188afa87cae22d8c7e2f642da1df88a459ade615cbf6c8e7303b2ab27165fd *stdinSi tu editor le agrega un salto de línea al final, o si reindentás el JSON antes de firmarlo, el digest cambia por completo y no vas a poder reproducir el valor de arriba. Es exactamente el error que después aparece en producción como “la firma nunca coincide”.
Calculadora de firma
Pegá el secreto, el timestamp y el cuerpo crudo, y compará el v1 que esperamos con el que calcula tu código.
Header que esperamos
—Deduplicación: la entrega es at-least-once
Garantizamos que el evento llega al menos una vez, no exactamente una vez. El mismo X-Comges-Event-Id puede llegar más de una vez —un reintento después de que tu respuesta se perdió en la red, un reintento manual desde el portal, un rescate de una entrega que quedó colgada—. Guardá los ids procesados y devolvé 200 sin volver a hacer nada si ya lo viste.
El id es estable entre intentos y viaja tanto en el header como en id del cuerpo. Lo que cambia entre intentos es X-Comges-Delivery-Attempt: ese sirve para loguear, no para deduplicar.
Tampoco asumas orden. Si un documento pasa a dte.enviado y enseguida a dte.aceptado, los dos avisos pueden llegar en cualquier orden. Usá datos.estadoSii para decidir, no el orden de llegada.
Qué responder
| Tu respuesta | Qué hacemos |
|---|---|
| 2xx | Entrega exitosa. No hay más intentos. |
| 408 o 429 | Reintentamos según la escalera. Son las dos únicas excepciones dentro de los 4xx. |
| Cualquier otro 4xx | Agotamos la entrega de inmediato, sin consumir la escalera. Un 404 o un 401 no se arregla repitiendo el mismo POST: no tiene sentido castigarte 31 horas. |
| 5xx, timeout o error de red | Reintentamos según la escalera. |
Respondé rápido y procesá en segundo plano: el POST tiene un timeout de 10 segundos. No hagas el trabajo pesado dentro del handler: si tardás más, el intento cuenta como fallido y te vas a comer un duplicado cuando reintentemos. Guardamos siempre el cuerpo de tu respuesta —hasta 64 KB, recortado si te pasás— así que un mensaje de error legible en el cuerpo del 4xx te va a ahorrar tiempo cuando revises el historial.
Reintentos
7 intentos en total: la entrega inicial más 6 reintentos. En total cubre unas 31 horas, que es tiempo de sobra para enterarte de que tu endpoint está caído y levantarlo. Después de eso la entrega queda agotada y sólo se reintenta a mano desde el portal.
| Intento | Espera desde el anterior | Acumulado desde el evento |
|---|---|---|
| 1 | Inmediato | — |
| 2 | 1 minuto | 1 min |
| 3 | 5 minutos | 6 min |
| 4 | 15 minutos | 21 min |
| 5 | 1 hora | 1 h 21 min |
| 6 | 6 horas | 7 h 21 min |
| 7 | 24 horas | ≈ 31 horas |
La escalera sólo corre para lo que tiene sentido reintentar. Un 5xx, un timeout o un error de red se reintentan completos. Un 4xx permanente —400, 401, 404— agota la entrega en el primer intento: repetir el mismo POST no lo va a arreglar. Las dos excepciones dentro de los 4xx son 408 (timeout) y 429(demasiadas peticiones), que sí escalan: los dos dicen “ahora no, probá después”.
Son unos 3 eventos agotando la escalera entera. A esa altura el endpoint no está “con un problemita”: deshabilitamos la suscripción, la marcamos con el motivo y la fecha, y dejamos de intentar.
Se reactiva desde el portal: al volver a activarla, el contador de fallos consecutivos vuelve a cero, así que no se apaga de nuevo con el primer tropiezo. Arreglá el endpoint, probalo con el botón “Probar”, reactivá y después reintentá en lote lo que quedó agotado. Mientras tanto el estado real sigue disponible por polling.
Administrar desde el portal
Todo esto lo hace el cliente solo, sin escribirle a nadie, en Portal → Webhooks. Hace falta el permiso webhook:configurar.
Crear y editar suscripciones
URL https de destino, ambiente y descripción. La edición es en la misma pantalla del detalle, sin formularios paralelos.
Elegir qué eventos recibir
Del catálogo completo, agrupado por categoría, o el comodín * para recibir todos —incluidos los tipos que agreguemos después—.
Filtrar por tipo de DTE
Si tu ERP sólo procesa facturas, no tiene por qué ahogarse en boletas. Ver la sección de filtros.
Agregar headers extra
Los que necesite tu gateway o tu WAF —por ejemplo un Authorization propio— fijos para todas las entregas de esa suscripción.
Generar o fijar tu propio secreto, y rotarlo
Lo genera el sistema o lo traés vos. La rotación es inmediata y sin ventana de gracia: ver la sección del secreto.
Simular cualquier evento contra tu endpoint
Elegís qué evento del catálogo mandar (dte.aceptado, recepcion.documento_recibido, caf.folios_bajos, …), ves el cuerpo exacto antes de enviarlo y te devuelve el resultado del intento —status, milisegundos, error— en el momento, sin esperar ningún backoff. Así podés desarrollar cada rama de tu receptor sin esperar a que el hecho ocurra de verdad: los eventos simulados llegan con datos.extra.prueba en true.
Ver el historial de entregas
Cada intento con el JSON exacto que se mandó y la respuesta que devolviste: status, headers y cuerpo. Es el material para depurar tu verificación HMAC sin pedirnos nada.
Reintentar, de a una o en lote
Una entrega puntual, o todas las que cumplan un filtro de estado, tipo y fechas. El lote tiene un tope duro de 500 entregas por corrida.
Ver métricas
Entregadas, fallidas y pendientes, tasa de éxito y latencia de tu endpoint (promedio y percentiles), por período, por tipo de evento y por suscripción.
De cada intento queda el JSON exacto que salió —con todos los headers X-Comges-*, incluida la firma entera, que es lo que necesitás para depurar tu verificación HMAC— y la respuesta que devolviste: status, headers y cuerpo. El secreto no se guarda nunca en ese registro.
La latencia que ves medida es la de tu endpoint: se corta al recibir tus headers de respuesta, sin contar la lectura del cuerpo ni nuestro propio trabajo posterior. El historial se conserva un año.
Filtros y headers extra
Filtro por tipo de DTE
Además de elegir qué eventos querés, podés acotar sobre qué documentos. El filtro es una lista de códigos SII —33,34,61, por ejemplo—. Vacío significa todos.
- Se aplica además de la lista de eventos, no en su lugar: primero mira si el tipo de evento te interesa, después si el tipo de documento pasa el filtro.
- Si el evento no trae
tipoDte, el filtro no aplica y el evento se entrega igual. Es el caso decertificado.por_vencer—el día que se publique—: no hay documento que filtrar, y silenciar un aviso operativo por tener puesto un filtro de documentos sería el peor resultado posible. - El caso que resuelve: un ERP que sólo procesa facturas no tiene por qué recibir —ni verificar, ni encolar, ni descartar— cada boleta de un punto de venta con miles de emisiones por día.
Headers extra
Si tu endpoint vive detrás de un gateway que exige su propia autenticación, podés declarar headers fijos que se agregan a todas las entregas de esa suscripción —el caso típico es un Authorization: Bearer … de tu API gateway—.
- No pueden pisar ningún header
X-Comges-*niContent-Type. Al guardar, uno de esos nombres se rechaza con un400; al entregar, se ignora. Son los que sostienen la firma y la deduplicación: si pudieras sobrescribirX-Comges-Signatureestarías anulando tu propia verificación, y si pudieras sobrescribirContent-Typeromperías el parseo del cuerpo. - Máximo 10 headers, con nombres alfanuméricos y guiones (
[A-Za-z0-9-]) y 1000 caracteres de tope en total. - Van en claro en el request: quedan visibles en el historial de entregas, igual que el resto de los headers. No los uses para nada que no puedas ver en tu propio panel.
Son para atravesar tu infraestructura, no para autenticarnos. Lo que prueba que el POST salió de nosotros y que el cuerpo no se tocó es X-Comges-Signature. Verificá la firma siempre, aunque tu gateway ya haya validado tu propio header.
El secreto de firma
- Autogenerado o propio. El portal lo genera con prefijo
whsec_, o traés el tuyo: mínimo 16 caracteres. Si ya manejás secretos en un vault, poné el que ya tenés y no toques nada más. - Se muestra una sola vez, al crearlo o al rotarlo. Después siempre viaja enmascarado (
whsec_••••abcd). Guardalo apenas lo recibís: no hay forma de volver a verlo, sólo de rotarlo. - La rotación no tiene ventana de gracia. No conviven el secreto viejo y el nuevo: la entrega siguiente ya va firmada con el nuevo, y cualquier cosa que todavía verifique con el viejo va a rechazarla. Actualizá tu receptor antes de rotar, no después. Si podés, dejá el receptor aceptando los dos durante el despliegue y sacá el viejo cuando la rotación esté hecha.
- El secreto nunca viaja en el request del webhook ni queda guardado en el historial de entregas. Sólo lo tenemos nosotros para firmar y vos para verificar.
Requisitos del endpoint de destino
La URL la elegís vos y la llama nuestro backend, así que la validamos con criterio estricto: al registrarla y otra vez antes de cada POST, porque el DNS puede cambiar en el medio.
httpsobligatorio. No aceptamoshttp.- Sin direcciones internas: nada de loopback, link-local, multicast ni rangos privados (RFC 1918, CGNAT, IPv6 ULA). Si tu host resuelve a una de esas, la entrega no sale.
- Sin credenciales embebidas en la URL (
https://usuario:clave@host). Autenticá con la firma HMAC, o con un header extra si tu gateway lo exige. - No seguimos redirects. Un
302no se sigue: devolvé2xxen la URL registrada.
Limitación conocida
dte.error_envio se notifica una sola vez por documento. Si el mismo sobre falla al subir otra vez, no volvés a recibir el aviso. Es deliberado —evita spamear mientras un sobre reintenta solo—, pero significa que ves el primer fallo y no los siguientes. Como ese evento no es terminal, el documento va a terminar en un estado final igual y ese sí te llega.
Esta guía es un tutorial. La referencia completa de la API pública vive en el API Reference. Ante cualquier diferencia, manda lo que diga ahí.