Comges DTE

Buscar en la documentación

Escribí para filtrar guías, endpoints y webhooks.

Fuente de verdadActualizada el Probar los endpoints en el explorador OpenAPI

Este documento describe la superficie completa de la API pública y es lo que manda ante cualquier diferencia con las guías o con el explorador.

API pública Comges DTE — guía de integración

Público objetivo: desarrollador de un sistema externo (ERP, e-commerce, POS) que necesita emitir Documentos Tributarios Electrónicos al SII de Chile desde su propio software, sin usar nuestro portal.

Este documento es la fuente de verdad de la API pública. Las guías y la referencia interactiva del portal son tutoriales que apuntan acá; si alguna difiere de este documento, vale este documento.

Regla de este documento: acá solo se describe lo que existe y está desplegado. Cada ruta, cada campo y cada código de error fue verificado contra el código que corre en producción. Lo que todavía no existe está en Qué no existe todavía, explícito, para que nadie escriba código contra una promesa.

Referencia interactiva (OpenAPI, generada del mismo contrato): /docs/reference en el portal.


Índice

  1. Antes de empezar: dar de alta la empresa
  2. Lo mínimo para entender el modelo
  3. Autenticación con API Key
  4. Ambientes: certificación y producción
  5. Scopes (permisos de la key)
  6. Health check
  7. Prerequisitos: qué tiene que estar listo antes de emitir
  8. Emitir un DTE
  9. Consultar el estado (modelo asíncrono)
  10. Descargar XML y PDF - Enlaces firmados para el comprador
  11. Anular un documento
  12. Devolución parcial: notas de crédito por línea o por monto
  13. Exportación (110 / 111 / 112)
  14. Paso a producción (go-live)
  15. Errores: formato y códigos
  16. Idempotencia y reintentos
  17. Rate limiting y límites duros
  18. Webhooks - Catálogo de eventos - Vector de prueba fijo
  19. Ejemplo end-to-end con curl
  20. Qué no existe todavía
  21. Soporte

1. Antes de empezar: dar de alta la empresa

El alta de una empresa emisora no es self-service. No existe un endpoint público de registro: la ejecuta el equipo de Comges, y hasta que ese paso no está hecho no hay portal, no hay usuario, no hay API Key y no hay nada que integrar.

Esto es lo primero que hay que resolver, antes de escribir una línea de código.

Los cinco datos que hay que entregar

Para dar de alta a la empresa emisora hay que entregarnos, de esa empresa:

DatoDetalle
RUT de la empresaEl del contribuyente que va a emitir.
Clave tributaria del SIILa clave con la que la empresa entra al sitio del SII.
Certificado digital (.pfx)El certificado de firma electrónica del representante. Máximo 5 MB.
Clave del certificadoLa contraseña del archivo .pfx.
RUT del titular del certificadoEl RUT de la persona dueña del certificado. Se informa aparte: no se lee del archivo — los certificados chilenos no lo traen de forma confiable.

⚠️ Estos datos son credenciales reales del contribuyente. Entregarlos es una decisión del cliente final, no del integrador. Coordinalo con él antes de prometer una fecha de puesta en marcha: en la práctica es el paso que más tarda de todo el proyecto.

Qué pasa en el alta

Con esos cinco datos, el alta:

  1. Valida el certificado y su clave, y valida la clave tributaria contra el sitio del SII.
  2. Trae del SII los datos de la empresa: razón social, correo, teléfono, oficina del SII, sucursales y actividades económicas (actecos).
  3. Crea la empresa con esos datos y guarda el certificado.

Si el certificado, su clave o la clave tributaria están mal, el alta falla completa: no queda una empresa a medio crear.

Lo que NO hace el alta (y hay que pedir aparte)

  • El usuario administrador de la empresa se crea en un paso posterior. Sin usuario no hay quien entre al portal ni quien cree API Keys.
  • La habilitación de cada tipo de documento (33, 34, 39, …) es un paso aparte. Ver §7 Prerequisitos.
  • La certificación ante el SII para emitir en Producción es otro proceso, con su propia duración. Ver §14 Paso a producción.

El reloj del plan empieza acá

Toda empresa nueva se crea con plan Trial de 30 días, contados desde el alta, no desde que empieza la integración. Si el alta se hace mucho antes de que el ERP esté listo, el plan puede vencer en pleno desarrollo y toda emisión pasa a responder 403 plan.vencido.

Recomendación: pedí el alta cuando tengas el proyecto encaminado, y coordiná la vigencia del plan con soporte antes de arrancar.

A quién pedirlo

El alta, la creación del usuario administrador, la habilitación de tipos de documento y la extensión del plan las gestiona soporte de Comges: escribí a [email protected] con el RUT de la empresa y qué tipos de documento vas a emitir.

Quién puede crear la API Key

Una vez que la empresa existe y tiene usuario, las API Keys se administran desde el portal (/portal/configuracion/api-keys):

AcciónPermiso que necesita el usuario del portal
Ver las keys existentesempresa:api-keys:read
Crear, rotar, revocar o cambiar scopesempresa:api-keys:write

Si el usuario entra al portal y no ve la sección de API Keys, no es un bug: le falta el permiso. Se lo tiene que dar el administrador de la empresa.


2. Lo mínimo para entender el modelo

ConceptoQué significa acá
Base URLhttps://api-publica.dtecomges.cl — todas las rutas de negocio cuelgan de /api/public/v1/*.
CredencialUna API Key en el header X-Api-Key. No hay OAuth, no hay JWT, no hay sesiones.
Empresa emisoraSale del claim de la key. Nunca se manda en el request.
Ambiente SIISale del claim de la key (pk_test_ → Certificación, pk_live_ → Producción). Nunca se elige en el body.
EmisiónSíncrona hasta el folio y la firma cuando hay folio disponible; asíncrona hacia el SII. Si no hay folio, la emisión se encola y devuelve 202 — ver §8.
Formato de errorRFC 7807 extendido: { code, title, status, detail?, field?, traceId? }. El campo estable para programar es code. Con excepciones — ver §15.
Server-to-serverLa API no habilita CORS. Está pensada para que la llame tu backend. No pongas la key en un navegador ni en una app móvil: quien la tenga puede emitir documentos tributarios a nombre de tu cliente.

Rutas públicas

17 rutas de negocio, más el health check. Base: https://api-publica.dtecomges.cl

DTE nacional

Verbo y rutaScopeQué hace
POST /api/public/v1/dtedte:emit:{tipo}Emite un DTE nacional. Devuelve 201 o 202 + ticketId.
GET /api/public/v1/dte/{id}dte:readConsulta el documento y su estado ante el SII.
GET /api/public/v1/dte/{id}/xmldte:readDescarga el XML firmado.
GET /api/public/v1/dte/{id}/pdfdte:readDescarga el PDF (?formato=carta o ?formato=80mm).
POST /api/public/v1/dte/{id}/anulardte:emit:61Anula el documento completo (NC 61, CodRef=1).
GET /api/public/v1/dte/{id}/acreditabledte:readSaldo disponible para notas de crédito parciales.
POST /api/public/v1/dte/{id}/nota-creditodte:emit:61Nota de crédito parcial (CodRef=3). No anula.
GET /api/public/v1/dte/pendientes/{ticketId}dte:readResuelve el ticket que devolvió un 202.
GET /api/public/v1/dte/pendientesdte:readLista las emisiones encoladas esperando folio.

Exportación

Verbo y rutaScopeQué hace
POST /api/public/v1/exportaciondte:emit:{110|111|112}Emite un DTE de exportación.
GET /api/public/v1/exportacion/{id}dte:readConsulta la exportación y su estado.
GET /api/public/v1/exportacion/{id}/xmldte:readDescarga el XML firmado.
GET /api/public/v1/exportacion/{id}/pdfdte:readDescarga el PDF (solo Carta, no admite ?formato=).

Descargas firmadas — las únicas rutas sin API Key. Ver §10.

Verbo y rutaScopeQué hace
GET /api/public/v1/descargas/dte/{id}/xmlningunoXML firmado, autorizado por el token del enlace.
GET /api/public/v1/descargas/dte/{id}/pdfningunoPDF (?formato=carta o ?formato=80mm).
GET /api/public/v1/descargas/exportacion/{id}/xmlningunoXML firmado de la exportación.
GET /api/public/v1/descargas/exportacion/{id}/pdfningunoPDF de la exportación (solo Carta).

Estas cuatro no las construís vos: llegan armadas y firmadas en xmlUrl y pdfUrl dentro del cuerpo de la emisión. Son para reenviárselas al comprador, que no tiene tu API Key.

Operación

Verbo y rutaScopeQué hace
GET /healthningunoLiveness. Público, sin API Key, sin rate limit. Ver §6.

Una integración de facturación típica usa cinco: emitir, consultar, XML, PDF y anular.


3. Autenticación con API Key

Formato de la key

pk_{live|test}_{prefijo}_{secreto}
   └─ ambiente   └─ 8 chars  └─ secreto base64url (32 bytes)

Ejemplo (ilustrativo, no funcional): pk_test_a1b2c3d4_R0hKTlBRU1RVVldYWVpiY2RlZmdoaWprbG0

  • El prefijo de 8 caracteres identifica la key y es el que aparece en el portal, en los logs y en la partición del rate limiter. No es secreto.
  • El secreto se muestra una sola vez, al crear o rotar la key. No se puede recuperar: la base solo guarda su hash SHA-256.
  • El segmento live / test tiene que coincidir con el ambiente registrado de la key. Una clave pk_live_* presentada contra una key marcada como de certificación se rechaza con 401 (el prefijo no puede mentir sobre el ambiente).

Cómo se manda

Header canónico:

http
X-Api-Key: pk_test_a1b2c3d4_...

Alternativa aceptada (útil si tu cliente HTTP solo maneja Authorization):

http
Authorization: Bearer pk_test_a1b2c3d4_...

El servidor acepta Authorization: Bearer solo si el token empieza con pk_.

Qué pasa si falla

SituaciónRespuesta
Sin header401 con cuerpo {"code":"api-key.ausente","title":"..."}
Key mal formada, inexistente, revocada o expirada401 con el mismo cuerpo api-key.ausente
Key válida pero sin el scope del endpoint403 con {"code":"permiso.requerido","title":"Se requiere el permiso 'dte:read'.","status":403}

El 401 es deliberadamente genérico: no distingue "no mandaste key" de "tu key no existe" ni de "tu key fue revocada". Es anti-enumeración — no queremos que alguien descubra prefijos válidos probando. Para programar, usá el status 401, no el sub-código: hoy todos los caminos de fallo de autenticación devuelven el mismo code.

El 403 por scope faltante devuelve permiso.requerido, con el código del permiso que falta dentro del title. Si tu código busca otro valor, esa rama nunca se va a ejecutar.

Obtener y rotar una key

Se crean desde el portal (/portal/configuracion/api-keys). Al crear se elige nombre, scopes y una expiración opcional. Quién puede hacerlo: ver §1.

⚠️ La rotación no tiene ventana de gracia. Al rotar una key, el secreto anterior deja de servir de inmediato: no hay período en que las dos versiones funcionen. Planificá la rotación como un despliegue coordinado (guardá el secreto nuevo, desplegá, recién ahí rotá), no como un cambio en caliente.

⚠️ La revocación tarda hasta 30 segundos en propagarse. El resultado positivo de validación se cachea 30 s. Una key revocada puede seguir emitiendo dentro de esa ventana. Si necesitás corte inmediato, revocá y verificá; para un incidente de seguridad real, escalá a soporte.


4. Ambientes: certificación y producción

PrefijoAmbienteSIIQué emite
pk_test_CertificaciónMaullínDocumentos de prueba. No tienen validez tributaria.
pk_live_ProducciónPalenaDocumentos tributarios reales.

El ambiente lo determina la key y nada más.

  • No lo cambia el host: la misma URL sirve a los dos ambientes.
  • No lo cambia el body. El campo ambiente del cuerpo de emisión existe por compatibilidad, pero con una API Key se ignora si coincide y se rechaza si contradice:
json
// key pk_test_ (Certificación) + body {"ambiente":"Produccion"}
{
  "code": "emisor.ambiente-no-coincide-con-key",
  "title": "La API key opera en 'Certificacion' y el cuerpo pide 'Produccion'. El ambiente lo determina la key, no el request: usá una key del ambiente que necesitás.",
  "status": 400,
  "field": "ambiente"
}

La forma correcta de trabajar con los dos ambientes es tener dos keys y elegir cuál usar en tu configuración. La recomendación operativa es no mandar ambiente en el body en absoluto.

⚠️ Una sola URL base. https://api-publica.dtecomges.cl es la única que se integra, para los dos ambientes. Si en alguna herramienta o colección importada aparece un servidor alternativo con un hostname de despliegue (algo terminado en .run.app), no lo uses: es un entorno interno de desarrollo, corre contra otra base de datos, tus keys devuelven 401 ahí y su URL cambia sin aviso.

El ambiente de la key se hereda del usuario que la creó y queda congelado en ella. Un usuario que opera en certificación nunca obtiene una key de producción; al crear la key se puede además restringirla explícitamente a certificación, pero nunca ampliarla. Para pasar a producción, ver §14.


5. Scopes (permisos de la key)

Cada key lleva una lista de scopes. El servidor valida el scope antes de reservar folio.

ScopeHabilita
dte:emit:33Emitir factura electrónica afecta
dte:emit:34Emitir factura exenta
dte:emit:39Emitir boleta electrónica
dte:emit:41Emitir boleta exenta
dte:emit:52Emitir guía de despacho
dte:emit:56Emitir nota de débito
dte:emit:61Emitir nota de crédito, anular documentos y emitir notas de crédito parciales
dte:emit:110 / 111 / 112Emitir factura / ND / NC de exportación
dte:readConsultar documentos, tickets, saldo acreditable, y descargar XML y PDF
dte:emit:43 / dte:emit:46No operativo. El scope se puede asignar, pero la emisión responde 501. Ver §8.

Reglas que sorprenden si no se leen:

  • Anular exige dte:emit:61, no un scope propio. No existe dte:anular.
  • dte:read cubre todos los caminos de lectura (detalle, XML, PDF, ticket, acreditable). Sin él, todos responden 403, incluido el PDF.
  • Tener dte:emit:33 no habilita a leer. Son scopes independientes.
  • Existen otros scopes asignables en el portal (plantillas, sobres, procesos) que no tienen ninguna ruta en esta API pública. Asignarlos no habilita nada acá.
  • El scope webhook:configurar se puede asignar, pero no habilita nada en esta API. Las suscripciones de webhooks se administran desde el portal, no por código: ninguna ruta bajo /api/public/v1/ las crea, edita ni consulta. Ver §18.

Una key típica de integración de facturación lleva: dte:emit:33, dte:emit:61, dte:read.

⚠️ La misma falta de dte:emit:61 puede devolver tres códigos distintos, según qué capa corte primero: permiso.requerido (403), emisor.permiso-tipo-no-autorizado (403, al anular) o dte.permiso-faltante (403, al emitir una nota de crédito parcial). Los tres significan lo mismo: falta el scope. Ramificá por el code, no por el status: dentro del mismo 403 conviven causas que se resuelven de forma distinta —a la key le falta un scope (§15.1) vs. ese tipo todavía no está certificado en Producción (cert.tipo-no-aprobado-prod, §15.3) vs. el plan venció (plan.vencido, §15.2)—. Si programás una rama de "falta permiso", cubrí los tres códigos de scope.


6. Health check

GET https://api-publica.dtecomges.cl/health      → 200 "Healthy"
  • Cuelga de la raíz del host, no de /api/public/v1.
  • Público: no lleva X-Api-Key.
  • Exento del rate limit: podés consultarlo tan seguido como quieras sin gastar tu cuota.

⚠️ Solo dice que el servicio responde. No verifica el SII, ni la base de datos, ni la disponibilidad de folios. Un 200 acá no garantiza que una emisión vaya a funcionar.

Usalo para tus monitores de disponibilidad. Lo que no hay que hacer es usar GET /api/public/v1/dte/{id} como ping: consume cuota de rate limit por cada llamada.


7. Prerequisitos: qué tiene que estar listo antes de emitir

Cada fila es una condición que, si falta, hace fallar la emisión. La columna del código está para que puedas buscar acá el error que te devolvió la API.

PrerequisitoQuién lo resuelveSi falta
Empresa dada de altaSoporte de ComgesNo hay usuario, no hay portal, no hay key. Ver §1.
Tipo de documento habilitado para la empresa en ese ambienteSoporte de Comges403 emisor.tipo-dte-no-habilitado
Certificación SII aprobada por tiposolo en ProducciónEquipo de Comges + SII403 cert.tipo-no-aprobado-prod. Si la verificación no responde: 503 cert.servicio-no-disponible (reintentable). Ver §14.
CAF vigente del tipo que vas a emitirAutomático (se piden al SII) o carga manualNormalmente no falla: la emisión se encola y devuelve 202. Si el SII no autoriza timbraje: 422 caf.sin-autorizacion (terminal). Caso residual: 409 emisor.sin-folios.
CAF de tipo 61, si vas a anular o acreditarIgual que el anterior400 o 409 emisor.sin-folios-nc
Casa matriz configuradaSe toma del SII en el alta422 emisor.empresa-sin-sucursal
Actividades económicas (actecos) activasSe toman del SII en el alta422 emisor.empresa-sin-actecos
Certificado digital activoSe carga en el altaDTE nacional: 422 emisor.sin-certificado (no hay PFX activo) o 422 emisor.certificado-invalido (hay PFX pero no abre), sin consumir folio. Exportación: 409 exp.sin-certificado, pero ahí el folio ya se tomó. Ver §8.
Plan vigenteComercial403 plan.vencido

8. Emitir un DTE

POST /api/public/v1/dte
Content-Type: application/json
X-Api-Key: pk_test_...

Un solo endpoint cubre todos los tipos nacionales. El tipo se elige con tipoDte: 33 factura, 34 factura exenta, 39 boleta, 41 boleta exenta, 52 guía de despacho, 56 nota de débito, 61 nota de crédito.

Tipos que esta API no emite

Los tipos de exportación (110/111/112) no van por acá: POST /api/public/v1/dte los rechaza con 501 emisor.tipo-dte-no-implementado. Usá POST /api/public/v1/exportacion.

Los tipos 43 (liquidación-factura) y 46 (factura de compra) no se emiten por esta API: POST /api/public/v1/dte responde 501 emisor.tipo-dte-no-emisible sin consumir folio. La 43 admite montos negativos y exige un bloque de comisiones propio; la 46 invierte la semántica del receptor (el RUT es el del vendedor) y lleva retención. Ninguna de las dos aritméticas está implementada, y emitirlas a medias produciría documentos mal formados que el SII rechazaría con el folio ya quemado. Sin fecha comprometida. Los scopes dte:emit:43 y dte:emit:46 existen y se pueden asignar, pero no habilitan nada.

