# 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](#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](#1-antes-de-empezar-dar-de-alta-la-empresa) 2. [Lo mínimo para entender el modelo](#2-lo-mínimo-para-entender-el-modelo) 3. [Autenticación con API Key](#3-autenticación-con-api-key) 4. [Ambientes: certificación y producción](#4-ambientes-certificación-y-producción) 5. [Scopes (permisos de la key)](#5-scopes-permisos-de-la-key) 6. [Health check](#6-health-check) 7. [Prerequisitos: qué tiene que estar listo antes de emitir](#7-prerequisitos-qué-tiene-que-estar-listo-antes-de-emitir) 8. [Emitir un DTE](#8-emitir-un-dte) 9. [Consultar el estado (modelo asíncrono)](#9-consultar-el-estado-modelo-asíncrono) 10. [Descargar XML y PDF](#10-descargar-xml-y-pdf) - [Enlaces firmados para el comprador](#enlaces-firmados-para-el-comprador) 11. [Anular un documento](#11-anular-un-documento) 12. [Devolución parcial: notas de crédito por línea o por monto](#12-devolución-parcial-notas-de-crédito-por-línea-o-por-monto) 13. [Exportación (110 / 111 / 112)](#13-exportación-110--111--112) 14. [Paso a producción (go-live)](#14-paso-a-producción-go-live) 15. [Errores: formato y códigos](#15-errores-formato-y-códigos) 16. [Idempotencia y reintentos](#16-idempotencia-y-reintentos) 17. [Rate limiting y límites duros](#17-rate-limiting-y-límites-duros) 18. [Webhooks](#18-webhooks) - [Catálogo de eventos](#catálogo-de-eventos) - [Vector de prueba fijo](#vector-de-prueba-fijo) 19. [Ejemplo end-to-end con curl](#19-ejemplo-end-to-end-con-curl) 20. [Qué no existe todavía](#qué-no-existe-todavía) 21. [Soporte](#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: | Dato | Detalle | |---|---| | **RUT de la empresa** | El del contribuyente que va a emitir. | | **Clave tributaria del SII** | La 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 certificado** | La contraseña del archivo `.pfx`. | | **RUT del titular del certificado** | El 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](#7-prerequisitos-qué-tiene-que-estar-listo-antes-de-emitir). - **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](#14-paso-a-producción-go-live). ### 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 [soporte@comges.cl](mailto:soporte@comges.cl) 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ón | Permiso que necesita el usuario del portal | |---|---| | Ver las keys existentes | `empresa:api-keys:read` | | Crear, rotar, revocar o cambiar scopes | `empresa: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 | Concepto | Qué significa acá | |---|---| | **Base URL** | `https://api-publica.dtecomges.cl` — todas las rutas de negocio cuelgan de `/api/public/v1/*`. | | **Credencial** | Una **API Key** en el header `X-Api-Key`. No hay OAuth, no hay JWT, no hay sesiones. | | **Empresa emisora** | Sale del claim de la key. **Nunca** se manda en el request. | | **Ambiente SII** | Sale del claim de la key (`pk_test_` → Certificación, `pk_live_` → Producción). **Nunca** se elige en el body. | | **Emisión** | Sí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](#8-emitir-un-dte). | | **Formato de error** | RFC 7807 extendido: `{ code, title, status, detail?, field?, traceId? }`. El campo estable para programar es `code`. **Con excepciones** — ver [§15](#15-errores-formato-y-códigos). | | **Server-to-server** | La 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 ruta | Scope | Qué hace | |---|---|---| | `POST /api/public/v1/dte` | `dte:emit:{tipo}` | Emite un DTE nacional. Devuelve `201` o `202 + ticketId`. | | `GET /api/public/v1/dte/{id}` | `dte:read` | Consulta el documento y su estado ante el SII. | | `GET /api/public/v1/dte/{id}/xml` | `dte:read` | Descarga el XML firmado. | | `GET /api/public/v1/dte/{id}/pdf` | `dte:read` | Descarga el PDF (`?formato=carta` o `?formato=80mm`). | | `POST /api/public/v1/dte/{id}/anular` | `dte:emit:61` | Anula el documento completo (NC 61, `CodRef=1`). | | `GET /api/public/v1/dte/{id}/acreditable` | `dte:read` | Saldo disponible para notas de crédito parciales. | | `POST /api/public/v1/dte/{id}/nota-credito` | `dte:emit:61` | Nota de crédito **parcial** (`CodRef=3`). No anula. | | `GET /api/public/v1/dte/pendientes/{ticketId}` | `dte:read` | Resuelve el ticket que devolvió un `202`. | | `GET /api/public/v1/dte/pendientes` | `dte:read` | Lista las emisiones encoladas esperando folio. | **Exportación** | Verbo y ruta | Scope | Qué hace | |---|---|---| | `POST /api/public/v1/exportacion` | `dte:emit:{110\|111\|112}` | Emite un DTE de exportación. | | `GET /api/public/v1/exportacion/{id}` | `dte:read` | Consulta la exportación y su estado. | | `GET /api/public/v1/exportacion/{id}/xml` | `dte:read` | Descarga el XML firmado. | | `GET /api/public/v1/exportacion/{id}/pdf` | `dte:read` | Descarga el PDF (**solo Carta**, no admite `?formato=`). | **Descargas firmadas** — las únicas rutas **sin API Key**. Ver [§10](#enlaces-firmados-para-el-comprador). | Verbo y ruta | Scope | Qué hace | |---|---|---| | `GET /api/public/v1/descargas/dte/{id}/xml` | ninguno | XML firmado, autorizado por el token del enlace. | | `GET /api/public/v1/descargas/dte/{id}/pdf` | ninguno | PDF (`?formato=carta` o `?formato=80mm`). | | `GET /api/public/v1/descargas/exportacion/{id}/xml` | ninguno | XML firmado de la exportación. | | `GET /api/public/v1/descargas/exportacion/{id}/pdf` | ninguno | PDF 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 ruta | Scope | Qué hace | |---|---|---| | `GET /health` | ninguno | Liveness. Público, sin API Key, sin rate limit. Ver [§6](#6-health-check). | 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ón | Respuesta | |---|---| | Sin header | `401` con cuerpo `{"code":"api-key.ausente","title":"..."}` | | Key mal formada, inexistente, revocada o expirada | `401` con el **mismo** cuerpo `api-key.ausente` | | Key válida pero sin el scope del endpoint | `403` 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](#quién-puede-crear-la-api-key). > ⚠️ **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 | Prefijo | Ambiente | SII | Qué emite | |---|---|---|---| | `pk_test_` | Certificación | Maullín | Documentos de prueba. No tienen validez tributaria. | | `pk_live_` | Producción | Palena | **Documentos 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](#14-paso-a-producción-go-live). --- ## 5. Scopes (permisos de la key) Cada key lleva una lista de scopes. El servidor valida el scope **antes** de reservar folio. | Scope | Habilita | |---|---| | `dte:emit:33` | Emitir factura electrónica afecta | | `dte:emit:34` | Emitir factura exenta | | `dte:emit:39` | Emitir boleta electrónica | | `dte:emit:41` | Emitir boleta exenta | | `dte:emit:52` | Emitir guía de despacho | | `dte:emit:56` | Emitir nota de débito | | `dte:emit:61` | Emitir nota de crédito, **anular documentos** y emitir **notas de crédito parciales** | | `dte:emit:110` / `111` / `112` | Emitir factura / ND / NC de exportación | | `dte:read` | Consultar documentos, tickets, saldo acreditable, y descargar XML y PDF | | `dte:emit:43` / `dte:emit:46` | **No operativo.** El scope se puede asignar, pero la emisión responde `501`. Ver [§8](#tipos-que-esta-api-no-emite). | 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](#18-webhooks). 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. | Prerequisito | Quién lo resuelve | Si falta | |---|---|---| | Empresa dada de alta | Soporte de Comges | No hay usuario, no hay portal, no hay key. Ver [§1](#1-antes-de-empezar-dar-de-alta-la-empresa). | | **Tipo de documento habilitado** para la empresa en ese ambiente | Soporte de Comges | `403 emisor.tipo-dte-no-habilitado` | | **Certificación SII aprobada por tipo** — *solo en Producción* | Equipo de Comges + SII | `403 cert.tipo-no-aprobado-prod`. Si la verificación no responde: `503 cert.servicio-no-disponible` (reintentable). Ver [§14](#14-paso-a-producción-go-live). | | **CAF vigente** del tipo que vas a emitir | Automático (se piden al SII) o carga manual | Normalmente 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 acreditar | Igual que el anterior | `400` o `409 emisor.sin-folios-nc` | | **Casa matriz** configurada | Se toma del SII en el alta | `422 emisor.empresa-sin-sucursal` | | **Actividades económicas (actecos)** activas | Se toman del SII en el alta | `422 emisor.empresa-sin-actecos` | | **Certificado digital activo** | Se carga en el alta | DTE 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](#el-certificado-digital-tiene-que-estar-activo). | | **Plan vigente** | Comercial | `403 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`](#13-exportación-110--111--112). > 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ón | Respuesta | Folio | |---|---|---| | La empresa no tiene un PFX activo | `422 emisor.sin-certificado` | **no se consume** | | Hay un PFX cargado, pero no se pudo abrir (clave equivocada o certificado vencido) | `422 emisor.certificado-invalido` | **no 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](#1517-exportación-exp). ### 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:** | Campo | Tipo | Obligatorio | Notas | |---|---|---|---| | `tipoDte` | int | sí | Código SII. | | `receptor` | objeto | sí | Ver abajo. | | `detalles` | array | sí | 1 a **60** líneas (límite duro del SII). | | `transactionId` | uuid | no | Clave de idempotencia. Ver [§16](#16-idempotencia-y-reintentos). | | `ambiente` | string | no | **No usar con API Key.** Ver [§4](#4-ambientes-certificación-y-producción). | | `fechaEmision` | string | no | `dd-MM-yyyy` o `yyyy-MM-dd`. Si se omite, hoy en Chile. No puede ser futura. | | `formaPago` | byte | no | `1` contado, `2` crédito, `3` sin costo. | | `fechaVencimiento` | string | no | Mismo formato que `fechaEmision`. En 33/34 a crédito, si falta se usa emisión + 30 días. | | `medioPago` | string/int | no | `1..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). | | `indServicio` | byte | no | **Solo 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)](#boleta-electrónica-39-y-boleta-exenta-41). | | `indTraslado` | byte | condicional | **Obligatorio en la guía 52**, prohibido en el resto. Ver [Guía de despacho (52)](#guía-de-despacho-52). | | `tipoDespacho` | byte | no | **Solo en la guía 52.** `1`/`2`/`3`. | | `transporte` | objeto | no | Datos de transporte y destino. Solo tiene sentido en la 52; en boletas se descarta. | | `referencias` | array | condicional | **Obligatorio en 56 y 61.** Máx. **40**. | | `impuestosAdicionales` | array | no | Máx. **20**. | | `descuentosGlobales` | array | no | Máx. **20**. | | `observaciones` | string | no | Máx. 500. No viaja al SII; se imprime en el PDF. | | `moneda`, `tasaCambio` | string / decimal | no | Default `PESO CL`. | | `empresaSucursalId`, `actecoIds`, `empresaActecoIdPrincipal` | uuid(s) | no | Perfil de emisión. Si se omiten, el servidor elige casa matriz y actecos activos. | | `customFields` | objeto | no | Campos definidos para tu empresa. | **`receptor`:** | Campo | Obligatorio | Largo máx. | Notas | |---|---|---|---| | `rut` | sí | — | Formato `12345678-5`. Se normaliza automáticamente (se aceptan puntos, se guardan sin). RUT inválido → `400 emisor.rut-receptor-invalido`. | | `razonSocial` | sí | **100** | | | `giro` | no | **40** | Exigido por el SII en factura 33. | | `direccion` | no | **70** | Exigida por el SII en factura 33. | | `comuna` | no | **20** | Exigida por el SII en factura 33. | | `ciudad` | no | **20** | | | `contacto` | no | **80** | | | `rutSolicita` | no | **20** | | | `correos` | no | — | Lista de correos adicionales a los que se envía copia (PDF + XML). No viaja al XML. | **`detalles[]`:** | Campo | Obligatorio | Largo máx. | Notas | |---|---|---|---| | `nroLinea` | sí | — | 1..60. | | `nombreItem` | sí | **80** | | | `cantidad`, `precioUnitario` | sí | — | `cantidad > 0` (hasta 6 decimales), `precioUnitario >= 0` **redondeado a peso entero**. Ver [Aritmética](#aritmética-cómo-se-calcula-el-documento). | | `descripcionItem` | no | **1000** | | | `codigoItem` | no | **35** | | | `tipoCodigo` | no | **10** | | | `unidadMedida` | no | **4** | Ver la advertencia abajo. | | `descuentoMonto`, `recargoMonto` | no | — | Montos, no porcentajes. | | `indExe` | no | — | `0` 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): | Campo | Obligatorio | Largo máx. | |---|---|---| | `nroLinea` | sí | — | | `tipoDocRef` | sí | **3** | | `folioRef` | sí | **18** | | `fechaRef` | sí | — | | `codRef` | recomendado | — (`1` anula, `2` corrige texto, `3` corrige monto) | | `razonRef` | no | **90** | > Para **anular** un documento no armes la referencia a mano: usá > [`POST /{id}/anular`](#11-anular-un-documento), que la resuelve el servidor. Para una > **devolución parcial**, usá [`POST /{id}/nota-credito`](#12-devolución-parcial-notas-de-crédito-por-línea-o-por-monto). > **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`](#11-anular-un-documento), 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](#11-anular-un-documento)). **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 - marcela.ruiz@ejemplo.cl", "correos": ["cuentas.por.pagar@ejemplo.cl"] }, "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: | Campo | Valor | |---|---| | `montoNeto` | 1 075 000 | | `montoIva` | 204 250 | | `montoTotal` | 1 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 } ] } ``` | Campo | Valor | |---|---| | `montoNeto` | 0 | | `montoExento` | 530 000 | | `montoIva` | 0 | | `montoTotal` | 530 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 } ] } ``` | Campo | Valor | De dónde sale | |---|---|---| | `montoNeto` | 7 200 | 2 × 1 500 + 4 200 | | `montoIva` | 1 368 | | | `montoTotal` | 8 568 | lo 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 **sí** 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. | Valor | Significado | ¿Emitible? | |---|---|---| | `1` | Operación constituye venta | sí | | `2` | Ventas por efectuar | sí | | `3` | Consignaciones | sí | | `4` | Entrega gratuita | sí | | `5` | Traslados internos | sí | | `6` | Otros traslados no venta | sí | | `7` | Guía de devolución | sí | | `8` | Traslado para exportación | **no** → `400 emisor.ind-traslado-exportacion-no-soportado` | | `9` | Venta para exportación | **no** → `400 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:** | Campo | Largo máx. | Notas | |---|---|---| | `patente` | 8 | | | `rutTransportista` | — | RUT chileno válido; si no, `400 emisor.transporte-rut-invalido`. | | `rutChofer` | — | RUT chileno válido. | | `nombreChofer` | 30 | | | `direccionDestino` | 70 | | | `comunaDestino` | 20 | | | `ciudadDestino` | 20 | | **`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 } ] } ``` | Campo | Valor | |---|---| | `montoNeto` | 120 000 | | `montoIva` | 22 800 | | `montoTotal` | 142 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 `referencias` → `400 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**. | `codRef` | Significado | |---|---| | `1` | Anula documento referenciado | | `2` | Corrige texto del documento referenciado | | `3` | Corrige 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 } ] } ``` | Campo | Valor | |---|---| | `montoNeto` | 25 000 | | `montoIva` | 4 750 | | `montoTotal` | 29 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`](#11-anular-un-documento), y para una devolución > parcial [`POST /{id}/nota-credito`](#12-devolución-parcial-notas-de-crédito-por-línea-o-por-monto). > 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 } ] } ``` | Campo | Valor | |---|---| | `montoNeto` | 50 000 | | `montoIva` | 9 500 | | `montoTotal` | 59 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 } ] } ``` | Campo | Valor | |---|---| | `montoNeto` | 100 000 | | `montoExento` | 40 000 | | `montoIva` | 19 000 | | `montoTotal` | 159 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](#aritmética-cómo-se-calcula-el-documento). ```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`. | Campo | Valor | |---|---| | `montoNeto` | 66 500 | | `montoIva` | 12 635 | | `montoTotal` | 79 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. | Campo | Obligatorio | Valores | |---|---|---| | `nroLinea` | sí | 1..20 | | `tipoMovimiento` | sí | `"D"` descuento, `"R"` recargo — otro valor: `400 emisor.dr-tipo-movimiento-invalido` | | `tipoValor` | sí | `"%"` porcentaje, `"$"` monto — otro valor: `400 emisor.dr-tipo-valor-invalido` | | `valor` | sí | ≥ 0 — negativo: `400 emisor.dr-valor-negativo` | | `glosa` | no | máx. 45 → `400 emisor.dr-glosa-excede-45` | | `indExeDR` | no | ausente/`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 } ] } ``` | Campo | Valor | Cálculo | |---|---|---| | `montoNeto` | 90 000 | 100 000 − 10 % | | `montoExento` | 36 000 | 40 000 − 10 % | | `montoIva` | 17 100 | 19 % de 90 000 | | `montoTotal` | 143 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](#13-exportación-110--111--112)), 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": "
", "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: | Campo | Para qué sirve | |---|---| | `id` | **Guardalo siempre.** Es el identificador de todas las operaciones posteriores. | | `folio` | **Definitivo.** Podés imprimirlo, guardarlo y mostrarlo. | | `estadoSii` | Estado ante el SII, como texto. Ver [§9](#9-consultar-el-estado-modelo-asíncrono). | | `glosaSii` | **El 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`. | | `trackId` | Identificador del envío ante el SII. `null` hasta que el documento sale. | | `glosa` | El primer `nombreItem` truncado a 80. **Es contenido del documento, no una respuesta del SII** — no confundir con `glosaSii`. | | `montoNF` | Monto no facturable (líneas con `indExe` 2 o 6, ej. propina). | | `montoAcreditado` / `estadoAcreditacion` | Cuánto se acreditó por notas de crédito parciales, y en qué estado quedó (`SinAcreditar` / `Parcial` / `AcreditadoTotal` / `Anulado`). Ver [§12](#12-devolución-parcial-notas-de-crédito-por-línea-o-por-monto). | | `anuladoEn` / `notaCreditoId` | Marca de anulación completa. `null` = vigente. | | `offsetUtcHoras` | Offset de Chile al emitir. Con `creadoEn` (UTC) reconstruís la hora local exacta. | | `customFields` / `customFieldLabels` | Campos personalizados definidos para tu empresa. | | `plantillaId` / `cotizacionIds` | Trazabilidad interna del portal. En emisión por API vienen `null`. | | `xmlBlobPath` | Referencia interna de almacenamiento. **No la uses**: no es una URL descargable. Para el XML, usá [`GET /{id}/xml`](#10-descargar-xml-y-pdf). | | `ted` | **El bloque `` 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` / `pdfUrl` | **Enlaces de descarga firmados, que se abren sin API Key.** Son para reenviárselos al comprador. Vencen. Ver [Enlaces firmados para el comprador](#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](#15-errores-formato-y-códigos)). > 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: | `estado` | Qué significa | Qué hacer | |---|---|---| | `Pendiente` | En la fila, todavía no se intentó. | Seguir consultando. | | `Procesando` | Se está emitiendo ahora. | Seguir consultando. | | `Completado` | **Listo.** Trae `documentoId`. | Consultá `GET /dte/{documentoId}` y guardá ese id como el del documento. | | `Error` | Falló. 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. | | `Cancelado` | La 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](#17-rate-limiting-y-límites-duros)). #### Listar los tickets abiertos ``` GET /api/public/v1/dte/pendientes?estado={n}&pagina=1&tamano=50 (scope dte:read) ``` | Parámetro | Default | Notas | |---|---|---| | `estado` | (todos) | Byte del estado. | | `pagina` | `1` | Menor a 1 se corrige a 1. | | `tamano` | `50` | Má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](#17-rate-limiting-y-límites-duros)). > Se puede evitar buena parte de este polling recibiendo un aviso firmado en tu servidor. El > contrato está en [§18](#18-webhooks), 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](#el-certificado-digital-tiene-que-estar-activo)). 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 ``` | `formato` | Resultado | |---|---| | omitido | **Carta, siempre** — también en boletas 39/41. | | `carta` | Hoja carta. | | `80mm` | Ticket 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ámetro | Qué es | |---|---| | `t` | La firma. **Es la credencial**: tratala como un secreto mientras esté vigente. | | `exp` | Vencimiento, en segundos unix. Podés leerlo para saber hasta cuándo sirve el enlace sin tener que probarlo. | | `e`, `a` | Empresa y ambiente. Son criterio de búsqueda, no autorización: alterarlos solo consigue un `404`. | | `formato` | Solo 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: | Respuesta | `ted` | `xmlUrl` / `pdfUrl` | |---|---|---| | `POST /dte` → `201` | el TED del documento | firmadas | | `GET /dte/{id}` → `200` | `null` (ver [§8](#respuesta-201--el-camino-normal)) | firmadas, con vencimiento nuevo | | `POST /exportacion` → `201` | `null` (exportación no lo expone) | firmadas | | `GET /exportacion/{id}` → `200` | `null` | firmadas, con vencimiento nuevo | Dónde **no** aparecen: - **`POST /dte` → `202`.** 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 | `code` | HTTP | Cuándo | |---|---|---| | `descarga.token-invalido` | 403 | Falta `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-vencido` | 403 | La firma es correcta pero `exp` ya pasó. | Ambos usan el mismo cuerpo de error que el resto de la API ([§15](#15-errores-formato-y-códigos)), 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](#17-rate-limiting-y-límites-duros)) — 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`](#12-devolución-parcial-notas-de-crédito-por-línea-o-por-monto). ### 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" } ``` | Campo | Notas | |---|---| | `motivo` | Glosa que viaja como `RazonRef` al SII. Máx. **90** caracteres; si es más largo se **trunca**, no falla. Si se omite: `"Anula documento"`. | | `transactionId` | Aceptado 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ón | Regla | |---|---| | Tipo | **33, 34, 39, 41, 52, 56**. Una NC 61 no se anula con otra NC; exportación tiene su propio flujo. | | Estado SII | **Enviado, Aceptado o AceptadoConReparos**. Un documento que todavía no salió al SII (`Pendiente`) o que fue rechazado no se anula. | | Folios | Tiene que haber CAF vigente de **tipo 61**. | | Ambiente | La 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](#15-errores-formato-y-códigos); 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}/anular` | `POST /{id}/nota-credito` | |---|---|---| | Referencia SII | `CodRef=1` (anula documento completo) | `CodRef=3` (corrige monto) — salvo el modo `texto`, que usa `CodRef=2` (corrige texto) | | Efecto | El documento queda **anulado** (`anuladoEn` se llena) | El documento **sigue vigente** por el saldo restante | | Monto | El total del documento | El que vos indiques, hasta el saldo disponible | | Veces | Una sola vez por documento | **Varias** notas parciales sobre el mismo documento | | Idempotencia | Automática, por documento | Por `transactionId` que mandás vos | | Referencia al original | La arma el servidor | La arma el servidor | | Scope | `dte:emit:61` | `dte: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: | Campo | Qué significa | |---|---| | `documento.saldoAcreditable` | **El 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.estadoAcreditacion` | `SinAcreditar` / `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` / `motivoNoAcreditar` | Si es `false`, el motivo viene en texto legible. | | `modosDisponibles` | Qué modos acepta este documento hoy. Puede no incluir `lineas` (ver abajo). | | `advertenciaPlazo` | Texto 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` / `cantidadDisponible` | Lo que queda por acreditar de cada línea. Es contra esto que se valida el modo `lineas`. | | `lineas[].montoEfectivo` | El neto real que la línea aporta. `null` en líneas no facturables o todavía sin procesar. | | `notasCreditoPrevias` | Historial 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" } ``` | Campo | Obligatorio | Notas | |---|---|---| | `modo` | sí | `"lineas"`, `"monto"` o `"texto"`. Otro valor → `400 emisor.modo-invalido`. | | `motivo` | no | Glosa que viaja como `RazonRef` (máx. **90**, se **trunca**). | | `lineas` | solo en modo `lineas` | `[{ nroLinea, cantidad }]`. La cantidad se valida contra `cantidadDisponible`. | | `monto` | solo en modo `monto` | Monto **neto** a acreditar. Se valida contra `saldoAcreditable`. | | `transactionId` | no, pero recomendado | Ver la nota de idempotencia. | **Los tres modos:** | Modo | Para qué | Efecto sobre el saldo | |---|---|---| | `lineas` | Devolución de ítems concretos. | Descuenta de cada línea indicada. | | `monto` | Rebaja comercial, descuento posterior. | Descuenta del saldo del documento, sin imputar a líneas. | | `texto` | Corregir 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](#respuesta-201--el-camino-normal)), 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 | `code` | HTTP | Qué pasó | Qué hacer | |---|---|---|---| | `emisor.acreditacion-no-habilitada` | 404 | El módulo está apagado en este despliegue. | Pedirlo a soporte. **No** es una ruta inexistente. | | `emisor.documento-no-encontrado` | 404 | El `id` no existe o es de otra empresa. | Verificar el `id`. | | `emisor.modo-invalido` | 400 | `modo` no es `lineas`, `monto` ni `texto`. | Corregir. | | `emisor.tipo-no-acreditable` | 400 | El tipo no admite nota de crédito por esta vía. Acreditables: **33, 34, 39, 41, 52, 56**. | No reintentar. | | `emisor.estado-no-acreditable` | 400 | El 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-saldo` | 409 | Ya fue acreditado por completo. | Solo queda el modo `texto`. | | `emisor.documento-ya-anulado` | 409 | El documento fue anulado; no se acredita. | Ninguna acción. | | `emisor.documento-descartado` | 409 | El documento fue descartado por administración. | Escalar a soporte. | | `emisor.acreditacion-excede-saldo` | 400 | El 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-curso` | 409 | Hay otra petición emitiendo con el mismo `transactionId` ahora mismo. | Reintentar en unos segundos. | | `emisor.acreditacion-replay-inconsistente` | 409 | Ese `transactionId` ya acreditó, pero su nota no se pudo recuperar. | **No reintentar.** Escalar a soporte con el `transactionId`. | | `emisor.sin-folios-nc` | 400 / 409 | Sin folios de tipo 61. | Ver [§15.12](#1512-folios). | | `emisor.ambiente-invalido` | 400 | `ambiente` no reconocido. | No mandarlo. | | `dte.permiso-faltante` | 403 | Falta 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](#enlaces-firmados-para-el-comprador)). 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](#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 **sí** 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 - [ ] Certificación SII aprobada **para cada tipo** que vas a emitir (depende de Comges). - [ ] Plan de la empresa vigente y con fecha suficiente (depende de Comges). - [ ] 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](#el-certificado-digital-tiene-que-estar-activo)). - [ ] Todos los tipos habilitados para la empresa en **Producción** (depende de Comges). - [ ] Ambiente del usuario cambiado a Producción (lo hace el administrador de la empresa). - [ ] **Key nueva `pk_live_*` creada** (no rotada) y desplegada en tu configuración. - [ ] Tu código manda `transactionId` en toda emisión. - [ ] Tu código ramifica por el `code` del error ([§15](#15-errores-formato-y-códigos)) y maneja el `202` ([§8](#respuesta-202--la-emisión-quedó-esperando-folio)). - [ ] Tu timeout HTTP es mayor al nuestro ([§17](#timeouts)). - [ ] 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. | Campo | Uso | |---|---| | `code` | **Estable.** Es el campo contra el que hay que programar. | | `title` | Legible para humanos. La redacción puede cambiar entre versiones. | | `detail` | Explicación más larga, cuando el error la tiene. La mayoría la deja en `null`. | | `status` | Igual al status HTTP. | | `field` | Campo 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. | | `traceId` | Identificador 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ón | HTTP | Cuerpo real | Qué 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 `code`** | Leer el mapa `errors`: cada clave es la ruta del campo. | | Error interno | 500 | `{ "type", "title", "status", "traceId" }` — **sin `code`** | Reintentar con backoff y el mismo `transactionId`. Si persiste, soporte con el `traceId`. | | Documento inexistente en `GET /dte/{id}` y `GET /dte/{id}/xml` | 404 | **Cuerpo vacío** | Verificar el `id`. Un `id` de otra empresa también da 404. | | La petición superó los 60 s de la plataforma | 504 | **HTML**, no JSON | Reintentar con backoff y el mismo `transactionId`. Ver [§17](#timeouts). | 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. `sí` = 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](#1517-exportación-exp) lo marca caso por caso. --- ### 15.1 Autenticación, cuota y permisos | `code` | HTTP | Qué significa | ¿Reintentar? | Qué hacer | |---|---|---|---|---| | `api-key.ausente` | 401 | Falta 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. | no | Revisar el header `X-Api-Key` y el secreto. Si la key fue rotada, actualizar el valor guardado. | | `permiso.requerido` | 403 | La 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). | no | Emitir con una key que tenga ese scope. | | `emisor.permiso-tipo-no-autorizado` | 403 | Falta `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. | no | Emitir con una key que tenga `dte:emit:{tipo}`. | | `dte.permiso-faltante` | 403 | Falta `dte:emit:61` en `POST /dte/{id}/nota-credito`. Tercera variante del mismo problema. | no | Emitir con una key que tenga `dte:emit:61`. | | `api-publica.rate-limit` | 429 | Se excedió el límite de peticiones (se cuenta por key y también por IP). | **sí** | Esperar los segundos que indica el header `Retry-After` y reintentar. | | `descarga.token-invalido` | 403 | **Solo 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. | no | Usar la URL tal como vino en `xmlUrl` / `pdfUrl`, sin modificarla. Ver [§10](#enlaces-firmados-para-el-comprador). | | `descarga.token-vencido` | 403 | **Solo en las rutas de descarga firmada.** La firma es válida pero el enlace ya venció. | no | Pedir 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 | `code` | HTTP | Qué significa | ¿Reintentar? | Qué hacer | |---|---|---|---|---| | `plan.vencido` | 403 | El plan de la empresa venció. El cuerpo trae además `detail` y `fechaVencimiento`. | no | Renovar 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-lectura` | 403 | La key pertenece a la empresa de demostración, que es compartida y de solo lectura. Solo bloquea escrituras (`POST`). | no | Usar 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. | `code` | HTTP | Qué significa | ¿Reintentar? | Qué hacer | |---|---|---|---|---| | `emisor.tipo-dte-no-habilitado` | 403 | El tipo de documento no está habilitado para esa empresa. | no | Pedir la habilitación del tipo a soporte. | | `cert.tipo-no-aprobado-prod` | 403 | **Solo 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. | no | Completar la certificación del tipo ante el SII antes de emitirlo en producción (ver [§14](#14-paso-a-producción-go-live)). En Certificación este error no aparece. | | `cert.servicio-no-disponible` | 503 | No 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-sucursal` | 422 | La empresa no tiene casa matriz configurada; sin dirección de origen no se puede armar el documento. | no | Configurar la sucursal en el portal. | | `emisor.empresa-sin-actecos` | 422 | La empresa no tiene actividades económicas activas. | no | Configurar los actecos en el portal. | | `emisor.perfil-empresa-no-disponible` | 503 | No se pudo resolver el perfil de la empresa (razón social, giro, sucursales, actecos). | sí (backoff) | Reintentar. Si persiste, soporte. | | `emisor.sin-certificado` | 422 | La empresa no tiene un certificado digital (PFX) activo. Todo DTE se firma antes de guardarse, así que sin certificado no se puede emitir. | no | Cargar un PFX vigente. | | `emisor.certificado-invalido` | 422 | Hay un PFX cargado pero no se pudo abrir: clave equivocada o certificado vencido. | no | Revisar la vigencia del certificado y su clave. | ### 15.4 Tipo de documento y ambiente Ninguno consume folio. | `code` | HTTP | Qué significa | ¿Reintentar? | Qué hacer | |---|---|---|---|---| | `emisor.tipo-dte-invalido` | 400 | `tipoDte` no es un código conocido del SII. | no | Corregir el valor. | | `emisor.tipo-dte-no-emisible` | 501 | Se 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.** | no | No usar 43 ni 46. Los scopes `dte:emit:43` y `dte:emit:46` existen y se pueden conceder, pero **no habilitan nada**. Ver [§8](#tipos-que-esta-api-no-emite). | | `emisor.tipo-dte-no-implementado` | 501 | Se mandó un 110/111/112 a `POST /dte`. | no | Usar [`POST /api/public/v1/exportacion`](#13-exportación-110--111--112). | | `emisor.ambiente-no-coincide-con-key` | 400 | El cuerpo pide un ambiente distinto al de la key. El ambiente lo fija la key y no se puede cambiar desde el request. | no | Sacar `ambiente` del cuerpo, o usar la key del ambiente que corresponde. | | `emisor.ambiente-invalido` | 400 | `ambiente` no es `Certificacion` ni `Produccion`. | no | Corregir, o mejor, no mandar el campo. | ### 15.5 Validación del cuerpo — receptor Ninguno consume folio. Todos son 400. | `code` | Qué significa | Qué hacer | |---|---|---| | `emisor.rut-receptor-invalido` | El 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-incompleto` | Faltan `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-excedido` | Un campo del receptor supera el largo máximo del SII. El `field` dice cuál. Ver los largos en [§8](#campos-del-cuerpo). | Acortar. **Estos campos no se truncan solos:** un giro comercial chileno normal pasa fácil de 40 caracteres. | | `emisor.receptor-correo-invalido` | Alguno de los `correos` de copia no tiene forma de correo. | Corregir o quitar. | | `emisor.receptor-correos-largo-excedido` | La 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](#1519-códigos-que-no-existen). ### 15.6 Validación del cuerpo — detalle Ninguno consume folio. Todos son 400. | `code` | Qué significa | Qué hacer | |---|---|---| | `emisor.detalles-vacios` | `detalles` vacío. | Mandar al menos una línea. | | `emisor.detalles-excede-60` | Más de 60 líneas (límite duro del SII). | Partir el documento. | | `emisor.detalle-indexe-invalido` | `indExe` fuera de los valores soportados (`0`, `1`, `2`, `6`). | Corregir. | | `emisor.detalle-monto-invalido` | Cantidad, precio, descuento o recargo de una línea fuera de rango o incoherentes. | Corregir la línea que indica `field`. | | `emisor.detalle-largo-excedido` | Un campo de una línea supera el largo del SII. Ver los largos en [§8](#campos-del-cuerpo). | 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. | `code` | Qué significa | Qué hacer | |---|---|---| | `emisor.referencias-excede-40` | Más de 40 referencias. | Reducir. | | `emisor.referencia-requerida` | Una 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`](#11-anular-un-documento): la referencia la arma el servidor.) | | `emisor.referencia-codref-invalido` | `codRef` fuera de `1` (anula), `2` (corrige texto) o `3` (corrige montos). | Corregir. | | `emisor.referencia-fecha-fuera-de-rango` | Algú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-excedido` | Un campo de una referencia supera su largo. Límites: `tipoDocRef` 3, `folioRef` 18, `razonRef` 90. | Acortar. | | `emisor.impuestos-excede-20` | Más de 20 impuestos adicionales. | Reducir. | | `emisor.impuesto-codigo-invalido` | `codigoImpuesto` no existe en la tabla del SII (rango 14..53, más el 271). | Corregir. | | `emisor.impuesto-monto-negativo` | Un impuesto adicional con monto negativo: bajaría el total del documento. | Corregir. | | `emisor.descuentos-globales-excede-20` | Más de 20 descuentos o recargos globales. | Reducir. | | `emisor.dr-tipo-movimiento-invalido` | `tipoMovimiento` no es `"D"` (descuento) ni `"R"` (recargo). | Corregir. | | `emisor.dr-tipo-valor-invalido` | `tipoValor` no es `"%"` ni `"$"`. | Corregir. | | `emisor.dr-valor-negativo` | Valor de descuento o recargo negativo. | Corregir. | | `emisor.dr-glosa-excede-45` | Glosa de más de 45 caracteres. | Acortar. | ### 15.8 Validación del cuerpo — fechas y forma de pago Ninguno consume folio. Todos son 400. | `code` | Qué significa | Qué hacer | |---|---|---| | `emisor.fecha-emision-invalida` | Formato 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-futura` | Fecha posterior a hoy en Chile. El SII las rechaza. | Corregir u omitir el campo. | | `emisor.fecha-emision-anterior-minima` | Fecha anterior a la fecha mínima que acepta el SII. | Corregir. | | `emisor.fecha-vencimiento-invalida` | Formato de `fechaVencimiento` no reconocido. | Corregir. | | `emisor.fecha-vencimiento-anterior` | El vencimiento es anterior a la emisión. | Corregir. | | `emisor.forma-pago-invalida` | `formaPago` 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. | `code` | HTTP | Qué significa | Qué hacer | |---|---|---|---| | `emisor.sucursal-no-encontrada` | 400 | `empresaSucursalId` no existe en esa empresa. | Corregir u omitir (se usa la casa matriz). | | `emisor.actecos-excede-4` | 400 | Más de 4 actividades económicas (límite del SII). | Reducir u omitir. | | `emisor.acteco-no-pertenece` | 400 | Uno de los actecos declarados no pertenece a esa empresa. | Corregir u omitir. | | `emisor.acteco-principal-fuera-de-lista` | 400 | El acteco principal no está entre los declarados. | Corregir. | | `custom-field-obligatorio` | 400 | Falta un campo propio marcado como obligatorio para esa empresa. | Completar `customFields`. | | `custom-field-desconocido` | 400 | `customFields` trae una clave que no está definida para esa empresa. | Quitarla. | | `custom-field-tipo-invalido` | 400 | El valor de un campo propio no corresponde a su tipo. | Corregir el valor. | | `custom-field-payload-grande` | 400 | El 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. | `code` | Qué significa | Qué hacer | |---|---|---| | `emisor.ind-traslado-requerido` | Falta `indTraslado`, que es obligatorio en la guía. | Mandarlo (1..7). | | `emisor.ind-traslado-invalido` | `indTraslado` fuera del rango válido. | Corregir. | | `emisor.ind-traslado-no-aplica` | Se mandó `indTraslado` en un documento que no es una guía. | Quitarlo. | | `emisor.ind-traslado-exportacion-no-soportado` | Se pidió `indTraslado` 8 o 9 (traslado para exportación). Esta API no los soporta. | No usar 8 ni 9. | | `emisor.tipo-despacho-invalido` | `tipoDespacho` fuera de 1/2/3. | Corregir. | | `emisor.tipo-despacho-no-aplica` | Se mandó `tipoDespacho` en un documento que no es una guía. | Quitarlo. | | `emisor.transporte-chofer-incompleto` | Se mandó `rutChofer` sin `nombreChofer`, o al revés. Van los dos o ninguno. | Completar el par. | | `emisor.transporte-rut-invalido` | `rutTransportista` o `rutChofer` no pasa el dígito verificador. | Corregir. | | `emisor.transporte-largo-excedido` | Un 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. | `code` | HTTP | Qué significa | ¿Reintentar? | Qué hacer | |---|---|---|---|---| | `emisor.cotizacion-no-verificable` | 503 | No 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-encontrada` | 404 | La cotización no existe o es de otra empresa. | no | Corregir el id. | | `emisor.cotizacion-no-emitible` | 409 | La cotización no está abierta ni ganada, o está vencida. | no | Revisar 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. | `code` | HTTP | Qué significa | ¿Reintentar? | Qué hacer | |---|---|---|---|---| | `emisor.sin-folios` | 409 | No quedan folios de ese tipo en ese ambiente y tampoco se pudo pedir más. | no | Cargar un CAF nuevo para ese tipo. | | `caf.sin-autorizacion` | 422 | El SII **no autoriza** timbraje de ese tipo de documento para esa empresa. Es terminal: reintentar no lo va a cambiar. | no | Requiere gestión ante el SII. | | `emisor.sin-folios-nc` | 400 | No hay folios de **tipo 61** para emitir la nota de crédito (anulación o nota parcial). | no | Cargar un CAF de tipo 61. | | `emisor.sin-folios-nc` | 409 | Variante: 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-configurado` | 503 | Problema 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`](#respuesta-202--la-emisión-quedó-esperando-folio)): 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 | `code` | HTTP | Qué significa | ¿Reintentar? | Qué hacer | |---|---|---|---|---| | *(sin cuerpo)* | 404 | El documento no existe, **o es de otra empresa**. `GET /dte/{id}` y `GET /dte/{id}/xml` devuelven 404 vacío. | no | Verificar el `id`. | | `dte.cross-tenant-no-autorizado` | 403 | Se mandó `?empresaId=` en la query. Una API key nunca puede consultar otra empresa. | no | Quitar el parámetro: la empresa sale de la key. | | `dte.ambiente-no-autorizado` | 401 | No 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). | no | Si aparece, es un problema de la credencial — soporte. | | `emisor.ticket-no-encontrado` | 404 | El `ticketId` no existe, o es de otra empresa (se devuelve 404 y no 403 para no revelar su existencia). | no | Verificar el `ticketId` que devolvió el [202](#respuesta-202--la-emisión-quedó-esperando-folio). | | `emisor.pendientes-no-disponible` | 503 | La 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}`](#resolver-el-ticket), junto con `mensaje` y `esErrorTerminal`. La respuesta del endpoint es 200. | `codigoError` | `esErrorTerminal` | Qué significa | Qué hacer | |---|---|---|---| | `caf.sin-folios` | `false` | Todavía no hay folios; el SII aún no entregó. El ticket sigue en cola. | Seguir consultando. | | `caf.sin-autorizacion` | `true` | El 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-ilegible` | `true` | No se pudo recuperar el contenido de la emisión encolada. | Volver a emitir. Soporte si se repite. | | `emisor.error-inesperado` | `false` | Error inesperado al emitir; se reintenta solo. | Seguir consultando. | | `emisor.error` | `true` | Comodín cuando el error de emisión no trae código propio. | Leer `mensaje`. | | *cualquier código de las tablas anteriores* | `true` | El 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` | `code` | HTTP | Qué significa | ¿Folio? | ¿Reintentar? | Qué hacer | |---|---|---|---|---|---| | `emisor.documento-no-encontrado` | 404 | El `id` no existe o es de otra empresa. | no | no | Verificar el `id`. | | `emisor.documento-ya-anulado` | 409 | El documento ya tiene una anulación previa **que no pasó por este endpoint**. | no | no | Ninguna 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-anulable` | 400 | Ese tipo no se anula con nota de crédito (por ejemplo, una NC 61, o un documento de exportación). | no | no | Para exportación, emitir una NC 112. | | `emisor.estado-no-anulable` | 400 | El documento está `Pendiente`, `Rechazado` o `ErrorEnvio`. Solo se anula lo que ya llegó al SII (`Enviado`, `Aceptado`, `AceptadoConReparos`). | no | no | Si está `Pendiente`, esperar a que llegue al SII y reintentar. | | `emisor.montos-no-reproducibles` | 409 | Alguna 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. | no | no | Probá [`POST /{id}/nota-credito`](#12-devolución-parcial-notas-de-crédito-por-línea-o-por-monto) 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-descartada` | 409 | La nota de crédito que anulaba este documento fue descartada por administración. | no | no | No se re-emite automáticamente. Escalar a soporte. | | `emisor.anulacion-marca-fallida` | 500 | La nota de crédito **se emitió**, pero no se pudo marcar el original como anulado. | **sí, ya consumido** | **sí** | Reintentar 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. | `code` | HTTP | Qué significa | ¿Reintentar? | Qué hacer | |---|---|---|---|---| | `emisor.acreditacion-no-habilitada` | 404 | La funcionalidad de notas de crédito parciales no está habilitada. **Un 404 acá no significa "ruta inexistente".** | no | Pedir la habilitación a soporte. | | `emisor.documento-no-encontrado` | 404 | El `id` no existe o es de otra empresa. | no | Verificar el `id`. | | `emisor.documento-descartado` | 409 | El documento fue descartado por administración. | no | Escalar a soporte. | | `emisor.documento-ya-anulado` | 409 | El documento ya está anulado por completo: no queda nada que acreditar. | no | Ninguna. | | `emisor.acreditacion-sin-saldo` | 409 | El documento ya fue acreditado por completo. | no | Consultar `GET /{id}/acreditable` para ver el saldo. | | `emisor.tipo-no-acreditable` | 400 | Ese tipo de documento no admite nota de crédito parcial. | no | — | | `emisor.estado-no-acreditable` | 400 | El documento está en un estado que no admite nota de crédito. | no | Esperar a que llegue al SII. | | `emisor.modo-invalido` | 400 | `modo` no es `lineas`, `monto` ni `texto`. | no | Corregir. | | `emisor.modo-no-disponible` | 400 | Ese modo no aplica a ese documento. | no | Consultar `modosDisponibles` en [`GET /{id}/acreditable`](#121-consultar-el-saldo-acreditable). | | `emisor.lineas-requeridas` | 400 | `modo: "lineas"` sin el arreglo `lineas`. | no | Mandar las líneas. | | `emisor.linea-inexistente` | 400 | Una `nroLinea` no existe en el documento original. | no | Corregir. | | `emisor.linea-no-facturable` | 400 | Se intentó acreditar una línea que no es facturable. | no | Quitarla. | | `emisor.cantidad-invalida` | 400 | Cantidad a acreditar fuera de rango para esa línea. | no | Corregir. | | `emisor.monto-requerido` | 400 | `modo: "monto"` sin el campo `monto`. | no | Mandar el monto. | | `emisor.acreditacion-excede-saldo` | 400 | Lo pedido supera el saldo disponible (del documento o de una línea). El `field` señala la línea culpable. **No se reservó folio.** | no | Consultar `GET /{id}/acreditable` antes de emitir. | | `emisor.acreditacion-en-curso` | 409 | Hay otra petición emitiendo **con el mismo `transactionId`** en este momento. | sí (backoff) | Reintentar en unos segundos con el **mismo** `transactionId`. | | `emisor.acreditacion-replay-inconsistente` | 409 | Ese `transactionId` **ya acreditó**, pero su nota de crédito no se pudo recuperar. | no | **No 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. | `code` | HTTP | Qué significa | ¿Folio? | ¿Reintentar? | Qué hacer | |---|---|---|---|---|---| | `exp.tipo-dte-invalido` | 400 | `tipoDte` no es 110, 111 ni 112. | no | no | Corregir. | | `exp.tipo-dte-no-habilitado` | 403 | Ese tipo de exportación no está habilitado para la empresa en ese ambiente. | no | no | Pedir la habilitación a soporte. | | `exp.ambiente-no-coincide-con-key` | 400 | El cuerpo pide un ambiente distinto al de la key. | no | no | Sacar `ambiente` del cuerpo. | | `exp.ambiente-invalido` | 400 | `ambiente` no es `Certificacion` ni `Produccion`. | no | no | Corregir. | | `exp.detalles-vacios` | 400 | Sin líneas de detalle. | no | no | Mandar al menos una. | | `exp.detalles-excede-60` | 400 | Más de 60 líneas. | no | no | Partir el documento. | | `exp.referencias-excede-40` | 400 | Más de 40 referencias. | no | no | Reducir. | | `exp.bultos-excede-10` | 400 | Más de 10 bultos. | no | no | Reducir. | | `exp.comisiones-excede-20` | 400 | Más de 20 comisiones. | no | no | Reducir. | | `exp.dr-excede-20` | 400 | Más de 20 descuentos o recargos globales. | no | no | Reducir. | | `exp.nd-nc-sin-referencia` | 400 | Una nota de débito (111) o de crédito (112) de exportación sin referencia al documento original. | no | no | Agregar la referencia. | | `exp.referencia-codref-requerido` | 400 | Falta `codRef` en una referencia. | no | no | Agregarlo. | | `exp.referencia-codref-invalido` | 400 | `codRef` fuera de los valores válidos. | no | no | Corregir. | | `exp.receptor-ciudad-requerida` | 400 | Falta la ciudad del receptor extranjero. | no | no | Completar. | | `exp.receptor-direccion-requerida` | 400 | Falta la dirección del receptor extranjero. | no | no | Completar. | | `exp.moneda-invalida` | 400 | La moneda no pertenece al catálogo del SII. | no | no | Usar un literal del SII (`DOLAR USA`, `EURO`, `PESO CL`…). Si no está en el listado, `OTRAS MONEDAS`. | | `exp.fecha-emision-invalida` | 400 | Formato de fecha no reconocido. | no | no | Corregir. | | `exp.fecha-emision-futura` | 400 | Fecha posterior a hoy en Chile. | no | no | Corregir. | | `exportacion.*` | 400 | **Familia 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. | no | no | Corregir el campo que indica `field`. | | `exp.contrato-invalido` | 400 | Comodín cuando una regla del formato no trae código propio. | no | no | Leer `title` y `field`. | | `exp.empresa-incompleta` | 422 | Faltan datos maestros de la empresa exigidos para exportación (razón social, giro, dirección, comuna, sucursal o actecos). | no | no | Completar en el portal. | | `exp.perfil-empresa-no-disponible` | 503 | No se pudo resolver el perfil de la empresa. | no | sí (backoff) | Reintentar. | | `exp.caf-interno-no-configurado` | 503 | La reserva de folios no está disponible. | no | sí (backoff) | Soporte. | | `exp.sin-folios` | 409 | No quedan folios de ese tipo de exportación. | no | no | Cargar un CAF. | | `exp.sin-certificado` | 409 | La empresa no tiene certificado digital activo. **Se detecta después de tomar el folio.** | **sí, ya consumido** | no | Cargar el PFX antes de volver a emitir. | | `exp.error-pfx` | 500 | El certificado digital no se pudo abrir (vencido o ilegible). **Después de tomar el folio.** | **sí** (se intenta liberar) | no | Revisar el certificado. | | `exp.error-xml` | 500 | Falló la construcción o la firma del XML. | **sí** (se intenta liberar) | sí (backoff) | Reintentar con el mismo `transactionId`. Si persiste, soporte. | | `exp.error-persistencia` | 500 | Falló el guardado del documento. | **sí** (se intenta liberar) | sí (backoff) | Reintentar con el mismo `transactionId`. | | `exp.not-found` | 404 | La exportación no existe o es de otra empresa. | — | no | Verificar el `id`. | | `exp.xml-no-disponible` | 404 | El documento existe pero todavía no tiene XML firmado. | — | sí (backoff) | Reintentar en unos segundos. | | `exp.error-descarga-xml` | 500 | No se pudo recuperar el XML almacenado. | — | sí (backoff) | Soporte con el `traceId`. | | `exp.cross-tenant` | 403 | Se mandó `?empresaId=` en la query. | — | no | Quitar el parámetro. | | `exp.ambiente-no-autorizado` | 401 | No se pudo resolver el ambiente. | — | no | No debería ocurrir con una API key vigente. Soporte. | | `exp.sin-contexto` | 401 | No 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: | Prefijo | Cubre | |---|---| | `exportacion.aduana.*` y `exportacion.aduana-requerida` | Bloque 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-requerido` | Receptor extranjero: nombre, giro, dirección, ciudad, nacionalidad, número de identificación, correo. | | `exportacion.referencia.*`, `exportacion.referencias-maximo`, `exportacion.referencia-110-faltante*`, `exportacion.dus-duplicado` | Referencias: tipo, folio, fecha, razón y código de referencia; DUS obligatorio y sin duplicar. | | `exportacion.moneda.*`, `exportacion.moneda-requerida` | Moneda y tasa de cambio. | | `exportacion.bulto.*`, `exportacion.bultos-maximo` | Bultos: código, cantidad, marcas, sellos, contenedor. | | `exportacion.comision.*`, `exportacion.comisiones-maximo` | Comisiones y otros cargos. | | `exportacion.dr-global.*`, `exportacion.dr-global-maximo` | Descuentos 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ón | **No.** El request está mal; reintentar da lo mismo. | | 401 y 403 | **No.** Es un problema de credencial, de scope o de configuración de la empresa. | | 404 | **No.** | | 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 | **Sí**, con backoff. | | 422 `caf.sin-autorizacion` | **No.** Requiere gestión ante el SII. | | 403 `descarga.token-invalido` / `descarga.token-vencido` | **No.** El enlace no se arregla reintentando: hay que generar uno nuevo con `GET /dte/{id}`. | | 429 | **Sí**, respetando `Retry-After`. Ver [§17](#rate-limit). | | 500, 503, 504 y timeouts de red | **Sí**, con backoff exponencial y **siempre con el mismo `transactionId`**. | | 202 con `ticketId` | **No 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 existe** | Qué devuelve la API en su lugar | |---|---| | `api-key.scope-insuficiente` | `permiso.requerido` (y sus dos variantes de §15.1). | | `api-key.invalida` | Todo 401 llega como `api-key.ausente`, sin distinguir el motivo. | | `caf.agotado` | `emisor.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-invalido` | `emisor.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](#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`](#respuesta-202--la-emisión-quedó-esperando-folio), 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](#11-anular-un-documento). ### 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](#123-idempotencia-acá-transactionid-sí-importa). --- ## 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**: | Capa | Límite | Partición | |---|---|---| | Por credencial | **120 requests / 60 s** | Prefijo de la API Key presentada | | Global por IP | **300 requests / 60 s** | IP 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](#enlaces-firmados-para-el-comprador) 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 detalle | 60 | | Referencias | 40 | | Impuestos adicionales | 20 | | Descuentos/recargos globales | 20 | | Actecos declarados | 4 | | 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: | Campo | Largo | Código de error | |---|---|---| | `receptor.razonSocial` | 100 | `emisor.receptor-largo-excedido` | | `receptor.giro` | **40** | `emisor.receptor-largo-excedido` | | `receptor.direccion` | 70 | `emisor.receptor-largo-excedido` | | `receptor.comuna` | **20** | `emisor.receptor-largo-excedido` | | `receptor.ciudad` | 20 | `emisor.receptor-largo-excedido` | | `receptor.contacto` | 80 | `emisor.receptor-largo-excedido` | | `receptor.rutSolicita` | 20 | `emisor.receptor-largo-excedido` | | `detalles[].nombreItem` | 80 | `emisor.detalle-largo-excedido` | | `detalles[].descripcionItem` | 1000 | `emisor.detalle-largo-excedido` | | `detalles[].codigoItem` | 35 | `emisor.detalle-largo-excedido` | | `detalles[].tipoCodigo` | 10 | `emisor.detalle-largo-excedido` | | **`detalles[].unidadMedida`** | **4** | `emisor.detalle-largo-excedido` | | `referencias[].tipoDocRef` | 3 | `emisor.referencia-largo-excedido` | | `referencias[].folioRef` | 18 | `emisor.referencia-largo-excedido` | | `referencias[].razonRef` | 90 | `emisor.referencia-largo-excedido` | | `descuentosGlobales[].glosa` | 45 | `emisor.dr-glosa-excede-45` | | `motivo` de anulación / nota de crédito | 90 | *(se **trunca**, no falla)* | | `observaciones` | 500 | *(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 | Camino | Timeout del servidor | |---|---| | Emisión, consulta, XML, anulación, nota de crédito | 45 s | | PDF | 60 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}`](#9-consultar-el-estado-modelo-asíncrono) 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}`](#9-consultar-el-estado-modelo-asíncrono). 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. | `tipo` | Cuándo se dispara | Qué trae en `datos` | |---|---|---| | `dte.emitido` | El documento existe: folio asignado, XML firmado y persistido. Se publica **antes** de ir al SII. | `documentoId`, `tipoDte`, `folio`, `montoTotal`, `fechaEmision`, `rutReceptor` y `razonSocialReceptor`. `estadoSii` llega `0` (`Pendiente`) y `trackId` en `null`: todavía no hay sobre. En una NC/ND llega además la referencia (`codRef` + `documentoReferenciado`). | | `dte.anulado` | Se anuló un documento con una nota de crédito 61 (`codRef` `1`) y el original quedó marcado como anulado. | **El documento ORIGINAL**, con `estadoSii` `5` (`Anulado`). `codRef` y `documentoReferenciado` llegan en `null` —ver el aviso de abajo— y el folio de la nota de crédito va en `detalle`, legible. | > ⚠️ **`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](#9-consultar-el-estado-modelo-asíncrono): en vez de repetir la consulta, esperás el aviso. | `tipo` | Cuándo se dispara | ¿Terminal? | Qué trae en `datos` | |---|---|---|---| | `dte.enviado` | El sobre se subió al SII y el SII devolvió `trackId`. A partir de acá conviene esperar el estado final en vez de pollear. | No | `trackId` ya con valor y `estadoSii` `1` (`Enviado`). | | `dte.aceptado` | El SII aceptó el documento. | Sí | `estadoSii` `2` y `detalle` con lo que respondió el SII. | | `dte.aceptado_con_reparos` | El SII lo aceptó pero observó algo. **Es válido tributariamente**: el documento existe y tiene folio. Va aparte de `dte.aceptado` para que puedas revisarlo sin mirar el detalle de cada aceptado. | Sí | `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.rechazado` | El SII rechazó el documento. | Sí | `estadoSii` `3` y `detalle` con el motivo del rechazo. | | `dte.error_envio` | El 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. | No | `estadoSii` 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](#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. | `tipo` | Cuándo se dispara | Qué trae en `datos` | |---|---|---| | `recepcion.documento_recibido` | Llegó un DTE de un proveedor y quedó persistido, después del dedupe. | `tipoDte`, `folio`, `montoTotal` y `fechaEmision` del documento del proveedor. `estadoSii` y `trackId` en `null`: el ciclo ante el SII es del emisor, no tuyo. | | `recepcion.documento_aceptado` | Se envió el acuse comercial de aceptación del documento del proveedor. | El documento del proveedor que se aceptó. | | `recepcion.documento_rechazado` | Se envió el acuse comercial de rechazo. | El documento del proveedor, con el motivo en `detalle`. | | `recepcion.documento_reclamado` | Se reclamó el documento dentro de los 8 días hábiles. **Todavía no se publica** — ver el aviso de [Eventos declarados que aún no se publican](#eventos-declarados-que-aún-no-se-publican). | — | | `recepcion.acuse_tacito` | Venció el plazo legal sin acuse y quedó registrada la aceptación tácita. Lo dispara un proceso programado, no una acción tuya. | El documento del proveedor que quedó aceptado por vencimiento. | | `recepcion.recibo_mercaderia` | Se registró el recibo de mercaderías de la Ley 19.983. | El documento del proveedor sobre el que se firmó el recibo. | > ⚠️ **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 **vos** > —`datos.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`. | `tipo` | Cuándo se dispara | Qué trae en `datos` | |---|---|---| | `caf.folios_bajos` | Quedan pocos folios disponibles para un tipo de DTE. El umbral es configurable, y un barrido diario lo evalúa. | `tipoDte` del CAF y `extra` con `foliosDisponibles`, `tipoDte` y `umbral`. `folio`, `trackId` y `estadoSii` en `null`. | | `caf.por_vencer` | Un CAF vence pronto: hay que reobtener folios antes de quedarse sin timbrar. El aviso es **por CAF**, no por tipo. | `tipoDte` y `extra` con `folioDesde`, `folioHasta`, `fechaVencimiento` (`AAAA-MM-DD`), `diasRestantes` y `cafId`. | | `certificado.por_vencer` | El certificado digital de la empresa vence pronto. **Todavía no se publica** — ver 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 | `tipo` | Cuándo se dispara | Qué trae en `datos` | |---|---|---| | `webhook.prueba` | Lo 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. | `tipo` | Estado real | |---|---| | `certificado.por_vencer` | **Sin 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_reclamado` | **Sin 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": {} } } ``` | Campo | Qué es | |---|---| | `id` | Identificador del **evento**. Es estable entre reintentos: es la clave por la que tenés que deduplicar. Viaja también en `X-Comges-Event-Id`. | | `tipo` | Uno de los 17 tipos del catálogo. Viaja también en `X-Comges-Event-Type`, así que podés rutear sin parsear el cuerpo. | | `version` | Versión del formato del cuerpo. Hoy siempre `1`. | | `ocurridoEn` | Momento real del hecho, en UTC (ISO 8601). No es el momento del intento de entrega. | | `ambiente` | `Certificacion` o `Produccion`. Una suscripción vive en un solo ambiente. | | `empresaRut` | RUT de **tu** empresa, la dueña de la suscripción. En los eventos `recepcion.*` sigue siendo el tuyo, no el del proveedor. | | `datos.documentoId` | En emisión y ciclo SII, el mismo `id` que devolvió la emisión y que usás en `GET /dte/{id}`. En `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.tipoDte` | Código SII del tipo, **como número** (`33`, `34`, `39`, `41`, `52`, `56`, `61`, `110`, `111`, `112`). | | `datos.folio` | Folio del documento. | | `datos.trackId` | Identificador del sobre en el SII. `null` mientras no haya sobre subido. | | `datos.estadoSii` | Código numérico del estado (ver tabla). | | `datos.estadoSiiNombre` | El mismo estado en texto. | | `datos.detalle` | Texto legible: lo que respondió el SII, el motivo del error, o la descripción del aviso operativo. Puede venir `null`. | | `datos.codRef` | Solo cuando el DTE del evento referencia a otro (NC y ND): `1` anula el documento completo, `2` corrige texto, `3` corrige monto (devolución parcial). `null` en el resto. | | `datos.codRefNombre` | El mismo `codRef` en texto: `Anula documento`, `Corrige texto`, `Corrige monto`. | | `datos.documentoReferenciado` | El documento al que apunta el DTE del evento: `{ "tipoDte": "33", "folio": "1234" }`. `null` cuando no referencia a nadie. **Ojo con los tipos** — ver el aviso de abajo. | | `datos.rutEmisor` | Quién emitió el documento. En emisión y ciclo SII sos vos; en `recepcion.*` es el proveedor. | | `datos.rutReceptor` | Quién lo recibe. En emisión es tu cliente; en `recepcion.*` sos vos. | | `datos.razonSocialReceptor` | Razón social del receptor, para no tener que ir a buscarla. | | `datos.montoTotal` | Monto total del documento, en la moneda del documento, como número. | | `datos.fechaEmision` | Fecha de emisión, `AAAA-MM-DD`. Es una fecha de negocio, sin hora ni zona: no la parsees como instante o vas a mostrar el día anterior. | | `datos.extra` | Diccionario abierto con lo propio de cada evento que no habla de un documento (`caf.*`, `certificado.*`, `webhook.prueba`). Llega `{}` cuando no hay nada. 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ódigo | Nombre | |---|---| | `0` | `Pendiente` | | `1` | `Enviado` | | `2` | `Aceptado` | | `3` | `Rechazado` | | `4` | `AceptadoConReparos` | | `5` | `Anulado` | | `6` | `ErrorEnvio` | > **`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 | Header | Contenido | |---|---| | `X-Comges-Signature` | `t=,v1=` — ver abajo. | | `X-Comges-Timestamp` | Segundos Unix UTC. Es el mismo valor que el `t=` de la firma, y entra en el material firmado. | | `X-Comges-Event-Id` | Id del evento. **Estable entre reintentos** → deduplicá por acá. | | `X-Comges-Event-Type` | El `tipo` del evento, para rutear sin parsear el cuerpo. | | `X-Comges-Delivery-Id` | Id de la entrega. Cambia por suscripción, no por intento. | | `X-Comges-Delivery-Attempt` | Número de intento, `1` en la primera entrega. | A esos se suman los [headers extra](#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. | Dato | Valor | |---|---| | Secreto | `whsec_documentacion_vector_de_prueba` | | `X-Comges-Timestamp` | `1774704312` | | Cuerpo | 568 bytes UTF-8, **una sola línea, sin salto final** (es el bloque de abajo) | | Material firmado | `1774704312.` + el cuerpo, concatenados sin nada en medio | | `v1` esperado | `66188afa87cae22d8c7e2f642da1df88a459ade615cbf6c8e7303b2ab27165fd` | | Header completo | `X-Comges-Signature: t=1774704312,v1=66188afa87cae22d8c7e2f642da1df88a459ade615cbf6c8e7303b2ab27165fd` | El cuerpo es el mismo ejemplo `dte.aceptado` de más arriba, pero **tal como viaja**: compacto, sin saltos de línea y sin espacios entre claves. 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 respuesta | Qué hacemos | |---|---| | `2xx` | Entrega exitosa. No hay más intentos. | | `408` o `429` | Reintentamos según la escalera. Son las dos únicas excepciones dentro de los `4xx`. | | Cualquier otro `4xx` | **Agotamos la entrega de inmediato.** Un `404` o un `401` no se arregla repitiendo el mismo `POST`; no tiene sentido castigarte 31 horas. | | `5xx`, timeout o error de red | Reintentamos según la escalera. | Respondé rápido y procesá en segundo plano: **el `POST` tiene un timeout de 10 segundos**. 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. | Intento | Espera desde el anterior | Acumulado | |---|---|---| | 1 | Inmediato | — | | 2 | 1 minuto | 1 min | | 3 | 5 minutos | 6 min | | 4 | 15 minutos | 21 min | | 5 | 1 hora | 1 h 21 min | | 6 | 6 horas | 7 h 21 min | | 7 | 24 horas | ≈ 31 horas | 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](#9-consultar-el-estado-modelo-asíncrono). ### 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](#headers-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](#respuesta-202--la-emisión-quedó-esperando-folio)). 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 <&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](#4-ambientes-certificación-y-producción)). --- ## Qué no existe todavía Esta sección existe para que nadie escriba código contra una promesa. | Función | Estado 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 **sí** es autoservicio, pero **desde el portal** (`/portal/webhooks`, permiso `webhook:configurar`): ver [§18](#18-webhooks). 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_reclamado`** | Está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](#eventos-declarados-que-aún-no-se-publican). | | **Emisión de los tipos 43 y 46** | `POST /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 encolado** | No hay ruta pública. Un ticket se resuelve solo o queda en error. | | **Emisión en lote** | No existe en la superficie pública. Un documento por request. | | **Emisión por plantilla** | Existe 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 demanda** | El 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 permanentes** | No existen, y no van a existir. Lo que sí hay son **enlaces firmados con vencimiento** (`xmlUrl` / `pdfUrl`, [§10](#enlaces-firmados-para-el-comprador)): 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 emitido** | No 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 emitido** | No 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 API** | No existe. La ejecuta soporte. Ver [§1](#1-antes-de-empezar-dar-de-alta-la-empresa). | | **Consultar o disparar la certificación SII por API** | No existe. Ver [§14](#14-paso-a-producción-go-live). | | **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 /anular`** | No aplica: se anula emitiendo una NC 112. No existe `POST /exportacion/{id}/anular`. | | **PDF de exportación en 80 mm** | No existe. Exportación se renderiza solo en Carta. | | **Ventana de gracia en la rotación de keys** | No existe. Rotar invalida el secreto anterior de inmediato. | | **Convertir una key de certificación en una de producción** | No existe. Hay que crear una key nueva. Ver [§14](#14-paso-a-producción-go-live). | --- ## Soporte Ante un error `5xx`, un `504` sin cuerpo JSON o un comportamiento que no calce con este documento, escribí a [soporte@comges.cl](mailto:soporte@comges.cl) 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**.