El certificado digital tiene que estar activo

Firmar es obligatorio: no existe ningún camino que persista un DTE sin firma. Por eso la emisión nacional verifica el certificado antes de reservar el folio, y falla explícito:

SituaciónRespuestaFolio
La empresa no tiene un PFX activo422 emisor.sin-certificadono se consume
Hay un PFX cargado, pero no se pudo abrir (clave equivocada o certificado vencido)422 emisor.certificado-invalidono se consume

Los dos errores traen field: "certificado". Ninguno se arregla reintentando: hay que cargar un PFX vigente o corregir su clave desde el portal. El certificado vence cada 1–3 años, así que este error puede aparecer de un día para otro en una integración que venía funcionando: tratalo como un error operativo que hay que avisar, no como un 4xx de validación de tu request.

⚠️ En exportación el orden es distinto y peor. POST /api/public/v1/exportacion carga el PFX después de tomar el folio, así que responde 409 exp.sin-certificado (sin PFX activo) o 500 exp.error-pfx (PFX ilegible) con el folio ya consumido. Ver §15.17.

Cuerpo mínimo

json
{
  "tipoDte": 33,
  "receptor": {
    "rut": "96790240-3",
    "razonSocial": "Cliente Ejemplo S.A.",
    "giro": "Comercio al por mayor",
    "direccion": "Av. Providencia 1234",
    "comuna": "Providencia"
  },
  "detalles": [
    {
      "nroLinea": 1,
      "nombreItem": "Consultoría técnica",
      "cantidad": 1,
      "precioUnitario": 1000000
    }
  ]
}

Campos del cuerpo

Raíz:

CampoTipoObligatorioNotas
tipoDteintCódigo SII.
receptorobjetoVer abajo.
detallesarray1 a 60 líneas (límite duro del SII).
transactionIduuidnoClave de idempotencia. Ver §16.
ambientestringnoNo usar con API Key. Ver §4.
fechaEmisionstringnodd-MM-yyyy o yyyy-MM-dd. Si se omite, hoy en Chile. No puede ser futura.
formaPagobyteno1 contado, 2 crédito, 3 sin costo.
fechaVencimientostringnoMismo formato que fechaEmision. En 33/34 a crédito, si falta se usa emisión + 30 días.
medioPagostring/intno1..6, o "Efectivo"/"Cheque"/"Transferencia"/"TarjetaCredito"/"TarjetaDebito"/"Otro", o código SII de 2 letras. Un valor desconocido se ignora (no tumba la emisión).
indServiciobytenoSolo se emite en boletas 39/41; si falta, el servidor pone 3. El valor no se valida: ver Boleta electrónica (39) y boleta exenta (41).
indTrasladobytecondicionalObligatorio en la guía 52, prohibido en el resto. Ver Guía de despacho (52).
tipoDespachobytenoSolo en la guía 52. 1/2/3.
transporteobjetonoDatos de transporte y destino. Solo tiene sentido en la 52; en boletas se descarta.
referenciasarraycondicionalObligatorio en 56 y 61. Máx. 40.
impuestosAdicionalesarraynoMáx. 20.
descuentosGlobalesarraynoMáx. 20.
observacionesstringnoMáx. 500. No viaja al SII; se imprime en el PDF.
moneda, tasaCambiostring / decimalnoDefault PESO CL.
empresaSucursalId, actecoIds, empresaActecoIdPrincipaluuid(s)noPerfil de emisión. Si se omiten, el servidor elige casa matriz y actecos activos.
customFieldsobjetonoCampos definidos para tu empresa.

receptor:

CampoObligatorioLargo máx.Notas
rutFormato 12345678-5. Se normaliza automáticamente (se aceptan puntos, se guardan sin). RUT inválido → 400 emisor.rut-receptor-invalido.
razonSocial100
girono40Exigido por el SII en factura 33.
direccionno70Exigida por el SII en factura 33.
comunano20Exigida por el SII en factura 33.
ciudadno20
contactono80
rutSolicitano20
correosnoLista de correos adicionales a los que se envía copia (PDF + XML). No viaja al XML.

detalles[]:

CampoObligatorioLargo máx.Notas
nroLinea1..60.
nombreItem80
cantidad, precioUnitariocantidad > 0 (hasta 6 decimales), precioUnitario >= 0 redondeado a peso entero. Ver Aritmética.
descripcionItemno1000
codigoItemno35
tipoCodigono10
unidadMedidano4Ver la advertencia abajo.
descuentoMonto, recargoMontonoMontos, no porcentajes.
indExeno0 afecto, 1 exento, 2 no facturable (ej. propina), 6 no facturable negativo. Manda sobre el booleano legado indExento.

⚠️ unidadMedida tiene un máximo de 4 caracteres. Es un límite del formato del SII, no nuestro. Unidades corrientes como UNIDAD, KILOS o LITROS no caben: usá UN, KG, LT, MT, CJA. Si tu ERP exporta la unidad como texto largo, mapeala antes de llamar. Una línea con la unidad demasiado larga devuelve 400 emisor.detalle-largo-excedido y ninguna línea del documento se emite.

referencias[] (obligatorio en notas de crédito y débito emitidas a mano):

CampoObligatorioLargo máx.
nroLinea
tipoDocRef3
folioRef18
fechaRef
codRefrecomendado— (1 anula, 2 corrige texto, 3 corrige monto)
razonRefno90

Para anular un documento no armes la referencia a mano: usá POST /{id}/anular, que la resuelve el servidor. Para una devolución parcial, usá POST /{id}/nota-credito.

Ningún largo de esta sección se trunca. Exceder cualquiera de los de arriba —razonRef incluido— devuelve 400 antes de reservar folio, con el campo culpable en field. El único campo del sistema que sí se recorta en silencio es el motivo de POST /{id}/anular, que no es parte de este cuerpo.

Aritmética: cómo se calcula el documento

Los totales del documento — y los montoNeto / montoIva / montoTotal que devuelve la respuesta — los calcula el servidor, no vos. Si tu ERP usa otra aritmética, el total que te devolvemos no va a coincidir con el tuyo, y esa diferencia después impide anular el documento (emisor.montos-no-reproducibles, ver §11).

El precio unitario se redondea a peso entero (AwayFromZero) antes de calcular la línea:

montoLinea = round( cantidad × round(precioUnitario) − descuentoMonto + recargoMonto )

Si trabajás con precios de más de dos decimales, mandá el precio ya redondeado a peso. cantidad sí admite decimales (hasta 6, formato del SII); los montos del documento son pesos enteros.

Cero y negativos se rechazan: cantidad > 0, precioUnitario >= 0, y descuentoMonto y recargoMonto no negativos. Si no, 400 emisor.detalle-monto-invalido, con todas las líneas problemáticas listadas de una vez en el formato L[n] que usa el SII.

Única excepción: en nota de crédito (61) y nota de débito (56) se admite cantidad: 0 — es la línea "dummy" con la que el SII anula un documento o corrige un texto.

Ejemplos por tipo de documento

Todos los cuerpos de abajo van al mismo POST /api/public/v1/dte.

Factura afecta (33)

El caso base. giro, direccion y comuna del receptor son obligatorios (el SII rechaza la factura sin ellos, y para entonces el folio ya se consumió: por eso se validan antes).

json
{
  "tipoDte": 33,
  "receptor": {
    "rut": "96790240-3",
    "razonSocial": "Cliente Ejemplo S.A.",
    "giro": "Comercio al por mayor",
    "direccion": "Av. Providencia 1234, of. 501",
    "comuna": "Providencia",
    "ciudad": "Santiago",
    "contacto": "Marcela Ruiz - [email protected]",
    "correos": ["[email protected]"]
  },
  "formaPago": 2,
  "fechaVencimiento": "30-08-2026",
  "detalles": [
    {
      "nroLinea": 1,
      "codigoItem": "SRV-CONS-01",
      "tipoCodigo": "INT1",
      "nombreItem": "Consultoría técnica",
      "descripcionItem": "Horas de consultoría del mes de julio 2026",
      "cantidad": 1,
      "unidadMedida": "UN",
      "precioUnitario": 1000000
    },
    {
      "nroLinea": 2,
      "nombreItem": "Licencia de software",
      "cantidad": 3,
      "unidadMedida": "UN",
      "precioUnitario": 25000
    }
  ]
}

Totales que devuelve la respuesta:

CampoValor
montoNeto1 075 000
montoIva204 250
montoTotal1 279 250

formaPago: 2 (crédito) sin fechaVencimiento toma por defecto emisión + 30 días. Con formaPago: 1 (contado) o 3 (sin costo) no se genera vencimiento.

Factura exenta (34)

Mismos requisitos de receptor que la 33 (giro, direccion, comuna obligatorios).

⚠️ Marcá cada línea con indExe: 1. El servicio no lo deduce del tipo de documento: en una 34 el IVA siempre sale 0, así que una línea sin indExe suma a montoNeto y el documento queda con neto positivo e IVA cero — descuadrado, y el SII lo rechaza con el folio ya consumido. Es la trampa propia de este tipo.

json
{
  "tipoDte": 34,
  "receptor": {
    "rut": "77123456-9",
    "razonSocial": "Instituto de Formación Andes SpA",
    "giro": "Enseñanza",
    "direccion": "Manuel Montt 980",
    "comuna": "Ñuñoa"
  },
  "formaPago": 1,
  "detalles": [
    {
      "nroLinea": 1,
      "nombreItem": "Curso de capacitación exento",
      "cantidad": 1,
      "unidadMedida": "UN",
      "precioUnitario": 350000,
      "indExe": 1
    },
    {
      "nroLinea": 2,
      "nombreItem": "Material de estudio",
      "cantidad": 12,
      "unidadMedida": "UN",
      "precioUnitario": 15000,
      "indExe": 1
    }
  ]
}
CampoValor
montoNeto0
montoExento530 000
montoIva0
montoTotal530 000

Boleta electrónica (39) y boleta exenta (41)

Tres diferencias con la factura, todas propias del formato de boleta del SII:

  1. precioUnitario va NETO. El sistema le suma el IVA. En la boleta afecta, el precio que viaja al SII es el precio al público (con IVA); vos mandás el neto y el servicio multiplica por 1,19 al construir el documento. Si mandás el precio con IVA ya incluido, la boleta sale por un 19 % de más.
  2. giro, contacto, ciudad y rutSolicita del receptor se descartan. El receptor de boleta del SII es estricto y rechaza el envío completo si aparecen. direccion y comuna sí se emiten si vienen.
  3. indServicio es propio de la boleta: 1 periódico con domicilio, 2 periódico sin domicilio, 3 boletas de venta y servicios (default si se omite), 4 espectáculos.
json
{
  "tipoDte": 39,
  "indServicio": 3,
  "receptor": {
    "rut": "66666666-6",
    "razonSocial": "Consumidor final"
  },
  "detalles": [
    {
      "nroLinea": 1,
      "nombreItem": "Café de grano 250 g",
      "cantidad": 2,
      "unidadMedida": "UN",
      "precioUnitario": 1500
    },
    {
      "nroLinea": 2,
      "nombreItem": "Sándwich del día",
      "cantidad": 1,
      "unidadMedida": "UN",
      "precioUnitario": 4200
    }
  ]
}
CampoValorDe dónde sale
montoNeto7 2002 × 1 500 + 4 200
montoIva1 368
montoTotal8 568lo que paga el cliente en caja

Propinas y otros no facturables: una línea con indExe: 2 no genera neto ni IVA, y en la boleta suma al montoTotal (viaja además en montoNF). Es la diferencia con la factura, donde el no facturable queda fuera del montoTotal.

Boleta exenta (41): mismo cuerpo, con "tipoDte": 41 y indExe: 1 en todas las líneas (misma regla que la 34: el tipo no lo deduce solo). En la 41 el montoTotal incluye el componente no facturable, igual que en la 39.

indServicio no se valida: se escribe tal cual en el documento. Un valor fuera de rango no devuelve 400 acá — lo rechaza el SII después, con el folio ya consumido.

Guía de despacho (52)

Es el tipo con más campos propios, y los tres son exclusivos de él.

indTraslado — obligatorio, sin valor por defecto. Sin él: 400 emisor.ind-traslado-requerido. No se asume un default a propósito: emitir "traslado interno" por omisión falsearía el motivo del traslado ante el SII.

ValorSignificado¿Emitible?
1Operación constituye venta
2Ventas por efectuar
3Consignaciones
4Entrega gratuita
5Traslados internos
6Otros traslados no venta
7Guía de devolución
8Traslado para exportaciónno400 emisor.ind-traslado-exportacion-no-soportado
9Venta para exportaciónno400 emisor.ind-traslado-exportacion-no-soportado

8 y 9 exigen el bloque de aduana (puerto de embarque y desembarque, peso bruto, total de bultos) que esta operación no arma. Se rechazan antes de reservar folio, en vez de emitir un documento que el SII rechazaría con el folio ya quemado. Un valor fuera de 1..9 devuelve 400 emisor.ind-traslado-invalido.

tipoDespacho — opcional. 1 por cuenta del receptor, 2 por cuenta del emisor a instalaciones del cliente, 3 por cuenta del emisor a otras instalaciones. Fuera de 1..3: 400 emisor.tipo-despacho-invalido.

indTraslado y tipoDespacho solo existen en el 52. Mandarlos en otro tipo devuelve 400 (emisor.ind-traslado-no-aplica / emisor.tipo-despacho-no-aplica) — no se ignoran en silencio.

transporte — opcional, todos sus campos opcionales:

CampoLargo máx.Notas
patente8
rutTransportistaRUT chileno válido; si no, 400 emisor.transporte-rut-invalido.
rutChoferRUT chileno válido.
nombreChofer30
direccionDestino70
comunaDestino20
ciudadDestino20

rutChofer y nombreChofer van juntos o no van. Informar solo uno devuelve 400 emisor.transporte-chofer-incompleto: el formato del SII exige los dos hijos dentro del bloque del chofer. Exceder cualquiera de los largos devuelve 400 emisor.transporte-largo-excedido (no se trunca). El bloque de transporte se acepta en cualquier tipo salvo boletas, donde se descarta; en la práctica solo tiene sentido en la 52.

json
{
  "tipoDte": 52,
  "indTraslado": 1,
  "tipoDespacho": 2,
  "receptor": {
    "rut": "96790240-3",
    "razonSocial": "Cliente Ejemplo S.A.",
    "giro": "Comercio al por mayor",
    "direccion": "Av. Providencia 1234",
    "comuna": "Providencia"
  },
  "transporte": {
    "patente": "KXPR23",
    "rutTransportista": "76543210-3",
    "rutChofer": "13579246-2",
    "nombreChofer": "Pedro Salinas",
    "direccionDestino": "Camino a Melipilla 5600, bodega 3",
    "comunaDestino": "Maipú",
    "ciudadDestino": "Santiago"
  },
  "detalles": [
    {
      "nroLinea": 1,
      "codigoItem": "PROD-114",
      "nombreItem": "Caja de repuestos modelo A",
      "cantidad": 10,
      "unidadMedida": "CJ",
      "precioUnitario": 12000
    }
  ]
}
CampoValor
montoNeto120 000
montoIva22 800
montoTotal142 800

Guía de venta vs. traslado interno. Con indTraslado 1 o 2 la mercadería ya está vendida y el SII exige neto, tasa e IVA cuadrados: se calcula IVA sobre las líneas afectas, como en una factura. En un traslado interno (indTraslado: 5) lo habitual es marcar las líneas con indExe: 1, con lo que neto e IVA quedan en 0 y el documento solo mueve mercadería.

Nota de débito (56)

Exige al menos una referencia, y cada referencia exige codRef. Son dos validaciones distintas, con dos códigos distintos:

  • Sin referencias400 emisor.referencia-requerida.
  • Con referencia pero sin codRef (o con un codRef distinto de 1/2/3) → 400 emisor.referencia-codref-invalido, con todas las líneas problemáticas listadas.

A diferencia de la factura, en la nota de débito y en la de crédito giro, direccion y comuna del receptor son opcionales.

codRefSignificado
1Anula documento referenciado
2Corrige texto del documento referenciado
3Corrige montos

fechaRef debe ser la fecha de emisión del documento referenciado, y tiene que caer entre 2002-08-01 y 2050-12-31; fuera de rango es 400 emisor.referencia-fecha-fuera-de-rango.

json
{
  "tipoDte": 56,
  "receptor": {
    "rut": "96790240-3",
    "razonSocial": "Cliente Ejemplo S.A."
  },
  "referencias": [
    {
      "nroLinea": 1,
      "tipoDocRef": "33",
      "folioRef": "1048",
      "fechaRef": "2026-07-27",
      "codRef": 3,
      "razonRef": "Cobro de intereses por mora"
    }
  ],
  "detalles": [
    {
      "nroLinea": 1,
      "nombreItem": "Intereses por mora factura 1048",
      "cantidad": 1,
      "unidadMedida": "UN",
      "precioUnitario": 25000
    }
  ]
}
CampoValor
montoNeto25 000
montoIva4 750
montoTotal29 750

Nota de crédito (61)

Mismas reglas de referencia que la 56 (referencias obligatoria, codRef obligatorio en cada línea, receptor sin giro/direccion/comuna obligatorios).

Antes de armar una 61 a mano, mirá si te sirve un endpoint dedicado. Para anular un documento completo está POST /{id}/anular, y para una devolución parcial POST /{id}/nota-credito. En los dos casos la referencia la arma el servidor leyendo el documento original. Armarla a mano ya rechazó una anulación real: el cliente mandó fechaRef con la fecha de hoy en lugar de la fecha de emisión del original, el SII rechazó la nota y el folio se quemó igual.

La 61 a mano queda para lo que esos endpoints no cubren: por ejemplo corregir el texto de un documento (codRef: 2) o acreditar un documento que no emitiste por esta API.

json
{
  "tipoDte": 61,
  "receptor": {
    "rut": "96790240-3",
    "razonSocial": "Cliente Ejemplo S.A."
  },
  "referencias": [
    {
      "nroLinea": 1,
      "tipoDocRef": "33",
      "folioRef": "1048",
      "fechaRef": "2026-07-27",
      "codRef": 3,
      "razonRef": "Descuento comercial acordado post facturación"
    }
  ],
  "detalles": [
    {
      "nroLinea": 1,
      "nombreItem": "Descuento comercial factura 1048",
      "cantidad": 1,
      "unidadMedida": "UN",
      "precioUnitario": 50000
    }
  ]
}
CampoValor
montoNeto50 000
montoIva9 500
montoTotal59 500

Corregir solo el texto (codRef: 2) es el único caso donde se admite cantidad: 0: la nota no mueve montos, solo declara la corrección.

json
{
  "tipoDte": 61,
  "receptor": { "rut": "96790240-3", "razonSocial": "Cliente Ejemplo S.A." },
  "referencias": [
    {
      "nroLinea": 1,
      "tipoDocRef": "33",
      "folioRef": "1048",
      "fechaRef": "2026-07-27",
      "codRef": 2,
      "razonRef": "Corrige giro del receptor"
    }
  ],
  "detalles": [
    {
      "nroLinea": 1,
      "nombreItem": "Corrige giro del receptor",
      "cantidad": 0,
      "precioUnitario": 0
    }
  ]
}

Casos de borde

Ítems exentos mezclados con afectos

Usá indExe por línea: 0 afecto (default), 1 exento, 2 no facturable (propina, cobro por cuenta de terceros), 6 no facturable negativo. Cualquier otro valor devuelve 400 emisor.detalle-indexe-invalido (los códigos 3, 4 y 5 del formato del SII no están soportados).

indExento: true sigue funcionando por compatibilidad, pero equivale a indExe: 1 y manda indExe cuando vienen los dos. En integraciones nuevas usá solo indExe.

json
{
  "tipoDte": 33,
  "receptor": {
    "rut": "96790240-3",
    "razonSocial": "Cliente Ejemplo S.A.",
    "giro": "Comercio al por mayor",
    "direccion": "Av. Providencia 1234",
    "comuna": "Providencia"
  },
  "detalles": [
    {
      "nroLinea": 1,
      "nombreItem": "Servicio afecto a IVA",
      "cantidad": 1,
      "unidadMedida": "UN",
      "precioUnitario": 100000,
      "indExe": 0
    },
    {
      "nroLinea": 2,
      "nombreItem": "Servicio exento de IVA",
      "cantidad": 1,
      "unidadMedida": "UN",
      "precioUnitario": 40000,
      "indExe": 1
    }
  ]
}
CampoValor
montoNeto100 000
montoExento40 000
montoIva19 000
montoTotal159 000

Descuentos y recargos

Hay dos niveles, y no se comportan igual.

Por línea — descuentoMonto / recargoMonto. Son montos, nunca porcentajes, y el valor es el de la línea completa, no el de cada unidad. No pueden ser negativos. Se aplican dentro de la línea, con la fórmula de Aritmética.

json
{
  "tipoDte": 33,
  "receptor": {
    "rut": "96790240-3",
    "razonSocial": "Cliente Ejemplo S.A.",
    "giro": "Comercio al por mayor",
    "direccion": "Av. Providencia 1234",
    "comuna": "Providencia"
  },
  "detalles": [
    {
      "nroLinea": 1,
      "nombreItem": "Producto con descuento por volumen",
      "cantidad": 10,
      "unidadMedida": "UN",
      "precioUnitario": 5000,
      "descuentoMonto": 5000
    },
    {
      "nroLinea": 2,
      "nombreItem": "Servicio con recargo por urgencia",
      "cantidad": 1,
      "unidadMedida": "UN",
      "precioUnitario": 20000,
      "recargoMonto": 1500
    }
  ]
}

Línea 1: 10 × 5000 − 5000 = 45 000. Línea 2: 20 000 + 1 500 = 21 500.

CampoValor
montoNeto66 500
montoIva12 635
montoTotal79 135

Globales — descuentosGlobales[]. Máximo 20 entradas. Se aplican en cascada sobre las bases del documento, en el orden en que vienen: un segundo descuento del 10 % descuenta el 10 % de lo que quedó, no del total inicial.

CampoObligatorioValores
nroLinea1..20
tipoMovimiento"D" descuento, "R" recargo — otro valor: 400 emisor.dr-tipo-movimiento-invalido
tipoValor"%" porcentaje, "$" monto — otro valor: 400 emisor.dr-tipo-valor-invalido
valor≥ 0 — negativo: 400 emisor.dr-valor-negativo
glosanomáx. 45 → 400 emisor.dr-glosa-excede-45
indExeDRnoausente/0 = afecta la base afecta; 1 = la base exenta; 2 = no facturables (no toca ninguna base tributaria)

⚠️ Un documento con líneas afectas y exentas necesita una línea de descuento global por cada base. Con una sola entrada sin indExeDR solo se descuenta la parte afecta, y la exenta queda entera — el documento se emite igual, y el SII lo cuestiona porque los montos no le cuadran.

json
{
  "tipoDte": 33,
  "receptor": {
    "rut": "96790240-3",
    "razonSocial": "Cliente Ejemplo S.A.",
    "giro": "Comercio al por mayor",
    "direccion": "Av. Providencia 1234",
    "comuna": "Providencia"
  },
  "detalles": [
    {
      "nroLinea": 1,
      "nombreItem": "Servicio afecto",
      "cantidad": 1,
      "unidadMedida": "UN",
      "precioUnitario": 100000,
      "indExe": 0
    },
    {
      "nroLinea": 2,
      "nombreItem": "Servicio exento",
      "cantidad": 1,
      "unidadMedida": "UN",
      "precioUnitario": 40000,
      "indExe": 1
    }
  ],
  "descuentosGlobales": [
    {
      "nroLinea": 1,
      "tipoMovimiento": "D",
      "tipoValor": "%",
      "valor": 10,
      "glosa": "Descuento comercial 10% - afectos"
    },
    {
      "nroLinea": 2,
      "tipoMovimiento": "D",
      "tipoValor": "%",
      "valor": 10,
      "glosa": "Descuento comercial 10% - exentos",
      "indExeDR": 1
    }
  ]
}
CampoValorCálculo
montoNeto90 000100 000 − 10 %
montoExento36 00040 000 − 10 %
montoIva17 10019 % de 90 000
montoTotal143 100

En boleta (39/41) los descuentos globales se aplican sobre bases brutas, porque el detalle de la boleta va con IVA incluido. Un descuento global con tipoValor: "$" en una boleta tiene que expresarse con IVA; en porcentaje da lo mismo.

Receptor sin RUT y boleta a consumidor final

receptor.rut y receptor.razonSocial son siempre obligatorios, en todos los tipos. No hay forma de emitir sin receptor: tanto omitir el RUT como mandarlo con el dígito verificador equivocado devuelven 400 emisor.rut-receptor-invalido, con field: "receptor.rut" y sin tocar el folio (se valida por módulo 11 antes de reservarlo).

Ojo con el orden de las palabras: el código que vas a recibir es emisor.rut-receptor-invalido. Los códigos emisor.receptor-rut-requerido y emisor.receptor-rut-invalido existen en el código fuente, pero son inalcanzables desde POST /dte: el chequeo que devuelve emisor.rut-receptor-invalido corre antes y se los come. No programes contra ellos.

Cuando no hay cliente identificado —el caso normal de una boleta en punto de venta— se usa el RUT genérico del SII para consumidor final:

json
{
  "receptor": {
    "rut": "66666666-6",
    "razonSocial": "Consumidor final"
  }
}

razonSocial es texto libre: cualquier etiqueta legible sirve.

El formato del RUT es indiferente: 96790240-3, 96.790.240-3 y 967902403 se normalizan solos a la forma canónica. La k del dígito verificador se acepta en minúscula o mayúscula.

Otros RUT que conviene conocer: 55555555-5 es el receptor extranjero genérico y pertenece al flujo de exportación (§13), no a este endpoint.

Respuesta 201 — el camino normal

json
{
  "id": "b5f8c2e1-7d93-4e8f-a12b-9c4d5e6f7a8b",
  "tipoDte": 33,
  "folio": 1048,
  "ambiente": "Certificacion",
  "fechaEmision": "2026-07-27",
  "montoNeto": 1000000,
  "montoExento": 0,
  "montoIva": 190000,
  "montoTotal": 1190000,
  "montoNF": 0,
  "estadoSii": "Pendiente",
  "glosaSii": null,
  "trackId": null,
  "transactionId": "a3bb189e-8bf9-3888-9912-ace4e6543002",
  "creadoEn": "2026-07-27T14:30:12Z",
  "offsetUtcHoras": -4,
  "glosa": "Consultoría técnica",
  "receptorRut": "96790240-3",
  "receptorRznSoc": "Cliente Ejemplo S.A.",
  "montoAcreditado": 0,
  "estadoAcreditacion": "SinAcreditar",
  "anuladoEn": null,
  "notaCreditoId": null,
  "plantillaId": null,
  "cotizacionIds": null,
  "customFields": null,
  "customFieldLabels": null,
  "xmlBlobPath": "...",

  "ted": "<TED version=\"1.0\"><DD>…</DD><FRMT algoritmo=\"SHA1withRSA\">…</FRMT></TED>",
  "xmlUrl": "https://api-publica.dtecomges.cl/api/public/v1/descargas/dte/b5f8c2e1-.../xml?t=Vg9m…&exp=1769472000&e=7c1e…&a=Certificacion",
  "pdfUrl": "https://api-publica.dtecomges.cl/api/public/v1/descargas/dte/b5f8c2e1-.../pdf?t=Kq2p…&exp=1769472000&e=7c1e…&a=Certificacion"
}

Campos que conviene conocer:

CampoPara qué sirve
idGuardalo siempre. Es el identificador de todas las operaciones posteriores.
folioDefinitivo. Podés imprimirlo, guardarlo y mostrarlo.
estadoSiiEstado ante el SII, como texto. Ver §9.
glosaSiiEl motivo que informó el SII cuando el documento fue rechazado o quedó con reparos. null mientras no hay una respuesta con glosa. Es el único lugar donde leer por qué un documento quedó Rechazado.
trackIdIdentificador del envío ante el SII. null hasta que el documento sale.
glosaEl primer nombreItem truncado a 80. Es contenido del documento, no una respuesta del SII — no confundir con glosaSii.
montoNFMonto no facturable (líneas con indExe 2 o 6, ej. propina).
montoAcreditado / estadoAcreditacionCuánto se acreditó por notas de crédito parciales, y en qué estado quedó (SinAcreditar / Parcial / AcreditadoTotal / Anulado). Ver §12.
anuladoEn / notaCreditoIdMarca de anulación completa. null = vigente.
offsetUtcHorasOffset de Chile al emitir. Con creadoEn (UTC) reconstruís la hora local exacta.
customFields / customFieldLabelsCampos personalizados definidos para tu empresa.
plantillaId / cotizacionIdsTrazabilidad interna del portal. En emisión por API vienen null.
xmlBlobPathReferencia interna de almacenamiento. No la uses: no es una URL descargable. Para el XML, usá GET /{id}/xml.
tedEl bloque <TED> del documento, como XML. Es el timbre electrónico ya firmado con el CAF — el mismo que va dentro del XML y que se imprime como código PDF417. Sirve si armás tu propia representación impresa. Es el XML del timbre, no la imagen del barcode: el código de barras lo dibuja quien imprime. Solo viene en la emisión — ver abajo.
xmlUrl / pdfUrlEnlaces de descarga firmados, que se abren sin API Key. Son para reenviárselos al comprador. Vencen. Ver Enlaces firmados para el comprador.

ted solo viaja en la respuesta de la emisión. Al emitir, el timbre ya está en memoria y devolverlo no cuesta nada. En GET /dte/{id} llega siempre null a propósito: el TED vive en almacenamiento y leerlo penalizaría una consulta que hoy no toca disco. Si lo necesitás, guardalo cuando emitís — no se puede recuperar después por esta API. Para tener el timbre más tarde, descargá el XML completo, que lo contiene.

En exportación (POST /exportacion) el campo existe en el cuerpo pero llega siempre null: ese camino todavía no expone el TED. El XML de la exportación sí lo trae.

Lo que todavía no ocurrió en un 201 es el envío al SII.

Respuesta 202 — la emisión quedó esperando folio

Este no es un caso raro: es el caso normal de un contribuyente nuevo. El SII entrega folios de a poco a quien recién empieza (a veces de a uno), así que al emitir varios documentos seguidos es esperable quedarse sin folio disponible en medio.

Cuando eso pasa, el documento ya fue validado y en vez de descartarlo lo encolamos: pedimos folios al SII y emitimos apenas llegan. La respuesta es 202 con un ticket:

json
{
  "ticketId": "6f1c2b90-33a1-4a5e-8b7d-0c2e5f9a1d44",
  "estado": "Pendiente",
  "tipoDte": 33,
  "ambiente": "Certificacion",
  "posicionEnCola": 1,
  "intentos": 0,
  "documentoId": null,
  "codigoError": null,
  "mensaje": "No hay folios disponibles; se solicitaron al SII.",
  "esErrorTerminal": false,
  "resumen": "Factura 33 — Cliente Ejemplo S.A.",
  "solicitadoEn": "2026-07-27T14:30:12Z",
  "terminadoEn": null,
  "offsetUtcHoras": -4
}

Fijate en lo que no trae: no hay id, no hay folio.

⚠️ Tres formas clásicas de romperse acá:

  1. Leer .id de la respuesta sin mirar el status. Con un 202 te queda undefined o null, y como es un 2xx tu manejo de errores no se entera: guardás un documento fantasma. Ramificá siempre por el status code (201 vs 202), no por la presencia de campos. Para los errores, en cambio, el campo estable es el code (§15).
  2. Tratar todo lo que no sea 201 como un fallo y reemitir. El documento ya está encolado: reemitir consume otro folio, salvo que mandes el mismo transactionId.
  3. Seguir el header Location. No llega: la respuesta pública no reenvía ese header. Leé el ticketId del cuerpo.

Resolver el ticket

GET /api/public/v1/dte/pendientes/{ticketId}        (scope dte:read)

Devuelve el mismo cuerpo, actualizado. Los estados posibles de estado son:

estadoQué significaQué hacer
PendienteEn la fila, todavía no se intentó.Seguir consultando.
ProcesandoSe está emitiendo ahora.Seguir consultando.
CompletadoListo. Trae documentoId.Consultá GET /dte/{documentoId} y guardá ese id como el del documento.
ErrorFalló. Mirá codigoError y esErrorTerminal.Si esErrorTerminal es true, no reintentes: hay que actuar (típicamente, gestionar el timbraje ante el SII). Si es false, el sistema sigue reintentando solo.
CanceladoLa emisión fue cancelada.No se va a emitir.

Ritmo de consulta sugerido: igual que el polling de estado — 5 s, 10 s, 20 s, 40 s, 60 s. Consume la misma cuota de rate limit (§17).

Listar los tickets abiertos

GET /api/public/v1/dte/pendientes?estado={n}&pagina=1&tamano=50      (scope dte:read)
ParámetroDefaultNotas
estado(todos)Byte del estado.
pagina1Menor a 1 se corrige a 1.
tamano50Máximo 200; fuera de rango vuelve a 50.

Devuelve un array plano, sin envoltorio de paginación: paginá hasta recibir menos elementos que tamano. Si la cola está deshabilitada en el despliegue, responde 200 con un array vacío (no un error).

Cuando el SII no autoriza timbraje

Si el SII directamente no autoriza folios para ese tipo de documento, la emisión no se encola: falla de inmediato con 422 caf.sin-autorizacion. Es terminal — reintentar no cambia nada, hay que gestionarlo ante el SII.


9. Consultar el estado (modelo asíncrono)

GET /api/public/v1/dte/{id}          (scope dte:read)

Devuelve el mismo cuerpo que la emisión, con estadoSii, glosaSii y trackId actualizados.

Pendiente  → firmado y persistido, todavía no salió al SII (trackId null)
Enviado    → el SII lo recibió, ya hay trackId, falta el veredicto
Aceptado   | AceptadoConReparos | Rechazado   → estado final
ErrorEnvio → error técnico de envío; reintenta solo desde nuestra cola
Anulado    → anulado por nota de crédito

Si llegás a Rechazado o AceptadoConReparos, el motivo está en glosaSii. Es el campo que explica qué objetó el SII.

Ritmo de polling sugerido: primer intento a los 5 s, después backoff (10 s, 20 s, 40 s, 60 s). Cortá en cuanto llegues a un estado final. No hagas polling en loop cerrado: el rate limit es por key y lo vas a topar (§17).

Se puede evitar buena parte de este polling recibiendo un aviso firmado en tu servidor. El contrato está en §18, y la suscripción la das de alta vos, desde el portal.

Aislamiento entre empresas: un id que pertenece a otra empresa responde 404, no 403. La API no confirma la existencia de documentos ajenos.

⚠️ El 404 de esta ruta y el de GET /{id}/xml llegan con el cuerpo vacío, sin code ni title. No intentes leer code de un 404: ramificá por el status.

⚠️ No agregues parámetros de query que no estén documentados. La query se reenvía tal cual al servicio de emisión, y los dos parámetros que existen ahí se comportan distinto:

  • ?empresaId=... falla fuerte: 403 dte.cross-tenant-no-autorizado. Una API key nunca puede consultar otra empresa.
  • ?ambiente=Produccion falla en silencio, y eso es peor. El override de ambiente solo lo honra un godadmin; para una API key se ignora sin avisar y la consulta se resuelve en el ambiente de la key. Con una key de certificación vas a recibir 200 con el documento de certificación (o 404 si ese id no existe ahí), nunca un error que te diga que el parámetro no se aplicó. Si tu código lo manda "por las dudas", vas a leer datos del ambiente equivocado creyendo que leíste producción.

Ninguno de los dos hace falta: la key ya define empresa y ambiente, y no hay forma de cambiarlos desde el request.


10. Descargar XML y PDF

XML firmado

GET /api/public/v1/dte/{id}/xml        → 200 application/xml

Devuelve el XML del DTE firmado, con su TED. La firma está garantizada: sin certificado digital activo el documento no se llega a emitir (§8). Es el archivo que corresponde archivar y el que se le entrega al receptor para intercambio.

El XML se sirve en ISO-8859-1 y así lo declara su cabecera. Si lo vas a procesar, respetá esa codificación: reinterpretarlo como UTF-8 rompe las tildes y la eñe, y con eso invalida su propia firma.

PDF

GET /api/public/v1/dte/{id}/pdf?formato=carta   → 200 application/pdf
formatoResultado
omitidoCarta, siempre — también en boletas 39/41.
cartaHoja carta.
80mmTicket térmico.

Si necesitás el ticket térmico de una boleta, pedilo explícitamente con ?formato=80mm. Omitir el parámetro no lo elige por vos.

⚠️ Estos dos endpoints requieren API Key. No los pegues en un correo al receptor: quien tenga la key puede emitir a nombre de tu cliente. Para hacerle llegar el documento al comprador existen los enlaces firmados, acá abajo.

Ambos exigen el scope dte:read.

Enlaces firmados para el comprador

El problema que resuelven. Al comprador hay que entregarle su factura, y tu API Key no puede salir de tu backend. Hasta acá la única salida era descargar el archivo y redistribuirlo vos.

Por eso la respuesta de emisión trae xmlUrl y pdfUrl: enlaces que se abren sin API Key, listos para reenviar por correo o incrustar en tu portal de clientes. Lo que autoriza no es una credencial sino la firma del propio enlace, que lleva vencimiento.

GET /api/public/v1/descargas/dte/{id}/xml?t=…&exp=…&e=…&a=…
GET /api/public/v1/descargas/dte/{id}/pdf?t=…&exp=…&e=…&a=…&formato=carta
GET /api/public/v1/descargas/exportacion/{id}/xml?t=…&exp=…&e=…&a=…
GET /api/public/v1/descargas/exportacion/{id}/pdf?t=…&exp=…&e=…&a=…

Usá la URL tal como viene. No la armes vos, no le cambies parámetros, no la re-firmes: el token cubre el documento y el recurso, así que cualquier retoque la invalida. Los parámetros:

ParámetroQué es
tLa firma. Es la credencial: tratala como un secreto mientras esté vigente.
expVencimiento, en segundos unix. Podés leerlo para saber hasta cuándo sirve el enlace sin tener que probarlo.
e, aEmpresa y ambiente. Son criterio de búsqueda, no autorización: alterarlos solo consigue un 404.
formatoSolo en el PDF de DTE nacional: carta (default) o 80mm. La exportación no lo admite.

Dónde aparecen los tres campos

ted, xmlUrl y pdfUrl viajan en el cuerpo de cuatro respuestas:

RespuestatedxmlUrl / pdfUrl
POST /dte201el TED del documentofirmadas
GET /dte/{id}200null (ver §8)firmadas, con vencimiento nuevo
POST /exportacion201null (exportación no lo expone)firmadas
GET /exportacion/{id}200nullfirmadas, con vencimiento nuevo

Dónde no aparecen:

  • POST /dte202. El ticket todavía no tiene documento: no hay nada que firmar ni que descargar. Cuando el ticket se resuelve, consultá GET /dte/{documentoId} y ahí sí vienen.
  • POST /dte/{id}/anular y POST /dte/{id}/nota-credito. El cuerpo trae la nota de crédito emitida, pero sin enlaces. Si necesitás mandarle la NC al comprador, consultala aparte con GET /dte/{notaCreditoId} y usá los enlaces de esa respuesta.
  • En cualquier respuesta de error.

Cuánto duran y qué pasa al vencer

30 días, por defecto, contados desde que el enlace se firma — no desde que se emitió el documento. En la respuesta de emisión son lo mismo; en la de una consulta posterior, el reloj arranca en esa consulta. El valor exacto viaja en exp, así que no hace falta suponerlo.

Es configuración del despliegue, no del cliente: si necesitás otra ventana, hablalo con soporte.

Pasado el vencimiento el enlace devuelve 403 descarga.token-vencido y deja de servir para siempre — no hay período de gracia.

Renovar un enlace vencido es gratis: volvé a consultar el documento. GET /api/public/v1/dte/{id} (con tu API Key) devuelve xmlUrl y pdfUrl recién firmadas, con un vencimiento nuevo contado desde ese momento. Lo mismo vale para GET /api/public/v1/exportacion/{id}. No hace falta reemitir nada ni pedir nada a soporte.

Por eso conviene generar el enlace cuando el comprador lo pide, y no guardar el de la emisión para reenviarlo meses después.

Errores

codeHTTPCuándo
descarga.token-invalido403Falta t, está mal formado, o no corresponde a ese documento y ese recurso. También lo devuelve el enlace del XML si intentás usarlo para el PDF, y viceversa.
descarga.token-vencido403La firma es correcta pero exp ya pasó.

Ambos usan el mismo cuerpo de error que el resto de la API (§15), con field: "t". Con un token inválido la respuesta es idéntica exista o no el documento: el token se valida antes de consultar nada, así que probar ids al azar no revela cuáles son reales.

Un 404 acá significa lo de siempre: el documento no existe o no pertenece a esa empresa.

Seguridad: lo que hay que asumir

⚠️ La URL da acceso al documento a cualquiera que la tenga, hasta que vence. No hay login ni segundo factor detrás: el enlace es la llave. Eso es exactamente lo que lo hace útil para mandárselo al comprador, y también lo que obliga a tratarlo con cuidado.

  • No la publiques en una página indexable, ni en un repositorio, ni en un ticket público. Un buscador que la encuentre expone la factura de tu cliente.
  • Asumí que un reenvío de correo, un log de proxy o una captura de pantalla la propagan. El vencimiento existe justamente porque eso pasa.
  • Del lado nuestro, el token no queda archivado: se enmascara antes de guardar la traza de la petición.
  • No se puede revocar un enlace suelto. No hay ruta para eso, ni por API ni en el portal: el único corte disponible es rotar el secreto de firma del ambiente, lo que invalida de golpe todos los enlaces vigentes de todas tus emisiones. Si un enlace se filtró y el impacto lo justifica, escribí a soporte para evaluarlo; si no, el enlace deja de servir al vencer.

Cuota

Estas rutas sí consumen rate limit (§17) — son públicas, y sin límite serían un vector de abuso. Como no llevan API Key, la cuota se cuenta por IP del visitante, no contra la de tu integración: los compradores que abren sus facturas no te gastan el presupuesto de emisión.

Si los campos llegan en null

xmlUrl y pdfUrl vienen null cuando la funcionalidad no está configurada en ese ambiente (le falta el secreto de firma al despliegue). En ese caso los cuatro endpoints de descarga rechazan todo enlace con 403 descarga.token-invalido.

No es un error de tu request y no se arregla reintentando. Programá para el caso — if (respuesta.xmlUrl) — y si esperabas tener la funcionalidad, pedila a soporte. El resto de la emisión funciona igual: el documento se emitió, y GET /dte/{id}/xml con tu API Key sigue siendo el camino de siempre.


11. Anular un documento

POST /api/public/v1/dte/{id}/anular
X-Api-Key: pk_test_...
Content-Type: application/json     (cuerpo opcional)

Anular es emitir una Nota de Crédito tipo 61 que referencia al documento original con CodRef=1 ("anula documento completo") y marcarlo como anulado. Es una sola llamada.

¿Anular o acreditar parcial? /anular deja el documento sin efecto por su total. Si lo que querés es devolver parte del documento (unas líneas, o un monto) y que el resto siga vigente, no es esto: es POST /{id}/nota-credito.

Lo importante: la referencia la arma el servidor

El cliente no manda tipoDocRef, folioRef ni fechaRef. El servidor lee el documento original y arma la referencia con su tipo, su folio y su fecha de emisión real.

Esto no es una comodidad, es la razón de existir del endpoint. Armar la NC a mano ya provocó una anulación rechazada por el SII: el cliente mandó FchRef con la fecha de hoy en vez de la fecha de emisión del original. El SII rechazó la nota de crédito y el folio se quemó igual.

La NC además espeja el cuerpo del original (detalles, impuestos adicionales, descuentos y recargos globales) para que los montos cuadren peso a peso.

Cuerpo (opcional)

json
{
  "motivo": "Error en el RUT del receptor"
}
CampoNotas
motivoGlosa que viaja como RazonRef al SII. Máx. 90 caracteres; si es más largo se trunca, no falla. Si se omite: "Anula documento".
transactionIdAceptado por compatibilidad, no influye en nada. Ver la nota de idempotencia.

El cuerpo entero es opcional: POST sin body es válido.

Idempotencia: reintentar es seguro

La anulación es idempotente por documento. La clave es el {id} de la URL, no un campo que mandes vos. Consecuencia práctica:

  • Reintentar el mismo POST (timeout de red, retry automático de tu cliente HTTP, doble click de un usuario) devuelve la misma nota de crédito.
  • No se emite una segunda NC. No se quema otro folio.
  • No necesitás mandar transactionId para que esto funcione — de hecho, mandarlo no cambia nada.

Respuesta 201

json
{
  "documentoAnulado": {
    "id": "b5f8c2e1-7d93-4e8f-a12b-9c4d5e6f7a8b",
    "tipoDte": 33,
    "folio": 1048,
    "fechaEmision": "2026-07-27",
    "estadoSii": "Aceptado"
  },
  "notaCredito": {
    "id": "7c1e9a44-2f60-4a1d-8e33-5b0c9d2f1a77",
    "tipoDte": 61,
    "folio": 312,
    "montoTotal": 1190000,
    "estadoSii": "Pendiente",
    "trackId": null
  }
}

Dos cosas que suelen confundir:

  1. documentoAnulado.estadoSii sigue diciendo Aceptado. No es un bug. Para el SII el documento original sigue aceptado: la anulación es comercial (una NC 61 con CodRef=1), no un cambio de estado en el SII. Lo que marca la anulación es el campo anuladoEn del documento (visible en GET /dte/{id}), junto con notaCreditoId.
  2. La nota de crédito nace Pendiente, como cualquier emisión: viaja al SII en segundo plano. Si querés confirmar que el SII la aceptó, hacé polling sobre GET /api/public/v1/dte/{notaCredito.id}.

Qué se puede anular

CondiciónRegla
Tipo33, 34, 39, 41, 52, 56. Una NC 61 no se anula con otra NC; exportación tiene su propio flujo.
Estado SIIEnviado, Aceptado o AceptadoConReparos. Un documento que todavía no salió al SII (Pendiente) o que fue rechazado no se anula.
FoliosTiene que haber CAF vigente de tipo 61.
AmbienteLa NC se emite en el mismo ambiente del documento. No se acepta override.

Errores propios de la anulación

Ver la tabla completa en §15; los específicos son emisor.documento-ya-anulado, emisor.tipo-no-anulable, emisor.estado-no-anulable, emisor.sin-folios-nc, emisor.montos-no-reproducibles, emisor.anulacion-nc-descartada y emisor.anulacion-marca-fallida.


12. Devolución parcial: notas de crédito por línea o por monto

Cuando el cliente devuelve parte de lo facturado —dos de cinco unidades, un descuento posterior, o hay que corregir un dato sin tocar montos— no corresponde anular. Corresponde una nota de crédito parcial, con CodRef=3 (o CodRef=2 si solo se corrige texto), que rebaja el documento sin dejarlo sin efecto.

POST /{id}/anularPOST /{id}/nota-credito
Referencia SIICodRef=1 (anula documento completo)CodRef=3 (corrige monto) — salvo el modo texto, que usa CodRef=2 (corrige texto)
EfectoEl documento queda anulado (anuladoEn se llena)El documento sigue vigente por el saldo restante
MontoEl total del documentoEl que vos indiques, hasta el saldo disponible
VecesUna sola vez por documentoVarias notas parciales sobre el mismo documento
IdempotenciaAutomática, por documentoPor transactionId que mandás vos
Referencia al originalLa arma el servidorLa arma el servidor
Scopedte:emit:61dte:emit:61

El flujo recomendado es siempre el mismo: consultar el saldo, después emitir.

12.1 Consultar el saldo acreditable

GET /api/public/v1/dte/{id}/acreditable        (scope dte:read)

Te dice qué queda disponible del documento y cómo se puede acreditar. Sirve especialmente si tu ERP no lleva su propio registro de devoluciones: el servidor ya lo lleva, y así no se desincronizan.

json
{
  "documento": {
    "id": "b5f8c2e1-7d93-4e8f-a12b-9c4d5e6f7a8b",
    "tipoDte": 33,
    "folio": 1048,
    "fechaEmision": "2026-07-27",
    "montoNeto": 1000000,
    "montoExento": 0,
    "montoTotal": 1190000,
    "montoAcreditado": 200000,
    "saldoAcreditable": 800000,
    "estadoAcreditacion": "Parcial",
    "porcentajeAcreditado": 20
  },
  "puedeAcreditar": true,
  "motivoNoAcreditar": null,
  "modosDisponibles": ["lineas", "monto", "texto"],
  "advertenciaPlazo": null,
  "lineas": [
    {
      "nroLinea": 1,
      "nombreItem": "Consultoría técnica",
      "descripcionItem": null,
      "codigoItem": null,
      "unidadMedida": null,
      "indExe": 0,
      "cantidadOriginal": 5,
      "precioUnitario": 200000,
      "montoItem": 1000000,
      "montoEfectivo": 1000000,
      "cantidadAcreditada": 1,
      "montoAcreditado": 200000,
      "cantidadDisponible": 4,
      "montoDisponible": 800000,
      "acreditable": true,
      "motivoNoAcreditable": null
    }
  ],
  "notasCreditoPrevias": [
    {
      "id": "…", "folio": 310, "fechaEmision": "2026-07-28",
      "montoTotal": 238000, "montoNetoImputado": 200000,
      "codRef": 3, "razonRef": "Devolución de 1 unidad", "estadoSii": "Aceptado",
      "lineasImputadas": [{ "nroLineaOrigen": 1, "cantidad": 1, "monto": 200000 }]
    }
  ]
}

Lo que hay que entender de esta respuesta:

CampoQué significa
documento.saldoAcreditableEl saldo va en NETO, no en el total con IVA: (montoNeto + montoExento) − montoAcreditado. Si acreditás por monto, ese monto se compara contra este número.
documento.estadoAcreditacionSinAcreditar / Parcial / AcreditadoTotal / Anulado. AcreditadoTotal no es lo mismo que Anulado: el primero es la acumulación de notas parciales (el SII nunca vio una anulación), el segundo es una anulación explícita.
puedeAcreditar / motivoNoAcreditarSi es false, el motivo viene en texto legible.
modosDisponiblesQué modos acepta este documento hoy. Puede no incluir lineas (ver abajo).
advertenciaPlazoTexto de advertencia cuando pasaron más de 90 días desde la emisión (plazo del Art. 70 del DL 825 para rebajar el débito fiscal). No bloquea: la nota se emite igual, pero fuera de plazo no da derecho a la rebaja. null si está en plazo.
lineas[].montoDisponible / cantidadDisponibleLo que queda por acreditar de cada línea. Es contra esto que se valida el modo lineas.
lineas[].montoEfectivoEl neto real que la línea aporta. null en líneas no facturables o todavía sin procesar.
notasCreditoPreviasHistorial de lo ya acreditado, con el detalle de qué línea imputó cada nota.

Si modosDisponibles no incluye "lineas", es porque los montos de las líneas de ese documento no son reproducibles a partir de cantidad × precio (típico de documentos importados de otro sistema). Podés acreditar igual, por monto.

⚠️ Un 404 emisor.acreditacion-no-habilitada en esta ruta NO significa que la ruta no exista: significa que el módulo de notas de crédito parciales está apagado para este despliegue. Si lo necesitás, pedilo a soporte.

12.2 Emitir la nota de crédito parcial

POST /api/public/v1/dte/{id}/nota-credito        (scope dte:emit:61)
Content-Type: application/json
json
{
  "modo": "lineas",
  "motivo": "Devolución de 2 unidades",
  "lineas": [
    { "nroLinea": 1, "cantidad": 2 }
  ],
  "transactionId": "1f0a7d2c-5c3e-4c1f-9b6a-2d8e4f0b7c31"
}
CampoObligatorioNotas
modo"lineas", "monto" o "texto". Otro valor → 400 emisor.modo-invalido.
motivonoGlosa que viaja como RazonRef (máx. 90, se trunca).
lineassolo en modo lineas[{ nroLinea, cantidad }]. La cantidad se valida contra cantidadDisponible.
montosolo en modo montoMonto neto a acreditar. Se valida contra saldoAcreditable.
transactionIdno, pero recomendadoVer la nota de idempotencia.

Los tres modos:

ModoPara quéEfecto sobre el saldo
lineasDevolución de ítems concretos.Descuenta de cada línea indicada.
montoRebaja comercial, descuento posterior.Descuenta del saldo del documento, sin imputar a líneas.
textoCorregir datos (razón social, giro, dirección) sin mover montos. Emite con CodRef=2, no CodRef=3.Ninguno: emite una nota por monto 0. Se puede usar incluso sobre un documento ya acreditado por completo.

La referencia al documento original la arma el servidor, igual que en /anular. Vos nunca mandás tipoDocRef, folioRef ni fechaRef.

12.3 Idempotencia: acá transactionId SÍ importa

A diferencia de /anular —donde un documento se anula a lo sumo una vez, así que la clave se deriva del documento— acá pueden existir N notas parciales legítimas sobre el mismo folio, incluso idénticas (dos devoluciones de 1 unidad el mismo día). El servidor no puede distinguir un reintento de una devolución nueva.

  • Mandá un transactionId propio, generado antes de la primera llamada. Con él, reintentar devuelve la misma nota; sin él, un reintento tras un timeout puede emitir una segunda nota y acreditar de más.
  • Si se omite, el servidor genera uno interno: la operación funciona, pero perdés la protección contra reintentos.
  • El guard de saldo sigue impidiendo acreditar más de lo disponible en cualquier caso.

12.4 Respuesta 201

json
{
  "notaCredito": { "id": "…", "tipoDte": 61, "folio": 313, "montoTotal": 476000, "estadoSii": "Pendiente", "…": "…" },
  "documentoOrigen": {
    "id": "b5f8c2e1-…", "tipoDte": 33, "folio": 1048,
    "montoAcreditado": 600000, "saldoAcreditable": 400000,
    "estadoAcreditacion": "Parcial", "porcentajeAcreditado": 60
  },
  "imputaciones": [
    { "nroLineaOrigen": 1, "cantidad": 2, "monto": 400000 }
  ]
}

notaCredito tiene el mismo formato que cualquier documento emitido (§8), incluido su id para hacerle polling. documentoOrigen viene con el saldo ya actualizado. En modo monto, imputaciones trae una sola entrada con nroLineaOrigen: null.

12.5 Errores

codeHTTPQué pasóQué hacer
emisor.acreditacion-no-habilitada404El módulo está apagado en este despliegue.Pedirlo a soporte. No es una ruta inexistente.
emisor.documento-no-encontrado404El id no existe o es de otra empresa.Verificar el id.
emisor.modo-invalido400modo no es lineas, monto ni texto.Corregir.
emisor.tipo-no-acreditable400El tipo no admite nota de crédito por esta vía. Acreditables: 33, 34, 39, 41, 52, 56.No reintentar.
emisor.estado-no-acreditable400El documento no está Enviado, Aceptado ni AceptadoConReparos, o no tiene montos que acreditar.Si está Pendiente, esperar a que salga al SII.
emisor.acreditacion-sin-saldo409Ya fue acreditado por completo.Solo queda el modo texto.
emisor.documento-ya-anulado409El documento fue anulado; no se acredita.Ninguna acción.
emisor.documento-descartado409El documento fue descartado por administración.Escalar a soporte.
emisor.acreditacion-excede-saldo400El monto o la cantidad pedida supera lo disponible. field indica la línea o monto.Consultar /acreditable y ajustar. No reserva folio.
emisor.acreditacion-en-curso409Hay otra petición emitiendo con el mismo transactionId ahora mismo.Reintentar en unos segundos.
emisor.acreditacion-replay-inconsistente409Ese transactionId ya acreditó, pero su nota no se pudo recuperar.No reintentar. Escalar a soporte con el transactionId.
emisor.sin-folios-nc400 / 409Sin folios de tipo 61.Ver §15.12.
emisor.ambiente-invalido400ambiente no reconocido.No mandarlo.
dte.permiso-faltante403Falta el scope dte:emit:61.Agregar el scope.

13. Exportación (110 / 111 / 112)

POST /api/public/v1/exportacion
GET  /api/public/v1/exportacion/{id}
GET  /api/public/v1/exportacion/{id}/xml
GET  /api/public/v1/exportacion/{id}/pdf

Mismo modelo de autenticación y de errores, pero es un contrato distinto: el receptor es extranjero, los montos van en moneda extranjera y hay un bloque de aduana obligatorio.

Diferencias que importan de entrada:

  • El receptor extranjero usa el RUT genérico 55555555-5.
  • Si la moneda no es peso chileno, el servidor genera automáticamente el bloque de totales en CLP equivalente a partir de la tasa de cambio.
  • Los datos de aduana (país de destino, puerto de embarque y desembarque, modalidad y cláusula de venta, vía de transporte, bultos) salen de catálogos oficiales de aduana. Los códigos no son de invención libre.
  • Los códigos de error usan el prefijo exp. en vez de emisor..
  • estadoSii es un string, igual que en el DTE nacional ("Pendiente", "Enviado", "Aceptado", "AceptadoConReparos", "Rechazado", "Anulado", "ErrorEnvio"). No compares contra números.
  • El PDF de exportación es solo Carta y no admite ?formato=: el bloque de aduana, arancel, moneda extranjera y receptor extranjero no caben en un ticket de 80 mm.
  • Anulación: una exportación no se anula con POST /dte/{id}/anular ni con POST /exportacion/{id}/anular (esa ruta no existe). Se emite una NC de exportación (112) referenciando el original.
  • Enlaces firmados: POST /exportacion y GET /exportacion/{id} traen xmlUrl y pdfUrl igual que el DTE nacional, apuntando a /descargas/exportacion/.... Mismas reglas de vencimiento y de seguridad (§10). El PDF firmado tampoco admite ?formato=.
  • ted llega siempre null en exportación: ese camino todavía no expone el timbre por separado. Está dentro del XML.

Los catálogos de aduana todavía no tienen endpoint público. Hoy se consultan desde el portal. Si necesitás integrarte a exportación, pedí el volcado de los catálogos a soporte — ver Qué no existe todavía.

El contrato campo por campo del cuerpo de exportación está en la referencia OpenAPI (/docs/reference, sección Exportación), generada del mismo contrato que valida el servidor.


14. Paso a producción (go-live)

Emitir con una key pk_live_* no es solo cambiar la key. Son tres pasos, y dos dependen de nosotros.

Paso 1 — Certificación ante el SII, por cada tipo de documento

El SII exige un proceso de certificación por contribuyente y por tipo de DTE. Lo ejecuta el equipo de Comges; el integrador no lo dispara por API, y no hay endpoint público para consultarlo ni iniciarlo.

Mientras un tipo no esté aprobado, emitirlo en Producción devuelve 403 cert.tipo-no-aprobado-prod, tipo por tipo. Es decir: podés tener la 33 aprobada y la 61 no, y descubrirlo recién al intentar tu primera anulación real.

Pedí la certificación de todos los tipos que vas a emitir, incluida la 61 si vas a anular o acreditar. Es el olvido más común.

Si la verificación de certificación no responde, la emisión falla con 503 cert.servicio-no-disponible: eso es reintentable con backoff.

Paso 2 — Cambiar el ambiente del usuario

El ambiente de la key se hereda del usuario que la crea. Para que un usuario pueda crear keys de producción, un administrador de la empresa (con el permiso empresa:usuarios:ambiente) tiene que cambiarle el ambiente operativo desde el portal.

Ese cambio cierra las sesiones abiertas de ese usuario: tiene que volver a entrar.

Paso 3 — Crear una API Key nueva

⚠️ Rotar una key pk_test_ NUNCA la convierte en pk_live_. La rotación conserva el ambiente de la key: solo cambia el secreto. Si rotás tu key de certificación esperando pasar a producción, vas a seguir emitiendo contra Maullín — documentos de prueba, sin validez tributaria — sin ningún error que te avise.

Hay que crear una key nueva después del paso 2, con los mismos scopes.

Checklist de go-live

  • Pendiente: Certificación SII aprobada para cada tipo que vas a emitir (depende de Comges).
  • Pendiente: Plan de la empresa vigente y con fecha suficiente (depende de Comges).
  • Pendiente: Certificado digital de la empresa activo y no vencido (depende de Comges). Si vence, toda emisión corta con 422 emisor.sin-certificado / emisor.certificado-invalido (§8).
  • Pendiente: Todos los tipos habilitados para la empresa en Producción (depende de Comges).
  • Pendiente: Ambiente del usuario cambiado a Producción (lo hace el administrador de la empresa).
  • Pendiente: Key nueva pk_live_* creada (no rotada) y desplegada en tu configuración.
  • Pendiente: Tu código manda transactionId en toda emisión.
  • Pendiente: Tu código ramifica por el code del error (§15) y maneja el 202 (§8).
  • Pendiente: Tu timeout HTTP es mayor al nuestro (§17).
  • Pendiente: Verificado que las primeras emisiones reales llegan a Aceptado, y que si no, leés glosaSii.

15. Errores: formato y códigos

El cuerpo estándar

La mayoría de los errores llega con este cuerpo:

json
{
  "code": "emisor.sin-folios",
  "title": "No hay folios disponibles para tipo 33 ambiente Certificacion. Suba un CAF nuevo.",
  "detail": null,
  "status": 409,
  "field": "tipoDte",
  "traceId": null
}

Los campos que no aplican viajan como null, no se omiten.

CampoUso
codeEstable. Es el campo contra el que hay que programar.
titleLegible para humanos. La redacción puede cambiar entre versiones.
detailExplicación más larga, cuando el error la tiene. La mayoría la deja en null.
statusIgual al status HTTP.
fieldCampo del cuerpo que causó el rechazo, cuando aplica. Puede venir con notación de índice (detalles[2].unidadMedida) o con varios campos separados por coma.
traceIdIdentificador de la petición. Hoy solo viaja en las respuestas 500; en los errores de negocio (4xx) llega null. Para pedir soporte, ver más abajo qué mandar.

Respuestas que NO usan ese cuerpo

Estas son las excepciones. Si tu manejo de errores asume que siempre hay code, estas lo rompen:

SituaciónHTTPCuerpo realQué hacer
JSON malformado, tipo de dato equivocado, campo obligatorio ausente (rechazo del deserializador, antes de llegar a la lógica)400{ "type", "title": "One or more validation errors occurred.", "status", "errors": { … }, "traceId" }sin codeLeer el mapa errors: cada clave es la ruta del campo.
Error interno500{ "type", "title", "status", "traceId" }sin codeReintentar con backoff y el mismo transactionId. Si persiste, soporte con el traceId.
Documento inexistente en GET /dte/{id} y GET /dte/{id}/xml404Cuerpo vacíoVerificar el id. Un id de otra empresa también da 404.
La petición superó los 60 s de la plataforma504HTML, no JSONReintentar con backoff y el mismo transactionId. Ver §17.

Además, dos respuestas traen el cuerpo recortado: el 401 y el 429 vienen con code y title (el 429 también con status) pero sin traceId; y el 403 plan.vencido trae campos extra (type, detail, fechaVencimiento).

Cómo leer las tablas

  • ¿Reintentar?no significa que el mismo request va a fallar igual: hay que corregir algo. sí (backoff) significa que la causa es transitoria.
  • ¿Folio? — lo más importante cuando algo falla en una emisión. no = el documento se rechazó antes de tomar un folio del CAF, así que no perdiste nada y podés corregir y reenviar. = el folio ya se consumió (o quedó comprometido) y ese número no vuelve.

Regla general de la emisión nacional: todas las validaciones del cuerpo, del receptor, del detalle, de la guía de despacho, de las fechas, de los largos y de los prerequisitos de la empresa —el certificado digital incluido— corren antes de reservar folio. Si recibiste un 400, un 403, un 422 o un 501 de POST /dte, no se consumió folio.

⚠️ POST /exportacion no cumple esa regla del todo. Sus validaciones de formato también corren antes del folio, pero el certificado digital se carga después de reservarlo: por eso 409 exp.sin-certificado y 500 exp.error-pfx llegan con el folio ya consumido. La columna «¿Folio?» de §15.17 lo marca caso por caso.


15.1 Autenticación, cuota y permisos

codeHTTPQué significa¿Reintentar?Qué hacer
api-key.ausente401Falta el header, o la key no sirve: inválida, revocada, expirada o con formato malo. El 401 es genérico a propósito — no distingue el motivo.noRevisar el header X-Api-Key y el secreto. Si la key fue rotada, actualizar el valor guardado.
permiso.requerido403La key está bien, pero le falta el scope que exige la ruta (dte:read en las consultas, dte:emit:61 en anulación y nota de crédito).noEmitir con una key que tenga ese scope.
emisor.permiso-tipo-no-autorizado403Falta dte:emit:{tipo} para el tipo que estás emitiendo. Es el mismo problema que el anterior, pero detectado en la capa de emisión.noEmitir con una key que tenga dte:emit:{tipo}.
dte.permiso-faltante403Falta dte:emit:61 en POST /dte/{id}/nota-credito. Tercera variante del mismo problema.noEmitir con una key que tenga dte:emit:61.
api-publica.rate-limit429Se excedió el límite de peticiones (se cuenta por key y también por IP).Esperar los segundos que indica el header Retry-After y reintentar.
descarga.token-invalido403Solo en las rutas de descarga firmada. Falta el token t, está mal formado, o no corresponde a ese documento y ese recurso. También aparece cuando la funcionalidad no está configurada en el ambiente.noUsar la URL tal como vino en xmlUrl / pdfUrl, sin modificarla. Ver §10.
descarga.token-vencido403Solo en las rutas de descarga firmada. La firma es válida pero el enlace ya venció.noPedir uno nuevo: GET /dte/{id} devuelve xmlUrl y pdfUrl recién firmadas.

Los tres 403 de scope (permiso.requerido, emisor.permiso-tipo-no-autorizado, dte.permiso-faltante) describen la misma causa: la key no tiene el permiso. Cuál de los tres llega depende de qué capa corte primero, así que si programás una rama de "falta permiso", cubrí los tres.

15.2 Estado de la cuenta

codeHTTPQué significa¿Reintentar?Qué hacer
plan.vencido403El plan de la empresa venció. El cuerpo trae además detail y fechaVencimiento.noRenovar el plan. Alcance real: bloquea emisión, anulación, notas de crédito y las consultas de documentos. Los dos endpoints de PDF siguen respondiendo con el plan vencido.
demo.solo-lectura403La key pertenece a la empresa de demostración, que es compartida y de solo lectura. Solo bloquea escrituras (POST).noUsar una key de una empresa real.

15.3 Prerequisitos de la empresa

Nada de esto se puede resolver desde el ERP: son datos o habilitaciones de la empresa emisora. Ninguno consume folio — los dos controles del certificado también corren antes de tomar el folio, a propósito: la firma ocurre después de reservarlo.

codeHTTPQué significa¿Reintentar?Qué hacer
emisor.tipo-dte-no-habilitado403El tipo de documento no está habilitado para esa empresa.noPedir la habilitación del tipo a soporte.
cert.tipo-no-aprobado-prod403Solo en Producción. Ese tipo de documento todavía no tiene la certificación aprobada por el SII. El title incluye el estado actual del proceso.noCompletar la certificación del tipo ante el SII antes de emitirlo en producción (ver §14). En Certificación este error no aparece.
cert.servicio-no-disponible503No se pudo verificar el estado de certificación, así que la emisión en Producción se bloquea por seguridad.sí (backoff)Reintentar en unos minutos. Si persiste, soporte.
emisor.empresa-sin-sucursal422La empresa no tiene casa matriz configurada; sin dirección de origen no se puede armar el documento.noConfigurar la sucursal en el portal.
emisor.empresa-sin-actecos422La empresa no tiene actividades económicas activas.noConfigurar los actecos en el portal.
emisor.perfil-empresa-no-disponible503No se pudo resolver el perfil de la empresa (razón social, giro, sucursales, actecos).sí (backoff)Reintentar. Si persiste, soporte.
emisor.sin-certificado422La empresa no tiene un certificado digital (PFX) activo. Todo DTE se firma antes de guardarse, así que sin certificado no se puede emitir.noCargar un PFX vigente.
emisor.certificado-invalido422Hay un PFX cargado pero no se pudo abrir: clave equivocada o certificado vencido.noRevisar la vigencia del certificado y su clave.

15.4 Tipo de documento y ambiente

Ninguno consume folio.

codeHTTPQué significa¿Reintentar?Qué hacer
emisor.tipo-dte-invalido400tipoDte no es un código conocido del SII.noCorregir el valor.
emisor.tipo-dte-no-emisible501Se pidió un 43 (Liquidación-Factura) o un 46 (Factura de Compra). Esta API no los emite: su aritmética específica no está implementada. No se consumió folio.noNo usar 43 ni 46. Los scopes dte:emit:43 y dte:emit:46 existen y se pueden conceder, pero no habilitan nada. Ver §8.
emisor.tipo-dte-no-implementado501Se mandó un 110/111/112 a POST /dte.noUsar POST /api/public/v1/exportacion.
emisor.ambiente-no-coincide-con-key400El cuerpo pide un ambiente distinto al de la key. El ambiente lo fija la key y no se puede cambiar desde el request.noSacar ambiente del cuerpo, o usar la key del ambiente que corresponde.
emisor.ambiente-invalido400ambiente no es Certificacion ni Produccion.noCorregir, o mejor, no mandar el campo.

15.5 Validación del cuerpo — receptor

Ninguno consume folio. Todos son 400.

codeQué significaQué hacer
emisor.rut-receptor-invalidoEl RUT del receptor está vacío o no pasa el dígito verificador. Es el único que vas a recibir por este motivo.Corregir el RUT. Los genéricos del SII (66666666-6 consumidor final, 55555555-5 extranjero) son válidos.
emisor.receptor-incompletoFaltan direccion, comuna o giro, que el SII exige para ese tipo de documento. El field lista los que faltan.Completar los campos.
emisor.receptor-largo-excedidoUn campo del receptor supera el largo máximo del SII. El field dice cuál. Ver los largos en §8.Acortar. Estos campos no se truncan solos: un giro comercial chileno normal pasa fácil de 40 caracteres.
emisor.receptor-correo-invalidoAlguno de los correos de copia no tiene forma de correo.Corregir o quitar.
emisor.receptor-correos-largo-excedidoLa lista de correos de copia excede el largo permitido.Reducir la cantidad.

Los códigos emisor.receptor-rut-requerido y emisor.receptor-rut-invalido aparecen en el código fuente pero no son alcanzables desde POST /dte: el guard que devuelve emisor.rut-receptor-invalido corre primero. Programá contra ese código, o contra field == "receptor.rut". Ver §15.19.

15.6 Validación del cuerpo — detalle

Ninguno consume folio. Todos son 400.

codeQué significaQué hacer
emisor.detalles-vaciosdetalles vacío.Mandar al menos una línea.
emisor.detalles-excede-60Más de 60 líneas (límite duro del SII).Partir el documento.
emisor.detalle-indexe-invalidoindExe fuera de los valores soportados (0, 1, 2, 6).Corregir.
emisor.detalle-monto-invalidoCantidad, precio, descuento o recargo de una línea fuera de rango o incoherentes.Corregir la línea que indica field.
emisor.detalle-largo-excedidoUn campo de una línea supera el largo del SII. Ver los largos en §8.Acortar. Ojo con unidadMedida: son 4 caracteres, así que UN, KG o LT sirven, pero UNIDAD, KILOS y LITROS no.

15.7 Validación del cuerpo — referencias, impuestos, descuentos y recargos

Ninguno consume folio. Todos son 400.

codeQué significaQué hacer
emisor.referencias-excede-40Más de 40 referencias.Reducir.
emisor.referencia-requeridaUna nota de crédito (61) o de débito (56) sin ninguna referencia al documento que corrige o anula. El SII las exige.Agregar la referencia. (Para anular, es más seguro usar POST /{id}/anular: la referencia la arma el servidor.)
emisor.referencia-codref-invalidocodRef fuera de 1 (anula), 2 (corrige texto) o 3 (corrige montos).Corregir.
emisor.referencia-fecha-fuera-de-rangoAlgún fechaRef cae fuera del rango que el SII reconoce (desde 2002-08-01 hasta 2050-12-31). El title lista todas las líneas con problema.Corregir la fecha del documento referenciado.
emisor.referencia-largo-excedidoUn campo de una referencia supera su largo. Límites: tipoDocRef 3, folioRef 18, razonRef 90.Acortar.
emisor.impuestos-excede-20Más de 20 impuestos adicionales.Reducir.
emisor.impuesto-codigo-invalidocodigoImpuesto no existe en la tabla del SII (rango 14..53, más el 271).Corregir.
emisor.impuesto-monto-negativoUn impuesto adicional con monto negativo: bajaría el total del documento.Corregir.
emisor.descuentos-globales-excede-20Más de 20 descuentos o recargos globales.Reducir.
emisor.dr-tipo-movimiento-invalidotipoMovimiento no es "D" (descuento) ni "R" (recargo).Corregir.
emisor.dr-tipo-valor-invalidotipoValor no es "%" ni "$".Corregir.
emisor.dr-valor-negativoValor de descuento o recargo negativo.Corregir.
emisor.dr-glosa-excede-45Glosa de más de 45 caracteres.Acortar.

15.8 Validación del cuerpo — fechas y forma de pago

Ninguno consume folio. Todos son 400.

codeQué significaQué hacer
emisor.fecha-emision-invalidaFormato de fechaEmision no reconocido.Usar dd-MM-yyyy o yyyy-MM-dd. Lo más simple es omitir el campo: por defecto se usa la fecha de hoy en Chile.
emisor.fecha-emision-futuraFecha posterior a hoy en Chile. El SII las rechaza.Corregir u omitir el campo.
emisor.fecha-emision-anterior-minimaFecha anterior a la fecha mínima que acepta el SII.Corregir.
emisor.fecha-vencimiento-invalidaFormato de fechaVencimiento no reconocido.Corregir.
emisor.fecha-vencimiento-anteriorEl vencimiento es anterior a la emisión.Corregir.
emisor.forma-pago-invalidaformaPago fuera de los valores del SII (1 contado, 2 crédito, 3 sin costo).Corregir.

15.9 Validación del cuerpo — sucursal, actecos y campos propios

Ninguno consume folio.

codeHTTPQué significaQué hacer
emisor.sucursal-no-encontrada400empresaSucursalId no existe en esa empresa.Corregir u omitir (se usa la casa matriz).
emisor.actecos-excede-4400Más de 4 actividades económicas (límite del SII).Reducir u omitir.
emisor.acteco-no-pertenece400Uno de los actecos declarados no pertenece a esa empresa.Corregir u omitir.
emisor.acteco-principal-fuera-de-lista400El acteco principal no está entre los declarados.Corregir.
custom-field-obligatorio400Falta un campo propio marcado como obligatorio para esa empresa.Completar customFields.
custom-field-desconocido400customFields trae una clave que no está definida para esa empresa.Quitarla.
custom-field-tipo-invalido400El valor de un campo propio no corresponde a su tipo.Corregir el valor.
custom-field-payload-grande400El bloque customFields supera el tamaño permitido.Reducir.

Los cuatro códigos de campos propios no llevan prefijo de familia. Solo aparecen si la empresa tiene campos propios configurados.

15.10 Validación del cuerpo — guía de despacho (52)

Estos nueve solo aplican al tipo 52. Ninguno consume folio. Todos son 400.

codeQué significaQué hacer
emisor.ind-traslado-requeridoFalta indTraslado, que es obligatorio en la guía.Mandarlo (1..7).
emisor.ind-traslado-invalidoindTraslado fuera del rango válido.Corregir.
emisor.ind-traslado-no-aplicaSe mandó indTraslado en un documento que no es una guía.Quitarlo.
emisor.ind-traslado-exportacion-no-soportadoSe pidió indTraslado 8 o 9 (traslado para exportación). Esta API no los soporta.No usar 8 ni 9.
emisor.tipo-despacho-invalidotipoDespacho fuera de 1/2/3.Corregir.
emisor.tipo-despacho-no-aplicaSe mandó tipoDespacho en un documento que no es una guía.Quitarlo.
emisor.transporte-chofer-incompletoSe mandó rutChofer sin nombreChofer, o al revés. Van los dos o ninguno.Completar el par.
emisor.transporte-rut-invalidorutTransportista o rutChofer no pasa el dígito verificador.Corregir.
emisor.transporte-largo-excedidoUn campo del bloque transporte supera su largo. El field dice cuál.Acortar.

15.11 Cotizaciones de origen

Solo aparecen si el cuerpo trae cotizacionIds. Ninguno consume folio.

codeHTTPQué significa¿Reintentar?Qué hacer
emisor.cotizacion-no-verificable503No se pudo verificar la cotización de origen. La emisión se bloquea a propósito antes de tomar folio.sí (backoff)Reintentar. Si persiste, emitir sin cotizacionIds.
emisor.cotizacion-no-encontrada404La cotización no existe o es de otra empresa.noCorregir el id.
emisor.cotizacion-no-emitible409La cotización no está abierta ni ganada, o está vencida.noRevisar el estado de la cotización.

15.12 Folios

Es el grupo donde más importa saber si perdiste un folio. En ninguno de estos se consumió folio: el folio se toma después de todas las validaciones y, si algo falla más adelante, el servidor lo libera.

codeHTTPQué significa¿Reintentar?Qué hacer
emisor.sin-folios409No quedan folios de ese tipo en ese ambiente y tampoco se pudo pedir más.noCargar un CAF nuevo para ese tipo.
caf.sin-autorizacion422El SII no autoriza timbraje de ese tipo de documento para esa empresa. Es terminal: reintentar no lo va a cambiar.noRequiere gestión ante el SII.
emisor.sin-folios-nc400No hay folios de tipo 61 para emitir la nota de crédito (anulación o nota parcial).noCargar un CAF de tipo 61.
emisor.sin-folios-nc409Variante: se pidió el folio 61 al SII y no llegó a tiempo. La anulación no queda en cola, a diferencia de la emisión normal.sí (backoff)Reintentar el mismo POST /anular en unos minutos.
emisor.caf-interno-no-configurado503Problema de configuración del lado nuestro: la reserva de folios no está disponible.sí (backoff)Soporte con el traceId.

emisor.sin-folios es hoy un camino residual. Cuando no hay folio disponible, el sistema se los pide al SII y espera. Si el folio llega, recibís tu 201 normal. Si no llega a tiempo, la emisión se encola y recibís un 202 con ticketId (ver Respuesta 202): el documento ya está validado y se va a emitir solo. Solo si el SII dice que no autoriza ese tipo vas a ver el 422 caf.sin-autorizacion.

15.13 Consulta de documentos y de tickets

codeHTTPQué significa¿Reintentar?Qué hacer
(sin cuerpo)404El documento no existe, o es de otra empresa. GET /dte/{id} y GET /dte/{id}/xml devuelven 404 vacío.noVerificar el id.
dte.cross-tenant-no-autorizado403Se mandó ?empresaId= en la query. Una API key nunca puede consultar otra empresa.noQuitar el parámetro: la empresa sale de la key.
dte.ambiente-no-autorizado401No se pudo resolver el ambiente de la petición. Sale solo si falta el claim de ambiente, no por mandar ?ambiente=. Con una API key no debería ocurrir: el ambiente siempre viaja en la key (y ante la duda se asume Certificación).noSi aparece, es un problema de la credencial — soporte.
emisor.ticket-no-encontrado404El ticketId no existe, o es de otra empresa (se devuelve 404 y no 403 para no revelar su existencia).noVerificar el ticketId que devolvió el 202.
emisor.pendientes-no-disponible503La cola de emisiones pendientes no está habilitada en ese despliegue.sí (backoff)Soporte.

15.14 Códigos que llegan dentro del ticket del 202

Estos no son errores HTTP: viajan en el campo codigoError del cuerpo que devuelve GET /dte/pendientes/{ticketId}, junto con mensaje y esErrorTerminal. La respuesta del endpoint es 200.

codigoErroresErrorTerminalQué significaQué hacer
caf.sin-foliosfalseTodavía no hay folios; el SII aún no entregó. El ticket sigue en cola.Seguir consultando.
caf.sin-autorizaciontrueEl SII no autoriza folios de ese tipo. El ticket no se va a emitir nunca.Gestión ante el SII. Volver a emitir después.
emisor.payload-ilegibletrueNo se pudo recuperar el contenido de la emisión encolada.Volver a emitir. Soporte si se repite.
emisor.error-inesperadofalseError inesperado al emitir; se reintenta solo.Seguir consultando.
emisor.errortrueComodín cuando el error de emisión no trae código propio.Leer mensaje.
cualquier código de las tablas anteriorestrueEl documento encolado falló por un problema de negocio (datos inválidos, tipo no habilitado…).Corregir y volver a emitir.

esErrorTerminal: true significa: no reintentes ese ticket. Con false, el sistema lo vuelve a tomar solo.

15.15 Anulación — POST /dte/{id}/anular

codeHTTPQué significa¿Folio?¿Reintentar?Qué hacer
emisor.documento-no-encontrado404El id no existe o es de otra empresa.nonoVerificar el id.
emisor.documento-ya-anulado409El documento ya tiene una anulación previa que no pasó por este endpoint.nonoNinguna acción: ya está anulado. Un reintento del mismo POST /anular no da este error: devuelve la misma nota de crédito.
emisor.tipo-no-anulable400Ese tipo no se anula con nota de crédito (por ejemplo, una NC 61, o un documento de exportación).nonoPara exportación, emitir una NC 112.
emisor.estado-no-anulable400El documento está Pendiente, Rechazado o ErrorEnvio. Solo se anula lo que ya llegó al SII (Enviado, Aceptado, AceptadoConReparos).nonoSi está Pendiente, esperar a que llegue al SII y reintentar.
emisor.montos-no-reproducibles409Alguna línea tiene un monto que no se deriva de cantidad × precio − descuento + recargo (típico de documentos importados). Emitir igual daría una nota por un importe distinto al que anula.nonoProbá POST /{id}/nota-credito en modo monto, que no depende de las líneas. No armes la nota de crédito a mano: la referencia al original (tipoDocRef, folioRef, fechaRef) es exactamente lo que estos endpoints existen para resolver.
emisor.anulacion-nc-descartada409La nota de crédito que anulaba este documento fue descartada por administración.nonoNo se re-emite automáticamente. Escalar a soporte.
emisor.anulacion-marca-fallida500La nota de crédito se emitió, pero no se pudo marcar el original como anulado.sí, ya consumidoReintentar el mismo POST /anular: el camino idempotente termina de marcar sin emitir otra nota.

Y además: emisor.sin-folios-nc (§15.12) y dte.cross-tenant-no-autorizado (§15.13).

15.16 Nota de crédito parcial — GET /dte/{id}/acreditable y POST /dte/{id}/nota-credito

Ninguno consume folio: el saldo se valida antes de reservar.

codeHTTPQué significa¿Reintentar?Qué hacer
emisor.acreditacion-no-habilitada404La funcionalidad de notas de crédito parciales no está habilitada. Un 404 acá no significa "ruta inexistente".noPedir la habilitación a soporte.
emisor.documento-no-encontrado404El id no existe o es de otra empresa.noVerificar el id.
emisor.documento-descartado409El documento fue descartado por administración.noEscalar a soporte.
emisor.documento-ya-anulado409El documento ya está anulado por completo: no queda nada que acreditar.noNinguna.
emisor.acreditacion-sin-saldo409El documento ya fue acreditado por completo.noConsultar GET /{id}/acreditable para ver el saldo.
emisor.tipo-no-acreditable400Ese tipo de documento no admite nota de crédito parcial.no
emisor.estado-no-acreditable400El documento está en un estado que no admite nota de crédito.noEsperar a que llegue al SII.
emisor.modo-invalido400modo no es lineas, monto ni texto.noCorregir.
emisor.modo-no-disponible400Ese modo no aplica a ese documento.noConsultar modosDisponibles en GET /{id}/acreditable.
emisor.lineas-requeridas400modo: "lineas" sin el arreglo lineas.noMandar las líneas.
emisor.linea-inexistente400Una nroLinea no existe en el documento original.noCorregir.
emisor.linea-no-facturable400Se intentó acreditar una línea que no es facturable.noQuitarla.
emisor.cantidad-invalida400Cantidad a acreditar fuera de rango para esa línea.noCorregir.
emisor.monto-requerido400modo: "monto" sin el campo monto.noMandar el monto.
emisor.acreditacion-excede-saldo400Lo pedido supera el saldo disponible (del documento o de una línea). El field señala la línea culpable. No se reservó folio.noConsultar GET /{id}/acreditable antes de emitir.
emisor.acreditacion-en-curso409Hay otra petición emitiendo con el mismo transactionId en este momento.sí (backoff)Reintentar en unos segundos con el mismo transactionId.
emisor.acreditacion-replay-inconsistente409Ese transactionId ya acreditó, pero su nota de crédito no se pudo recuperar.noNo reintentar con otro transactionId: acreditarías dos veces. Escalar a soporte con el transactionId.

Y además: dte.permiso-faltante (§15.1) y emisor.sin-folios-nc (§15.12).

El saldo de GET /{id}/acreditable viene en neto, no en total con IVA.

15.17 Exportación (exp.*)

Mismo criterio que la emisión nacional: todo lo que sea 400, 403 o 422 ocurre antes de tomar folio. Las excepciones están marcadas.

codeHTTPQué significa¿Folio?¿Reintentar?Qué hacer
exp.tipo-dte-invalido400tipoDte no es 110, 111 ni 112.nonoCorregir.
exp.tipo-dte-no-habilitado403Ese tipo de exportación no está habilitado para la empresa en ese ambiente.nonoPedir la habilitación a soporte.
exp.ambiente-no-coincide-con-key400El cuerpo pide un ambiente distinto al de la key.nonoSacar ambiente del cuerpo.
exp.ambiente-invalido400ambiente no es Certificacion ni Produccion.nonoCorregir.
exp.detalles-vacios400Sin líneas de detalle.nonoMandar al menos una.
exp.detalles-excede-60400Más de 60 líneas.nonoPartir el documento.
exp.referencias-excede-40400Más de 40 referencias.nonoReducir.
exp.bultos-excede-10400Más de 10 bultos.nonoReducir.
exp.comisiones-excede-20400Más de 20 comisiones.nonoReducir.
exp.dr-excede-20400Más de 20 descuentos o recargos globales.nonoReducir.
exp.nd-nc-sin-referencia400Una nota de débito (111) o de crédito (112) de exportación sin referencia al documento original.nonoAgregar la referencia.
exp.referencia-codref-requerido400Falta codRef en una referencia.nonoAgregarlo.
exp.referencia-codref-invalido400codRef fuera de los valores válidos.nonoCorregir.
exp.receptor-ciudad-requerida400Falta la ciudad del receptor extranjero.nonoCompletar.
exp.receptor-direccion-requerida400Falta la dirección del receptor extranjero.nonoCompletar.
exp.moneda-invalida400La moneda no pertenece al catálogo del SII.nonoUsar un literal del SII (DOLAR USA, EURO, PESO CL…). Si no está en el listado, OTRAS MONEDAS.
exp.fecha-emision-invalida400Formato de fecha no reconocido.nonoCorregir.
exp.fecha-emision-futura400Fecha posterior a hoy en Chile.nonoCorregir.
exportacion.*400Familia completa de reglas del formato SII de exportación. El code nombra la regla concreta y el field el campo culpable. Ver el detalle abajo.nonoCorregir el campo que indica field.
exp.contrato-invalido400Comodín cuando una regla del formato no trae código propio.nonoLeer title y field.
exp.empresa-incompleta422Faltan datos maestros de la empresa exigidos para exportación (razón social, giro, dirección, comuna, sucursal o actecos).nonoCompletar en el portal.
exp.perfil-empresa-no-disponible503No se pudo resolver el perfil de la empresa.nosí (backoff)Reintentar.
exp.caf-interno-no-configurado503La reserva de folios no está disponible.nosí (backoff)Soporte.
exp.sin-folios409No quedan folios de ese tipo de exportación.nonoCargar un CAF.
exp.sin-certificado409La empresa no tiene certificado digital activo. Se detecta después de tomar el folio.sí, ya consumidonoCargar el PFX antes de volver a emitir.
exp.error-pfx500El certificado digital no se pudo abrir (vencido o ilegible). Después de tomar el folio. (se intenta liberar)noRevisar el certificado.
exp.error-xml500Falló la construcción o la firma del XML. (se intenta liberar)sí (backoff)Reintentar con el mismo transactionId. Si persiste, soporte.
exp.error-persistencia500Falló el guardado del documento. (se intenta liberar)sí (backoff)Reintentar con el mismo transactionId.
exp.not-found404La exportación no existe o es de otra empresa.noVerificar el id.
exp.xml-no-disponible404El documento existe pero todavía no tiene XML firmado.sí (backoff)Reintentar en unos segundos.
exp.error-descarga-xml500No se pudo recuperar el XML almacenado.sí (backoff)Soporte con el traceId.
exp.cross-tenant403Se mandó ?empresaId= en la query.noQuitar el parámetro.
exp.ambiente-no-autorizado401No se pudo resolver el ambiente.noNo debería ocurrir con una API key vigente. Soporte.
exp.sin-contexto401No se pudo resolver la empresa de la petición.noÍdem.

Y además: emisor.permiso-tipo-no-autorizado (§15.1) cuando falta dte:emit:{110|111|112}.

La familia exportacion.*

Son las reglas del formato oficial del SII para documentos de exportación: hoy son 109. Todas responden 400 sin consumir folio, y el code nombra exactamente la regla que falló, con el campo en field. Se agrupan por bloque del documento:

PrefijoCubre
exportacion.aduana.* y exportacion.aduana-requeridaBloque de aduana: modalidad y cláusula de venta, país de recepción y de destino, vía de transporte, puertos de embarque y desembarque, transportista, flete, seguro, pesos y tara.
exportacion.detalle.*, exportacion.detalles-*Líneas: cantidad, precio, código arancelario, descuentos y recargos, unidad de medida, indicador de exención, cantidad y unidad de referencia.
exportacion.receptor.*, exportacion.receptor-requeridoReceptor extranjero: nombre, giro, dirección, ciudad, nacionalidad, número de identificación, correo.
exportacion.referencia.*, exportacion.referencias-maximo, exportacion.referencia-110-faltante*, exportacion.dus-duplicadoReferencias: tipo, folio, fecha, razón y código de referencia; DUS obligatorio y sin duplicar.
exportacion.moneda.*, exportacion.moneda-requeridaMoneda y tasa de cambio.
exportacion.bulto.*, exportacion.bultos-maximoBultos: código, cantidad, marcas, sellos, contenedor.
exportacion.comision.*, exportacion.comisiones-maximoComisiones y otros cargos.
exportacion.dr-global.*, exportacion.dr-global-maximoDescuentos y recargos globales.
exportacion.fecha-emision-*, exportacion.ind-servicio-invalido, exportacion.ambiente-*Cabecera: fecha de emisión, indicador de servicio, ambiente.

No hace falta programar contra cada uno: todos son 400 de validación, ninguno consume folio, y ninguno se arregla reintentando. Lo accionable está en field y en title.

15.18 Resumen: qué reintentar y qué no

Situación¿Reintentar?
400 y 422 de validaciónNo. El request está mal; reintentar da lo mismo.
401 y 403No. Es un problema de credencial, de scope o de configuración de la empresa.
404No.
409 de folios (emisor.sin-folios, emisor.sin-folios-nc 400)No hasta cargar un CAF.
409 emisor.acreditacion-en-curso, emisor.sin-folios-nc 409, con backoff.
422 caf.sin-autorizacionNo. Requiere gestión ante el SII.
403 descarga.token-invalido / descarga.token-vencidoNo. El enlace no se arregla reintentando: hay que generar uno nuevo con GET /dte/{id}.
429, respetando Retry-After. Ver §17.
500, 503, 504 y timeouts de red, con backoff exponencial y siempre con el mismo transactionId.
202 con ticketIdNo es un error. El documento ya está validado y encolado. Consultá el ticket; no reemitas sin transactionId o vas a quemar otro folio.

15.19 Códigos que no existen

Ninguno de los códigos de esta tabla lo devuelve la API. Aparecen en documentación vieja o en ejemplos de terceros; si tu código tiene una rama para alguno, esa rama no se ejecuta nunca.

Código que no existeQué devuelve la API en su lugar
api-key.scope-insuficientepermiso.requerido (y sus dos variantes de §15.1).
api-key.invalidaTodo 401 llega como api-key.ausente, sin distinguir el motivo.
caf.agotadoemisor.sin-folios (409) o caf.sin-autorizacion (422). No hay ningún caf.agotado: la falta de folios no viaja con ese nombre.
emisor.receptor-rut-requerido / emisor.receptor-rut-invalidoemisor.rut-receptor-invalido (400). Existen en el código fuente pero son inalcanzables desde POST /dte (§15.5).

15.20 Qué reportar cuando algo no calza

Como traceId hoy solo viaja en las respuestas 500, para cualquier otro error hay que mandar otros datos. La lista completa está en Soporte.


16. Idempotencia y reintentos

Un timeout de red no te dice si el documento se emitió o no. Sin idempotencia, reintentar duplica documentos y quema folios. Hay tres mecanismos, uno por operación:

Emisión: transactionId

Generá un UUID antes de la primera llamada, guardalo junto a tu orden/factura interna, y mandalo en el cuerpo:

json
{ "transactionId": "a3bb189e-8bf9-3888-9912-ace4e6543002", "tipoDte": 33, "...": "..." }

Si reintentás con el mismo valor, el servidor devuelve el documento ya emitido en vez de emitir uno nuevo. No consume otro folio. Y si la primera llamada devolvió un 202, reintentar con el mismo transactionId devuelve el mismo ticketId: no encola un segundo documento.

La deduplicación corre después de las validaciones: un cuerpo inválido devuelve 400 aunque repita un transactionId ya usado.

transactionId es opcional pero prácticamente obligatorio en producción. Sin él, un timeout te deja sin forma de saber si reintentar es seguro. Y es lo único que te protege si tratás por error un 202 como un fallo y reemitís.

Anulación: automática, por documento

No necesita transactionId. La clave es el {id} del documento: un documento se anula a lo sumo una vez, así que reintentar el mismo POST /{id}/anular devuelve siempre la misma NC. Ver §11.

Nota de crédito parcial: transactionId, y acá sí hace falta

Un mismo documento puede recibir varias notas parciales legítimas, incluso idénticas. El servidor no puede distinguir un reintento de una devolución nueva. Mandá tu propio transactionId. Ver §12.3.


17. Rate limiting y límites duros

Rate limit

Dos capas, ambas con ventana deslizante de 60 segundos, y se aplican las dos a la vez:

CapaLímitePartición
Por credencial120 requests / 60 sPrefijo de la API Key presentada
Global por IP300 requests / 60 sIP de origen

Al exceder cualquiera de las dos:

http
HTTP/1.1 429 Too Many Requests
Retry-After: 15
Content-Type: application/json

{"code":"api-publica.rate-limit","title":"Demasiadas solicitudes para esta API key. Reintenta en unos segundos.","status":429}

El header Retry-After siempre viene (en segundos). Respetalo: reintentar antes solo gasta cuota.

Consecuencias prácticas:

  • El límite por IP no es un simple respaldo del otro: está siempre activo. Si tu ERP multiempresa usa 5 keys desde el mismo servidor, no tenés 5 × 120 = 600 requests/min: topás a 300 por la IP.
  • El polling de estado y el de tickets entran en la misma cuota que la emisión. Con backoff (5/10/20/40/60 s) un documento consume ~5 requests; en loop cerrado consume el presupuesto de una key entera.
  • Las descargas firmadas de §10 también consumen cuota, pero como no llevan API Key se cuentan por IP del visitante: los compradores que abren sus documentos no gastan el presupuesto de tu integración.
  • GET /health no consume cuota. Usalo para monitores.
  • Si necesitás más throughput, hablalo con soporte antes de subir a producción — el límite es configurable por despliegue, no por cliente.

Límites duros del documento (los pone el SII, no nosotros)

Cantidades:

QuéLímite
Líneas de detalle60
Referencias40
Impuestos adicionales20
Descuentos/recargos globales20
Actecos declarados4
Bultos (exportación)10

Largos de texto — exceder cualquiera devuelve 400 antes de reservar folio. La única excepción es el motivo de anulación y de nota de crédito, que se trunca en silencio:

CampoLargoCódigo de error
receptor.razonSocial100emisor.receptor-largo-excedido
receptor.giro40emisor.receptor-largo-excedido
receptor.direccion70emisor.receptor-largo-excedido
receptor.comuna20emisor.receptor-largo-excedido
receptor.ciudad20emisor.receptor-largo-excedido
receptor.contacto80emisor.receptor-largo-excedido
receptor.rutSolicita20emisor.receptor-largo-excedido
detalles[].nombreItem80emisor.detalle-largo-excedido
detalles[].descripcionItem1000emisor.detalle-largo-excedido
detalles[].codigoItem35emisor.detalle-largo-excedido
detalles[].tipoCodigo10emisor.detalle-largo-excedido
detalles[].unidadMedida4emisor.detalle-largo-excedido
referencias[].tipoDocRef3emisor.referencia-largo-excedido
referencias[].folioRef18emisor.referencia-largo-excedido
referencias[].razonRef90emisor.referencia-largo-excedido
descuentosGlobales[].glosa45emisor.dr-glosa-excede-45
motivo de anulación / nota de crédito90(se trunca, no falla)
observaciones500(no viaja al SII)

Los tres que más rompen con datos comerciales chilenos perfectamente normales: unidadMedida (4), receptor.giro (40) y receptor.comuna (20). Mapealos o recortalos en tu ERP antes de llamar.

Timeouts

CaminoTimeout del servidor
Emisión, consulta, XML, anulación, nota de crédito45 s
PDF60 s

Configurá el timeout de tu cliente HTTP por encima de esos valores (p. ej. 60 s / 75 s). Un timeout de tu lado más corto que el nuestro es la receta clásica para creer que algo falló cuando en realidad se emitió — y por eso transactionId no es opcional.

⚠️ En el camino del PDF podés recibir un 504 que no es nuestro: si la petición llega al techo de la plataforma, la respuesta es una página de error HTML, sin code ni traceId. No intentes parsearlo como JSON. Tratalo como resultado indeterminado y reintentá.


18. Webhooks

En vez de pollear GET /dte/{id} hasta que el estado se estabilice, podés recibir un POST firmado en tu servidor cada vez que pasa algo que te interesa: un documento cambia de estado ante el SII, llega un DTE de un proveedor, o se te están acabando los folios.

Estado: implementado, en autoservicio, contrato del cuerpo congelado.

Las suscripciones se administran desde el portal, en Portal → Webhooks (/portal/webhooks), con el permiso webhook:configurar. Ya no hay que escribirle a soporte para dar de alta un webhook, ni para rotar el secreto, ni para ver por qué falló una entrega.

Lo que no existe es administrarlas por código: ninguna ruta bajo /api/public/v1/ crea, edita, lista ni borra suscripciones, ni rota el secreto, ni lee el historial de entregas. El scope webhook:configurar se puede tildear al crear una key, pero no hay ninguna ruta de esta API que lo consuma: la administración va por sesión del portal. Dar de alta un webhook es configuración, no runtime.

El contrato del cuerpo y de la firma no cambia sin versionar el header. Podés escribir tu receptor contra esta sección y no tocarlo más.

Los webhooks son una optimización, no un reemplazo del polling. 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.

Catálogo de eventos

Son 17 tipos y el catálogo es cerrado: un tipo que no esté acá no se publica. Los nombres tienen la forma dominio.hecho y van en pasado — el webhook informa algo que ya ocurrió.

Al suscribirte elegís una lista de tipos, o * para recibir todos, incluidos los que agreguemos más adelante. Agregar un tipo nuevo es retrocompatible (nadie lo recibe hasta que se suscribe); renombrar uno no lo es, y por eso no lo hacemos sin versionar.

Tres cosas que valen para todo el catálogo:

  • El cuerpo tiene siempre la misma forma. Lo que cambia entre un tipo y otro es qué campos de datos vienen con valor y cuáles vienen null. No hay tipos con estructura propia.
  • datos.extra es una bolsa abierta y siempre está presente (aunque sea {}). Ahí viaja lo propio de los eventos que no hablan de un documento.
  • No asumas orden de llegada. Un dte.enviado y el dte.aceptado que le sigue pueden llegar en cualquier orden. Decidí por datos.estadoSii, no por el orden en que te llegaron.

Emisión

Tu propio documento, antes de que el SII opine. Sirven para numerar la venta sin esperar el ciclo tributario completo.

tipoCuándo se disparaQué trae en datos
dte.emitidoEl documento existe: folio asignado, XML firmado y persistido. Se publica antes de ir al SII.documentoId, tipoDte, folio, montoTotal, fechaEmision, rutReceptor y razonSocialReceptor. estadoSii llega 0 (Pendiente) y trackId en null: todavía no hay sobre. En una NC/ND llega además la referencia (codRef + documentoReferenciado).
dte.anuladoSe 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.

⚠️ documentoReferenciado apunta siempre en la misma dirección: es el documento al que referencia el DTE del evento. Nunca al revés. Una NC/ND trae ahí su documento original; un dte.anulado —cuyo DTE es el original, que no referencia a nadie— lo trae en null.

Si leés el par codRef + documentoReferenciado de 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.

Ciclo SII

El recorrido del documento ante el SII. Son los que reemplazan al polling de §9: en vez de repetir la consulta, esperás el aviso.

tipoCuándo se dispara¿Terminal?Qué trae en datos
dte.enviadoEl sobre se subió al SII y el SII devolvió trackId. A partir de acá conviene esperar el estado final en vez de pollear.NotrackId ya con valor y estadoSii 1 (Enviado).
dte.aceptadoEl SII aceptó el documento.estadoSii 2 y detalle con lo que respondió el SII.
dte.aceptado_con_reparosEl SII lo aceptó pero observó algo. Es válido tributariamente: el documento existe y tiene folio. Va aparte de dte.aceptado para que puedas revisarlo sin mirar el detalle de cada aceptado.estadoSii 2 (Aceptado) y detalle con el reparo. ⚠️ No es 4: el documento queda Aceptado en la base y el evento manda el mismo estado, para que no veas dos verdades. Lo que distingue el reparo es el tipo del evento, no estadoSii — si ramificás por estadoSii === 4 nunca entrás a esa rama.
dte.rechazadoEl SII rechazó el documento.estadoSii 3 y detalle con el motivo del rechazo.
dte.error_envioEl sobre no se pudo subir al SII (sin token, error HTTP, timeout). No es terminal: el documento sigue emitido y el envío se reintenta solo.NoestadoSii en null, trackId en null y detalle con el error del intento. ⚠️ No es 6: el documento sigue emitido y su estado no cambió, así que el evento no afirma ninguno. Ramificá por el tipo del evento. Ver Limitación conocida.

En los cuatro tipos que hablan de una NC o una ND, codRef y documentoReferenciado vienen poblados desde las líneas de referencia del documento. Sin codRef no podés distinguir "NC 61 aceptada porque anularon la factura" de "NC 61 aceptada porque devolvieron tres unidades". Si tu ERP registra devoluciones, es el campo que te importa.

Recepción de proveedores

Documentos que otros te emiten a vos, y los acuses que respondés. Los publica el módulo de recepción; no tienen nada que ver con tus emisiones.

tipoCuándo se disparaQué trae en datos
recepcion.documento_recibidoLlegó un DTE de un proveedor y quedó persistido, después del dedupe.tipoDte, folio, montoTotal y fechaEmision del documento del proveedor. estadoSii y trackId en null: el ciclo ante el SII es del emisor, no tuyo.
recepcion.documento_aceptadoSe envió el acuse comercial de aceptación del documento del proveedor.El documento del proveedor que se aceptó.
recepcion.documento_rechazadoSe envió el acuse comercial de rechazo.El documento del proveedor, con el motivo en detalle.
recepcion.documento_reclamadoSe reclamó el documento dentro de los 8 días hábiles. Todavía no se publica — ver el aviso de Eventos declarados que aún no se publican.
recepcion.acuse_tacitoVenció 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_mercaderiaSe registró el recibo de mercaderías de la Ley 19.983.El documento del proveedor sobre el que se firmó el recibo.

⚠️ Acá los RUT se invierten, y es el error más común de toda la sección. En los eventos recepcion.*, datos.rutEmisor es el proveedor y datos.rutReceptor sos vosdatos.razonSocialReceptor es tu razón social, no la del proveedor—. En emisión y en ciclo SII es al revés. empresaRut, en cambio, es siempre el tuyo: identifica de quién es la suscripción, no quién emitió el documento.

⚠️ datos.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.

Operativos

Avisos de infraestructura tributaria: lo que se te va a acabar antes de dejarte sin emitir. No hablan de un documento tuyo, así que casi todo el contenido útil viaja en datos.extra.

tipoCuándo se disparaQué trae en datos
caf.folios_bajosQuedan 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_vencerUn 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_vencerEl certificado digital de la empresa vence pronto. Todavía no se publica — ver el aviso de abajo.

⚠️ datos.documentoId de un evento caf.* no es un documento. Es 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.

Prueba

tipoCuándo se disparaQué trae en datos
webhook.pruebaLo dispara el botón "Probar" del portal contra una suscripción puntual.Datos de relleno con la forma completa del payload, y extra con {"prueba":true}.

webhook.prueba no se suscribe: se dispara. Es el único tipo del catálogo que no aparece en el selector de eventos —si lo declarás en la lista de una suscripción, el alta responde 400—. 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. Tampoco se deduplica: cada prueba llega como un evento nuevo.

Eventos declarados que aún no se publican

Regla de este documento: 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.

tipoEstado real
certificado.por_vencerSin productor. El tipo existe en el catálogo y se puede suscribir, pero ningún servicio lo publica todavía: le tocaría al servicio que conoce la vigencia del certificado. Mientras tanto, la fecha de vencimiento del PFX se mira en el portal.
recepcion.documento_reclamadoSin punto de publicación. La constante está declarada en el módulo de recepción, 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.

Cuerpo del POST

Content-Type: application/json. Este es el cuerpo exacto que se firma.

json
{
  "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": {}
  }
}
CampoQué es
idIdentificador del evento. Es estable entre reintentos: es la clave por la que tenés que deduplicar. Viaja también en X-Comges-Event-Id.
tipoUno de los 17 tipos del catálogo. Viaja también en X-Comges-Event-Type, así que podés rutear sin parsear el cuerpo.
versionVersión del formato del cuerpo. Hoy siempre 1.
ocurridoEnMomento real del hecho, en UTC (ISO 8601). No es el momento del intento de entrega.
ambienteCertificacion o Produccion. Una suscripción vive en un solo ambiente.
empresaRutRUT de tu empresa, la dueña de la suscripción. En los eventos recepcion.* sigue siendo el tuyo, no el del proveedor.
datos.documentoIdEn emisión y ciclo SII, el mismo id que devolvió la emisión y que usás en GET /dte/{id}. En recepcion.* es el id del documento recibido (no consultable por esa ruta), y en caf.* es un ancla de deduplicación, no un documento. null si el evento no tiene sujeto.
datos.tipoDteCódigo SII del tipo, como número (33, 34, 39, 41, 52, 56, 61, 110, 111, 112).
datos.folioFolio del documento.
datos.trackIdIdentificador del sobre en el SII. null mientras no haya sobre subido.
datos.estadoSiiCódigo numérico del estado (ver tabla).
datos.estadoSiiNombreEl mismo estado en texto.
datos.detalleTexto legible: lo que respondió el SII, el motivo del error, o la descripción del aviso operativo. Puede venir null.
datos.codRefSolo 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.codRefNombreEl mismo codRef en texto: Anula documento, Corrige texto, Corrige monto.
datos.documentoReferenciadoEl 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.rutEmisorQuién emitió el documento. En emisión y ciclo SII sos vos; en recepcion.* es el proveedor.
datos.rutReceptorQuién lo recibe. En emisión es tu cliente; en recepcion.* sos vos.
datos.razonSocialReceptorRazón social del receptor, para no tener que ir a buscarla.
datos.montoTotalMonto total del documento, en la moneda del documento, como número.
datos.fechaEmisionFecha 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.extraDiccionario abierto con lo propio de cada evento que no habla de un documento (caf.*, certificado.*, webhook.prueba). Llega {} cuando no hay nada. Leelo defensivamente: puede crecer sin previo aviso.

⚠️ Dentro de documentoReferenciado, tipoDte y folio son strings. Se 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: un evento.datos.documentoReferenciado.tipoDte === 33 en JavaScript da false. Compará como texto, o convertí explícitamente.

Códigos de estadoSii:

CódigoNombre
0Pendiente
1Enviado
2Aceptado
3Rechazado
4AceptadoConReparos
5Anulado
6ErrorEnvio

version sigue en 1 y los campos nuevos son aditivos. rutEmisor, rutReceptor, razonSocialReceptor, montoTotal, fechaEmision y extra se agregaron sin subir la versión: agregar claves a un objeto JSON no rompe a ningún consumidor, y subirla habría obligado a cada integrador a revisar su código para no ganar nada. Tu parser tiene que ignorar lo que no conoce en vez de fallar, porque vamos a seguir agregando.

Ejemplo: un evento de recepción

El mismo cuerpo, con los RUT invertidos. Acá rutEmisor es el proveedor que te facturó y rutReceptor sos vos:

json
{
  "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": {}
  }
}

empresaRut y rutReceptor son el mismo RUT —el tuyo—, y razonSocialReceptor es tu razón social. Si tu integración da por hecho que rutEmisor es siempre tu empresa, este evento te va a registrar la compra como si fuera una venta.

Ejemplo: un aviso operativo y el uso de extra

Los eventos que no hablan de un documento dejan casi todo el bloque de documento en null y ponen lo suyo en extra:

json
{
  "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. Leé extra con ?. o su equivalente: es el único bloque del payload que puede sumar claves nuevas sin que cambie nada más.

Headers

HeaderContenido
X-Comges-Signaturet=<unix>,v1=<hex minúscula de 64 caracteres> — ver abajo.
X-Comges-TimestampSegundos Unix UTC. Es el mismo valor que el t= de la firma, y entra en el material firmado.
X-Comges-Event-IdId del evento. Estable entre reintentos → deduplicá por acá.
X-Comges-Event-TypeEl tipo del evento, para rutear sin parsear el cuerpo.
X-Comges-Delivery-IdId de la entrega. Cambia por suscripción, no por intento.
X-Comges-Delivery-AttemptNúmero de intento, 1 en la primera entrega.

A esos se suman los headers extra que hayas declarado en la suscripción, si los hay. Ninguno de ellos puede pisar los X-Comges-* ni el Content-Type.

Verificar la firma

v1 = HMAC_SHA256(secreto, "{timestamp}.{cuerpoCrudo}")

Tres reglas que no se pueden saltar:

  1. Usá los bytes crudos del request, no el JSON re-serializado. Cualquier cambio de espaciado, de orden de claves o de escapado rompe la comparación.
  2. 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.
  3. Compará en tiempo constante (crypto.timingSafeEqual, hmac.compare_digest, 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.

js
import crypto from 'node:crypto'
import express from 'express'

const app = express()
const SECRETO = process.env.COMGES_WEBHOOK_SECRET

// Importante: body crudo, NO express.json()
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 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 y procesar aparte.
  encolarParaProcesar(evento)
  res.sendStatus(200)
})
python
import hmac, hashlib, time

def verificar(secreto: str, cuerpo_crudo: bytes, header_firma: str, tolerancia: int = 300) -> bool:
    partes = dict(p.split("=", 1) for p in header_firma.split(","))
    t = int(partes["t"])
    if abs(time.time() - t) > tolerancia:
        return False
    esperada = hmac.new(
        secreto.encode("utf-8"),
        f"{t}.".encode("utf-8") + cuerpo_crudo,
        hashlib.sha256,
    ).hexdigest()
    return hmac.compare_digest(esperada, partes.get("v1", ""))

Vector de prueba fijo

Para que puedas 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.

DatoValor
Secretowhsec_documentacion_vector_de_prueba
X-Comges-Timestamp1774704312
Cuerpo568 bytes UTF-8, una sola línea, sin salto final (es el bloque de abajo)
Material firmado1774704312. + el cuerpo, concatenados sin nada en medio
v1 esperado66188afa87cae22d8c7e2f642da1df88a459ade615cbf6c8e7303b2ab27165fd
Header completoX-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. Copialo entero, en una sola línea:

json
{"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":{}}}

⚠️ Byte a byte. Si 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".

Reproducilo en la consola:

bash
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 *stdin

O en Python, contra la misma función que vas a usar en tu receptor:

python
import hmac, hashlib

SECRETO = "whsec_documentacion_vector_de_prueba"
TIMESTAMP = 1774704312
CUERPO = b'{"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":{}}}'

v1 = hmac.new(
    SECRETO.encode("utf-8"),
    f"{TIMESTAMP}.".encode("utf-8") + CUERPO,
    hashlib.sha256,
).hexdigest()

assert v1 == "66188afa87cae22d8c7e2f642da1df88a459ade615cbf6c8e7303b2ab27165fd"

Deduplicación: la entrega es at-least-once

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, por ejemplo—. Guardá los ids procesados y devolvé 200 sin volver a hacer nada si ya lo viste.

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 respuestaQué hacemos
2xxEntrega exitosa. No hay más intentos.
408 o 429Reintentamos según la escalera. Son las dos únicas excepciones dentro de los 4xx.
Cualquier otro 4xxAgotamos la entrega de inmediato. Un 404 o un 401 no se arregla repitiendo el mismo POST; no tiene sentido castigarte 31 horas.
5xx, timeout o error de redReintentamos según la escalera.

Respondé rápido y procesá en segundo plano: el POST tiene un timeout de 10 segundos. Si tardás más, el intento cuenta como fallido y te vas a comer un duplicado cuando reintentemos.

Reintentos

7 intentos en total: la entrega inicial más 6 reintentos.

IntentoEspera desde el anteriorAcumulado
1Inmediato
21 minuto1 min
35 minutos6 min
415 minutos21 min
51 hora1 h 21 min
66 horas7 h 21 min
724 horas≈ 31 horas

Después del séptimo la entrega queda agotada: no se vuelve a intentar sola, pero se puede reintentar a mano desde el portal, de a una o en lote.

La escalera sólo corre para lo que tiene sentido reintentar. Un 5xx, un timeout o un error de red la recorren entera. Un 4xx permanente —400, 401, 403, 404— agota la entrega en el primer intento. Las dos excepciones son 408 y 429, que sí escalan: los dos dicen "ahora no, probá después".

⚠️ 20 fallos consecutivos apagan la suscripción. Son unos 3 eventos agotando la escalera entera; a esa altura el endpoint no está "con un problemita". La suscripción queda deshabilitada, con el motivo y la fecha registrados, y dejamos de intentar.

Se reactiva desde el portal, y al reactivarla el contador de fallos consecutivos vuelve a cero, así que no se apaga de nuevo con el primer tropiezo. El orden que funciona: arreglás el endpoint → lo probás con el botón "Probar" → reactivás la suscripción → reintentás en lote lo que quedó agotado. Mientras tanto, el estado real sigue disponible por polling.

Evidencia: qué queda archivado de cada intento

De cada intento —no sólo del último— se archivan dos JSON que podés ver y descargar desde el portal:

  • El request: la URL, el método, todos los headers (incluida la firma completa) y el cuerpo exacto que salió. Es el material con el que se depura una verificación HMAC que no cierra: tenés el cuerpo crudo y el v1 que calculamos nosotros. El secreto nunca se guarda ahí.
  • La respuesta: status, headers y cuerpo de lo que devolviste. El cuerpo se guarda siempre, también en los 2xx, con un tope de 64 KB (si te pasás, se recorta y queda marcado como truncado).

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 trabajo posterior. La evidencia se conserva un año.

Administrar la suscripción desde el portal

Todo esto lo hace el cliente solo, en Portal → Webhooks (/portal/webhooks), con el permiso webhook:configurar:

  • Crear y editar suscripciones: URL https de destino, ambiente y descripción.
  • Elegir qué eventos recibir, del catálogo completo agrupado por categoría, o * para todos.
  • Filtrar por tipo de DTE (ver abajo).
  • Agregar headers extra para atravesar tu gateway (ver abajo).
  • Generar o fijar tu propio secreto, y rotarlo.
  • Pausar, reactivar o eliminar una suscripción.
  • Probar el endpoint: dispara un webhook.prueba contra ese destino y te devuelve el resultado del intento —status, milisegundos, error y el cuerpo que contestaste— en el momento, sin esperar ningún backoff.
  • Ver el historial de entregas con la evidencia de cada intento.
  • Reintentar, de a una o en lote (tope de 500 entregas por corrida).
  • Ver métricas: entregadas, fallidas y pendientes, tasa de éxito y latencia de tu endpoint, por período, por tipo de evento y por suscripción.

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 se mira si el tipo de evento te interesa, después si datos.tipoDte pasa el filtro.
  • Si el evento no trae tipoDte, el filtro no aplica y el evento se entrega igual. Es el caso de certificado.por_vencer: no hay documento que filtrar, y silenciar un aviso operativo por tener un filtro de documentos puesto 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 …—.

  • No pueden pisar ningún header X-Comges-* ni el Content-Type. Al guardar, uno de esos nombres se rechaza con 400; al entregar, se ignora. Si pudieras sobrescribir X-Comges-Signature estarías anulando tu propia verificación, y si pudieras sobrescribir Content-Type romperí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 y quedan visibles en el historial de entregas, igual que el resto de los headers.
  • No reemplazan la firma. Sirven 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: verificala 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.
  • 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. Actualizá tu receptor antes de rotar, no después —o dejalo 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 la evidencia. 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.

  • https obligatorio. No aceptamos http.
  • 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 302 no se sigue: devolvé 2xx en 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 dte.error_envio no es terminal, el documento va a terminar en un estado final igual y ese sí te llega.


19. Ejemplo end-to-end con curl

Copiable y ejecutable. Emite una factura 33, resuelve el 202 si la emisión queda esperando folio, consulta el estado hasta que el SII se pronuncia, descarga XML y PDF, y anula el documento dos veces para mostrar que la anulación es idempotente.

Tres cosas que hace bien y que conviene copiar a tu integración:

  1. Ramifica por el código HTTP, no por la presencia de campos. Con un 202, jq -r '.id' devuelve la cadena "null" y el script seguiría pidiendo /dte/null/xml sin darse cuenta.
  2. Resuelve el ticketId del 202 leyéndolo del cuerpo. Seguir el header Location no sirve: no llega (§8).
  3. Comprueba el estado antes de anular. Un emisor.estado-no-anulable es un resultado esperable, no un incidente para soporte.

Requiere curl, jq y uuidgen.

bash
#!/usr/bin/env bash
# Ejemplo end-to-end de la API pública Comges DTE.
# Requisitos: curl, jq, uuidgen.
set -euo pipefail

BASE="https://api-publica.dtecomges.cl"
API_KEY="pk_test_XXXXXXXX_reemplazame"

CUERPO=$(mktemp)
trap 'rm -f "$CUERPO"' EXIT

# Hace la petición, deja el cuerpo en $CUERPO e imprime el código HTTP.
peticion() {
  local metodo="$1" ruta="$2" json="${3:-}"
  local args=(-sS -o "$CUERPO" -w '%{http_code}'
              -X "$metodo" "$BASE$ruta"
              -H "X-Api-Key: $API_KEY")
  if [ -n "$json" ]; then
    args+=(-H 'Content-Type: application/json' -d "$json")
  fi
  curl "${args[@]}"
}

abortar() {
  echo "✗ $1" >&2
  jq . "$CUERPO" >&2 2>/dev/null || cat "$CUERPO" >&2
  exit 1
}

# transactionId: se genera UNA vez y se reusa en todos los reintentos.
TX=$(uuidgen | tr 'A-Z' 'a-z')

# ─── 1. Emitir una factura 33 ────────────────────────────────────────────────
echo "→ Emitiendo factura 33 (transactionId=$TX)"
BODY=$(cat <<JSON
{
  "transactionId": "$TX",
  "tipoDte": 33,
  "receptor": {
    "rut": "96790240-3",
    "razonSocial": "Cliente Ejemplo S.A.",
    "giro": "Comercio al por mayor",
    "direccion": "Av. Providencia 1234",
    "comuna": "Providencia"
  },
  "detalles": [
    {
      "nroLinea": 1,
      "nombreItem": "Consultoría técnica",
      "cantidad": 1,
      "unidadMedida": "UN",
      "precioUnitario": 1000000
    }
  ]
}
JSON
)

STATUS=$(peticion POST "/api/public/v1/dte" "$BODY")
jq . "$CUERPO"

DOC_ID=""

case "$STATUS" in
  201)
    # Camino normal: había folio disponible, el documento ya está emitido.
    DOC_ID=$(jq -r '.id // empty' "$CUERPO")
    ;;
  202)
    # No había folio: la emisión quedó encolada y el SII ya fue notificado.
    # Le pasa a todo contribuyente nuevo, al que el SII autoriza folios de a pocos.
    TICKET=$(jq -r '.ticketId // empty' "$CUERPO")
    [ -n "$TICKET" ] || abortar "202 sin ticketId"
    echo "→ Emisión encolada esperando folio. ticketId=$TICKET"

    for espera in 5 10 20 30 60 60; do
      sleep "$espera"
      TSTATUS=$(peticion GET "/api/public/v1/dte/pendientes/$TICKET")
      [ "$TSTATUS" = "200" ] || abortar "consulta del ticket devolvió HTTP $TSTATUS"

      ESTADO_TICKET=$(jq -r '.estado' "$CUERPO")
      echo "→ ticket estado=$ESTADO_TICKET (posición en cola: $(jq -r '.posicionEnCola' "$CUERPO"))"

      case "$ESTADO_TICKET" in
        Completado)
          DOC_ID=$(jq -r '.documentoId // empty' "$CUERPO")
          break
          ;;
        Error|Cancelado)
          abortar "el ticket terminó en $ESTADO_TICKET: $(jq -r '.codigoError // "sin código"' "$CUERPO")"
          ;;
      esac
    done
    ;;
  *)
    abortar "la emisión devolvió HTTP $STATUS"
    ;;
esac

# Sin id no hay nada más que hacer: cortar acá evita pedir /dte/null/xml.
[ -n "$DOC_ID" ] && [ "$DOC_ID" != "null" ] \
  || abortar "no se resolvió el id del documento (¿el ticket sigue en cola?)"

STATUS=$(peticion GET "/api/public/v1/dte/$DOC_ID")
[ "$STATUS" = "200" ] || abortar "consulta del documento devolvió HTTP $STATUS"
FOLIO=$(jq -r '.folio' "$CUERPO")
echo "→ Documento $DOC_ID, folio $FOLIO (el folio ya es definitivo)"

# ─── 2. Polling del estado en el SII (con backoff) ───────────────────────────
# Estados finales: Aceptado, AceptadoConReparos, Rechazado.
# 'Enviado' NO es final —el SII todavía no dio veredicto—, pero ya permite anular,
# así que corta el bucle para no seguir gastando cuota de rate limit.
ESTADO=""
for espera in 5 10 20 40 60; do
  sleep "$espera"
  STATUS=$(peticion GET "/api/public/v1/dte/$DOC_ID")
  [ "$STATUS" = "200" ] || abortar "consulta de estado devolvió HTTP $STATUS"

  ESTADO=$(jq -r '.estadoSii' "$CUERPO")
  echo "→ estadoSii=$ESTADO"
  case "$ESTADO" in
    Enviado|Aceptado|AceptadoConReparos|Rechazado) break ;;
  esac
done

if [ "$ESTADO" = "Rechazado" ]; then
  echo "✗ El SII rechazó el documento: $(jq -r '.glosaSii // "sin glosa"' "$CUERPO")" >&2
fi

# ─── 3. Descargar XML firmado y PDF ──────────────────────────────────────────
STATUS=$(curl -sS -o "dte-$FOLIO.xml" -w '%{http_code}' \
  "$BASE/api/public/v1/dte/$DOC_ID/xml" -H "X-Api-Key: $API_KEY")
[ "$STATUS" = "200" ] || { echo "✗ XML devolvió HTTP $STATUS" >&2; exit 1; }

STATUS=$(curl -sS -o "dte-$FOLIO.pdf" -w '%{http_code}' \
  "$BASE/api/public/v1/dte/$DOC_ID/pdf?formato=carta" -H "X-Api-Key: $API_KEY")
[ "$STATUS" = "200" ] || { echo "✗ PDF devolvió HTTP $STATUS" >&2; exit 1; }

echo "→ Guardados dte-$FOLIO.xml y dte-$FOLIO.pdf"

# ─── 4. Anular — solo si el documento está en un estado anulable ─────────────
# Anulable: tipos 33, 34, 39, 41, 52 y 56, en estado Enviado, Aceptado o
# AceptadoConReparos, y con CAF vigente de tipo 61.
case "$ESTADO" in
  Enviado|Aceptado|AceptadoConReparos) ;;
  *)
    echo "→ Estado '$ESTADO': el documento todavía no es anulable. Fin del ejemplo."
    exit 0
    ;;
esac

echo "→ Anulando"
STATUS=$(peticion POST "/api/public/v1/dte/$DOC_ID/anular" '{"motivo":"Prueba de integración"}')
[ "$STATUS" = "201" ] || abortar "la anulación devolvió HTTP $STATUS"

jq . "$CUERPO"
NC_ID=$(jq -r '.notaCredito.id' "$CUERPO")
NC_FOLIO=$(jq -r '.notaCredito.folio' "$CUERPO")
echo "→ Nota de crédito $NC_ID folio $NC_FOLIO"

# ─── 5. Reintentar la anulación: devuelve LA MISMA NC, no emite otra ─────────
STATUS=$(peticion POST "/api/public/v1/dte/$DOC_ID/anular" '{"motivo":"Prueba de integración"}')
[ "$STATUS" = "201" ] || abortar "el reintento de anulación devolvió HTTP $STATUS"

NC_ID2=$(jq -r '.notaCredito.id' "$CUERPO")
if [ "$NC_ID" = "$NC_ID2" ]; then
  echo "✓ Idempotente: misma NC ($NC_ID). No se quemó un segundo folio."
else
  echo "✗ Se emitieron dos NC distintas — reportar a soporte." >&2
  exit 1
fi

Para producción: cambiar la key por una pk_live_*. La URL no cambia — el ambiente lo define la key (§4).


Qué no existe todavía

Esta sección existe para que nadie escriba código contra una promesa.

FunciónEstado real
Administrar webhooks por API (crear, editar o listar una suscripción desde código)Ninguna ruta bajo /api/public/v1/* administra suscripciones, rota el secreto ni lee el historial de entregas. El alta es autoservicio, pero desde el portal (/portal/webhooks, permiso webhook:configurar): ver §18. El scope webhook:configurar se puede tildear al crear una key, pero no hay ruta pública que lo consuma.
Los eventos certificado.por_vencer y recepcion.documento_reclamadoEstán en el catálogo y se pueden suscribir, pero hoy no los publica nadie: quien se suscriba no recibe nada y no hay error que lo avise. Ver §18.
Emisión de los tipos 43 y 46POST /dte responde 501 emisor.tipo-dte-no-emisible sin consumir folio. Los scopes existen pero no habilitan nada. Sin fecha comprometida.
Listado de documentos (GET /api/public/v1/dte con filtros)No existe. Los documentos se consultan por id. Guardá el id que devuelve la emisión. (Lo que sí existe es GET /dte/pendientes, que lista tickets de emisiones encoladas, no documentos.)
Cancelar un ticket encoladoNo hay ruta pública. Un ticket se resuelve solo o queda en error.
Emisión en loteNo existe en la superficie pública. Un documento por request.
Emisión por plantillaExiste en el portal, no en la API pública. El scope plantillas:emitir se puede asignar, pero no hay ruta que lo consuma.
Envío de sobres al SII a demandaEl envío es automático y en segundo plano. No hay endpoint público para forzarlo. Los scopes dte:sobre:* y dte:proceso:read no tienen ruta pública.
Links de descarga públicos y permanentesNo existen, y no van a existir. Lo que sí hay son enlaces firmados con vencimiento (xmlUrl / pdfUrl, §10): se abren sin API Key pero caducan, y renovarlos es volver a consultar el documento. Un link eterno queda descartado por diseño.
Revocar un enlace de descarga ya emitidoNo hay ruta pública. Un enlace filtrado deja de servir cuando vence; si necesitás cortarlo antes, escribí a soporte.
Recuperar el ted de un documento ya emitidoNo existe. ted solo viaja en la respuesta de la emisión; en las consultas llega null. Guardalo al emitir, o sacalo del XML firmado, que lo contiene.
Alta de empresa por APINo existe. La ejecuta soporte. Ver §1.
Consultar o disparar la certificación SII por APINo existe. Ver §14.
Catálogos de aduana por API (país, puerto, moneda, modalidad y cláusula de venta)No hay endpoint público. Se consultan en el portal o se piden a soporte.
Anulación de exportación por POST /anularNo aplica: se anula emitiendo una NC 112. No existe POST /exportacion/{id}/anular.
PDF de exportación en 80 mmNo existe. Exportación se renderiza solo en Carta.
Ventana de gracia en la rotación de keysNo existe. Rotar invalida el secreto anterior de inmediato.
Convertir una key de certificación en una de producciónNo existe. Hay que crear una key nueva. Ver §14.

Soporte

Ante un error 5xx, un 504 sin cuerpo JSON o un comportamiento que no calce con este documento, escribí a [email protected] con:

  1. El code de la respuesta (recordá: los 500 no traen code).
  2. El traceId, si vino. En los errores 4xx de negocio viene null — no lo esperes.
  3. El prefijo de la key (los 8 caracteres, nunca el secreto).
  4. El transactionId que usaste, y el ticketId si fue un 202.
  5. El id del documento, si ya se emitió.
  6. Timestamp aproximado en UTC.