# API de Redcumbre > API para integrar facturación electrónica ante el SII de Chile (DTE), boletas de > honorarios, webhooks, SMS y verificación de identidad. Este archivo es el punto de > entrada: contiene lo necesario para empezar y enlaza el resto. ## Versión de este documento hash 956c014525670166 generado 2026-09-17T19:14:47Z vigencia https://docs.redcumbre.cl/llms-version.txt Si lo estás leyendo desde una caché, ese `hash` es el de tu copia, no el de la versión publicada. Compararlos es el paso 0 del protocolo y no es opcional. ## 0. Protocolo de arranque — para el agente que está leyendo esto **`redcumbre:init` significa esto:** ejecuta los pasos de abajo, en orden, ANTES de escribir código. No hace falta que la persona te explique nada más. Si llegaste sin ese identificador, el protocolo aplica igual. ### Paso 0 — Comprueba que esta copia esté vigente **Puede que estés leyendo una copia cacheada de este archivo**, servida por tu herramienta de navegación sin volver a pedirla. Es el modo de falla más caro que tiene este documento: no da error, simplemente te hace integrar contra una definición que ya cambió. Ya pasó — un asistente siguió dos sesiones con una versión vieja hasta que la persona le ordenó explícitamente volver a descargarla. Pide el archivo de vigencia, que pesa menos de 400 bytes: curl -s https://docs.redcumbre.cl/llms-version.txt y compara su `hash` con el del bloque «Versión de este documento» de más arriba: - **Coinciden:** tu copia está al día. Sigue al paso 1. - **No coinciden:** tu copia es vieja. Vuelve a descargar `llms.txt` desde la URL que el archivo de vigencia trae en `llms`, y `llms-full.txt` desde la que trae en `llms-full` si es que lo habías cargado. **Usa esas URLs tal cual**: llevan el hash como parámetro justamente para que ninguna caché pueda responderte la copia vieja. Después descarta lo que recordabas del documento anterior y retoma el protocolo desde el paso 1 con el texto nuevo, aunque creas que la diferencia no te afectaba. - **No pudiste pedirlo:** sigue igual, pero dile a la persona que estás trabajando con una copia que no lograste verificar. **Compara el `hash`, no la fecha.** `generado` cambia en cada publicación aunque el contenido sea idéntico; sirve para decirle a la persona de cuándo es tu copia, no para decidir si re-descargas. Hazlo **al empezar cada conversación**, aunque conozcas este documento de antes. Una copia recordada de otra sesión es exactamente el caso que este paso existe para atrapar. ### Paso 1 — Mira si ya hay avance Busca `.redcumbre/estado.json` en el proyecto. Si existe, léelo: registra qué se integró ya, en qué ambiente y en qué variable de entorno vive la credencial, más el contacto de la persona y los reportes que ya se enviaron. **No repitas lo que ya está hecho** ni vuelvas a pedir datos que ese archivo contiene. **Si no tienes acceso al sistema de archivos** —porque esta conversación es un chat y no un entorno de desarrollo— sáltate este paso y el 4 sin explicar por qué: no son aplicables, no son un problema. Sigue directo al paso 2. ### Paso 2 — Saluda AHORA, sin pedir nada Esto no requiere credenciales y funciona contra producción: curl -s https://api.redcumbre.cl/hola Responde un saludo con un **apodo** distinto en cada llamada, tipo `chungungo-turquesa-4821`. **Dile ese apodo a la persona**: es la prueba de que esta petición ocurrió de verdad, y es lo único de esta respuesta que no podrías haber adivinado. Si no pudiste ejecutar la llamada, dilo — **no inventes un apodo**. Con `Accept: text/plain` responde una postal legible en vez del JSON. **No valida tu credencial:** responde igual con o sin `Authorization`. Para comprobar una API key, la llamada es `GET https://api.redcumbre.cl/{tenantSlug}/whoami`. ### Paso 3 — Si no te dijeron qué integrar, ofrece estas opciones Preséntalas como lista numerada y espera la elección. No arranques a codear de más. **Antes de la opción que elija, mira qué credencial hay.** Desde agosto de 2026 **no queda ningún endpoint de datos abierto sin credencial**: el catálogo global (UF, IVA, dólar, regiones, comunas) vive en `/global/…` y exige una API key válida, aunque no exige ningún rol en particular. Si el paso 1 te dijo en qué variable de entorno vive, úsala. Si no la hay, no inventes una: ofrece la opción 2 y espera. **Pide los TRES datos juntos, en una sola pregunta.** Una credencial sola no alcanza para construir ni una URL: 1. **La API key.** Pide que la ponga en una variable de entorno y dile que te diga **el nombre de la variable**, no el valor. Si de todos modos te la pega en el chat, úsala pero pídele que la mueva a una variable y que rote esa key: una credencial que pasó por un chat hay que darla por expuesta. NUNCA la escribas en un archivo del proyecto. 2. **El `tenantSlug`.** Es la empresa dentro de la plataforma y va en el path de casi toda ruta. Te lo entrega REDCUMBRE junto con la API key — pero si no lo tienes a mano, **no lo adivines ni pruebes rutas a ver cuál responde: pregúntaselo a la API.** Con la key ya en tu poder, `GET https://api.redcumbre.cl/global/whoami` te devuelve tu `tenantSlug` sin pedirlo en la URL. Es la única llamada del catálogo que no lo exige, justamente porque existe para el momento en que todavía no lo sabes. 3. **Si la key es de sandbox o de producción.** Cambia lo que pasa cuando emitas: una key de sandbox no llega al SII y no genera cobros. **Confírmalo, no lo asumas** — la respuesta de `whoami` trae el flag `sandbox` y ésa es la fuente de verdad. Si lo que te dijo la persona no coincide, avísale antes de emitir nada. Las dos verificaciones, en este orden: - `GET https://api.redcumbre.cl/global/whoami` — **descubre** quién eres: el tenant, los roles y el flag `sandbox`. No necesita el slug. - `GET https://api.redcumbre.cl/{tenantSlug}/whoami` — **confirma** que el slug que vas a usar en el resto de tus llamadas es el de tu credencial. Es lo único que la primera no puede comprobar, porque no recibe slug. Un `403` con `code: API_KEY_TENANT_MISMATCH` acá significa que el slug no es el de esa credencial — **no** que la key esté mala. **Apenas tengas la credencial, llama a `/global/whoami` y muéstrale el resultado a la persona.** No lo guardes en silencio: es el momento en que su integración deja de ser una idea y pasa a estar configurada, y hasta acá no hizo falta escribir una línea de código ni levantar nada. Preséntalo corto y concreto: ✓ Credencial verificada Empresa Mi Empresa SpA (mi-empresa) Rol FULL-API Ambiente sandbox — no llega al SII y no genera cobros **Di lo que la credencial habilita, no lo que falta averiguar.** Si es de sandbox, dilo con lo que implica: puede emitir sin consecuencias y sin costo. Si es de producción, adviértelo con la misma claridad **antes** de que emita nada — un documento tributario emitido de verdad no se deshace, se anula. **Y NO prometas de más.** Que la credencial funcione no significa que todo esté listo: usar un módulo exige que el servicio esté habilitado en la empresa, y emitir documentos tributarios exige además un emisor configurado con certificado vigente y folios. Nada de eso se resuelve por API ni lo puedes verificar desde acá — está en la sección «Lo que no se resuelve por API» de más abajo. Si el primer intento de emitir falla por eso, la persona va a recordar que se lo prometiste. Guarda el slug y el ambiente en el archivo de estado del paso 4, para no volver a preguntarlos. | # | Quiero… | ¿API key? | Receta | |---|---|---|---| | 1 | Consultar el catálogo global: UF, IVA, dólar, regiones, comunas | Sí | https://docs.redcumbre.cl/guias/comunas-regiones/ | | 2 | Configurar mi API key de sandbox en este proyecto | — | https://docs.redcumbre.cl/primeros-pasos/entornos/ | | 3 | Emitir facturas electrónicas (DTE 33/34) | Sí | https://docs.redcumbre.cl/guias/dte/ | | 4 | Emitir boletas de honorarios (BHE) | Sí | https://docs.redcumbre.cl/guias/boletas-honorarios/ | | 5 | Emitir boletas de terceros (BHET) | Sí | https://docs.redcumbre.cl/guias/boletas-terceros/ | | 6 | Consultar un RUT en el SII (persona o empresa) | Sí | https://docs.redcumbre.cl/guias/lookup-rut/ | | 7 | Recibir eventos por webhook en vez de hacer polling | Sí | https://docs.redcumbre.cl/guias/webhooks/ | | 8 | Procesar miles de documentos desde un Excel | Sí | https://docs.redcumbre.cl/guias/procesos-batch/ | Hay más dominios en la tabla de abajo. Los de la 3 a la 8 son los que se piden siempre. ### Paso 4 — Registra lo que vayas logrando Sólo si tienes acceso al sistema de archivos del proyecto. Si no lo tienes, este paso no aplica: no lo menciones y no te disculpes por saltarlo. Cuando completes un paso, escribe o actualiza `.redcumbre/estado.json`: { "tenantSlug": "", "ambientes": { "dev": { "keyEnvVar": "REDCUMBRE_API_KEY", "sandbox": true } }, "integraciones": { "conexion": "ok", "dte": "en-progreso" }, "contacto": { "email": "", "nombre": "" }, "reportes": [{ "caso": "RC-20260819-A3F1", "fecha": "2026-08-19", "titulo": "…" }], "actualizado": "" } Los tres datos del paso 3 viven ahí: el slug, el NOMBRE de la variable de entorno con la credencial, y el flag `sandbox` **tal como lo devolvió `whoami`** — no como te lo dijo la persona. `contacto` y `reportes` los usa `redcumbre:issue` (más abajo): el correo se pide UNA sola vez, y la lista de casos te permite decir «esto ya lo reportaste el martes» en vez de duplicarlo. **Ese archivo NUNCA guarda el secreto de la API key** — solo el NOMBRE de la variable de entorno donde vive. Tampoco guarda el apodo del saludo: es efímero, no identifica nada y no sirve para correlacionar después. **El correo de la persona SÍ va como valor**: no es un secreto, es dato suyo, y está justamente para no volver a preguntárselo. Y agrégalo a `.gitignore` **en el mismo momento en que lo creas**: registra el avance de una máquina, no es algo que el equipo comparta por el repo. --- ## `redcumbre:issue` — cuando encuentres un problema, dilo **No es un paso del arranque.** Los pasos 0 a 4 se ejecutan una vez, en orden, al empezar. Esto es una capacidad permanente: se dispara por un evento que puede no ocurrir nunca. No la ejecutes al arrancar. `redcumbre:issue` significa: prepara y envía un reporte de defecto. Vale escrito por la persona en la conversación, igual que `redcumbre:init`. **Ofrécelo cuando pase alguna de estas cinco cosas:** 1. Recibes un `5xx`. Esas respuestas traen el campo `reportar` con el link de la guía. 2. Una ruta que una guía publicada documenta rechaza tu credencial de máquina. 3. Una respuesta contradice el contrato del spec. 4. Una guía contradice el comportamiento real del API. 5. Un mensaje de error no te permite saber qué estaba mal. **NO lo ofrezcas** cuando el problema es tu propia petición: un `400` por un campo obligatorio que no mandaste, un `401` sin credencial, un `403` con `code: API_KEY_TENANT_MISMATCH`, o algo de «Lo que no se resuelve por API». Reportar eso es el ruido que este canal intenta evitar. **Pregunta siempre antes de enviar** — por acción, no por sesión —, di qué se envía y a dónde, y NO ofrezcas seguimiento del reporte: el repositorio donde queda es interno. Después dile el número de caso a la persona: es su único identificador para citarlo. **Guarda el `x-trace-id` de toda respuesta, también de las exitosas.** Cuando adviertes el defecto, la traza de la llamada anterior ya se perdió, y es el dato que más rinde del reporte entero. El contrato completo, el árbol de decisión y un ejemplo de `curl`: https://docs.redcumbre.cl/guias/reportar-issue/ --- ## Cómo usar el resto de este documento Si ya sabes qué vas a integrar, lee en este orden y no cargues más de lo que necesites: 1. **Este archivo** — entornos, autenticación, dominios y reglas que valen para todo. 2. **La guía del dominio que te toca** — enlazada en la tabla de abajo. Si prefieres todas juntas: https://docs.redcumbre.cl/llms-full.txt (~290 KB, el texto completo de cada guía). 3. **La especificación OpenAPI** — https://api.redcumbre.cl/api-docs-json (~410 KB). Campos, tipos, constraints y códigos de respuesta. Es la fuente de verdad del contrato: si algo acá contradice al spec, gana el spec. **Precedencia, cuando dos fuentes no coinciden:** el spec le gana a cualquier guía —una guía explica, el spec define—, y los valores que cambian por año tributario (tasa de PPM, retención de BHET, IVA) los manda el catálogo `https://api.redcumbre.cl/global/…` consultado en runtime, nunca un número escrito en una guía o en este archivo. Para ubicarte sin bajar el spec entero: https://api.redcumbre.cl/api-docs/index.txt lista todas las rutas en texto plano, agrupadas por dominio. ## Entornos URL base única: https://api.redcumbre.cl No existe un host de sandbox. **El modo sandbox es una propiedad de la API key**, sobre esa misma URL: una key de sandbox no llega al SII, no genera facturación y marca sus respuestas con `sandbox: true`. El flag se fija al crear la key y no se puede editar; para producción se emite una key nueva. Verifica en cuál estás con `GET https://api.redcumbre.cl/{tenantSlug}/whoami`, que devuelve el tenant, los roles de la credencial y su flag de sandbox. Conviene que sea tu primera llamada. Detalle: https://docs.redcumbre.cl/primeros-pasos/entornos/ ## Autenticación API Key como Bearer token, para integraciones server-to-server: Authorization: Bearer El `tenantSlug` va en el path de casi todas las rutas. La API key está asociada a un tenant: si el slug del path no le corresponde, la respuesta es 403. Detalle, y el carril OAuth2: https://docs.redcumbre.cl/primeros-pasos/autenticacion/ ## Reglas que valen para todos los endpoints - **Paginación:** `limit` (default 20, máximo 300) y `offset`. No hay número de página. Los totales vienen al nivel raíz de la respuesta: `{ data, total, limit, offset }`. - **Idempotencia:** `idempotencyKey` (máx. 128 chars) en las emisiones. Si repites la clave para el mismo emisor y tipo, devuelve el documento original sin consumir folio. - **Errores:** formato nativo de NestJS, `{ statusCode, message, error }`. `message` puede ser string **o array de strings** cuando falla la validación del payload. - **Campo `reportar`, sólo en errores del SERVIDOR:** las respuestas con `statusCode >= 500` agregan `reportar` con la URL de https://docs.redcumbre.cl/guias/reportar-issue/. Los rechazos de cliente (`4xx`) NO lo llevan, a propósito: un `400` es casi siempre tu petición, y ofrecerte reportarlo produciría reportes de defectos que no existen. - **Lookups vacíos NO son 404:** una búsqueda por identificador que no encuentra nada responde `200` con `null`. El 404 es para un recurso por ID que debería existir. - **Sin límite de tasa global.** Los hay por caso de uso (OTP de SMS, PIN-RUT). Detalle: https://docs.redcumbre.cl/primeros-pasos/reglas-transversales/ ## Dominios de la API | Dominio | Prefijo | Qué hace | Documentación | |---|---|---|---| | `autorizaciones-v2` | `/{tenantSlug}/autorizaciones-v2` | Flujo de aprobación por supervisores para excepciones comerciales: descuentos fuera de política, precios especiales y excepciones a límites de crédito. | https://docs.redcumbre.cl/guias/autorizaciones/ | | `bhe` | `/{tenantSlug}/bhe` | Boletas de Honorarios Electrónicas: emisión, consulta, descarga y anulación ante el SII. | https://docs.redcumbre.cl/guias/boletas-honorarios/ , https://docs.redcumbre.cl/guias/anulacion-boletas/ | | `bhet` | `/{tenantSlug}/bhet` | Boletas de Honorarios de Terceros: el emisor es la empresa que contrata y paga los servicios prestados por un tercero. | https://docs.redcumbre.cl/guias/boletas-terceros/ , https://docs.redcumbre.cl/guias/anulacion-boletas/ | | `canales` | `/{tenantSlug}/canales` | Despacho de mensajes de plantilla por el número de WhatsApp del tenant, y consulta del estado de un despacho. Las dos rutas son superficie de máquina, con el rol WHATSAPP_MENSAJERIA_DESPACHO. | https://docs.redcumbre.cl/guias/whatsapp/ | | `clientes` | `/{tenantSlug}/clientes` | Cartera de clientes del tenant: alta, consulta por ID, bloqueo y desbloqueo, y administración de los correos y teléfonos a los que se envían los DTE. | https://api.redcumbre.cl/api-docs-json | | `dte` | `/{tenantSlug}/dte` | Documentos Tributarios Electrónicos ante el SII: facturas, notas de crédito y débito, guías de despacho, liquidaciones y boletas. | https://docs.redcumbre.cl/guias/dte/ | | `emisores` | `/{tenantSlug}/emisores` | Deliberado, resuelto en #463: los controllers `:tenantSlug/emisores` y `:tenantSlug/emisores-tributarios` tienen 0 handlers con @ApiKeyAccess y responden 403 a toda API key, y así se quedan. El alta de emisores por API existe por otra vía —Tokenización SII, dominio `tokenizacion`— donde la clave tributaria y el certificado los entrega el propio contribuyente en el wizard, sin pasar por el integrador. El resto de la administración de emisores (listado, edición de sucursales, borrado del certificado) es sólo panel web. guias/emisores.md quedó reescrita en esos términos, así que ya no cita ninguna ruta de este prefijo. | https://docs.redcumbre.cl/guias/emisores/ | | `global` | `/global` | Rutas que exigen credencial válida pero no empresa en la URL, y cuya respuesta es idéntica para toda empresa: el catálogo global de la plataforma —normativa tributaria vigente (IVA, PPM, retención BHET, impuestos adicionales), indicadores del día (UF, dólar) y catálogo territorial de Chile (regiones, comunas)— y el canal de reporte `redcumbre:issue`, por el que un integrador reporta un defecto de la plataforma o de su documentación. | https://docs.redcumbre.cl/guias/comunas-regiones/ , https://docs.redcumbre.cl/guias/reportar-issue/ | | `herramientas` | `/{tenantSlug}/herramientas` | Consulta de datos tributarios de un RUT chileno directamente en el SII: razón social, direcciones y actividades, con caché y modo sandbox. | https://docs.redcumbre.cl/guias/lookup-rut/ | | `hola` | `/hola` | Saludo de arranque del protocolo redcumbre:init. No requiere credencial: es la primera llamada que un agente puede ejecutar antes de que exista una API key. Devuelve un apodo distinto en cada invocación — ésa es la prueba de que la petición ocurrió, porque un asistente no puede inventarlo. No valida la credencial y no devuelve datos de negocio. | https://api.redcumbre.cl/api-docs-json | | `pin` | `/{tenantSlug}/pin` | Verificación de identidad de personas con RUT chileno mediante PIN numérico personal. | https://docs.redcumbre.cl/guias/pin-rut/ | | `procesos-batch` | `/{tenantSlug}/procesos-batch` | Carga masiva por Excel de hasta 5.000 registros, con validación, reintentos automáticos y seguimiento del avance. | https://docs.redcumbre.cl/guias/procesos-batch/ | | `proveedores` | `/{tenantSlug}/proveedores` | Cartera de proveedores del tenant: listado, consulta por ID o por RUT, bloqueo y desbloqueo, y administración de correos y teléfonos para DTE. | https://api.redcumbre.cl/api-docs-json | | `sms` | `/{tenantSlug}/sms` | Envío de mensajes de texto a números chilenos, con procesamiento asíncrono, routing, reintentos y tracking de entrega. | https://docs.redcumbre.cl/guias/sms/ | | `tokenizacion` | `/{tenantSlug}/tokenizacion` | Delegación de credenciales del SII por parte del contribuyente, sin que la clave viaje al integrador: init-session, exchange, invitación y revocación. Es también la única vía de alta de emisores por API — devuelve el emisorId que la emisión de BHE/BHET pide como emisorTributarioId. | https://docs.redcumbre.cl/guias/emisores/ | | `validacion-rut` | `/{tenantSlug}/validacion-rut` | Validación masiva de RUT: carga de archivo, precio por nivel, inicio y cancelación del proceso, y descarga de resultados. | https://api.redcumbre.cl/api-docs-json | | `whoami` | `/{tenantSlug}/whoami` | Verifica la credencial y devuelve el tenant, los roles y el flag de sandbox. Conviene como primera llamada de una integración. | https://api.redcumbre.cl/api-docs-json | Las rutas concretas de cada dominio no se listan acá: están en https://api.redcumbre.cl/api-docs/index.txt, que el API genera en runtime y por eso nunca queda desfasado. ## Otras guías No corresponden a un dominio de la superficie, pero forman parte de la integración: - **dte-xml-format1** — Endpoint SOAP de compatibilidad, aprobado como superficie de máquina pero marcado `oculta` en el snapshot: se sostiene para integradores que migran desde un sistema SOAP, no se promociona a integradores nuevos. → https://docs.redcumbre.cl/guias/dte-xml-format1/ - **identidad-acreditada** — Documenta un evento SALIENTE del carril PIN-RUT: el integrador lo recibe, no lo pide. No expone ninguna ruta propia en la superficie pública, así que no le corresponde un dominio. Su contraparte con rutas es guias/pin-rut. → https://docs.redcumbre.cl/guias/identidad-acreditada/ - **webhooks** — Documenta el mecanismo de notificaciones SALIENTES firmadas con HMAC: el tenant recibe las peticiones, no las emite. No expone rutas propias en la superficie pública, así que no le corresponde un dominio. → https://docs.redcumbre.cl/guias/webhooks/ ## Lo que no se resuelve por API Requiere contacto con REDCUMBRE: emisión de API keys, habilitación de servicios en el tenant, y configuración de un emisor DTE con su certificado digital y folios CAF. No hay auto-registro. - Solicitar una cuenta de integración: https://docs.redcumbre.cl/solicitar-cuenta/ - Guía para integrar con un asistente de IA: https://docs.redcumbre.cl/primeros-pasos/integrar-con-ia/ - Documentación completa: https://docs.redcumbre.cl - Sitio de REDCUMBRE: https://redcumbre.cl # Anulación de Boletas (BHE y BHET) Fuente: https://docs.redcumbre.cl/guias/anulacion-boletas/ :::tip[TL;DR - Acceso Rápido] **Endpoint:** `POST /{tenantSlug}/bhe/{id}/anular` 👉 [Ver endpoints de anulación BHE en Swagger](https://api.redcumbre.cl/api-docs#/SII%20Boletas%20de%20Honorarios%20%E2%80%94%20Anulaci%C3%B3n) 👉 [Ver endpoints de anulación BHET en Swagger](https://api.redcumbre.cl/api-docs#/SII%20Boletas%20de%20Terceros%20%E2%80%94%20Anulaci%C3%B3n) ::: La API permite anular ante el SII boletas que emitiste con nosotros y también boletas que el emisor emitió directamente en el portal del SII. El trabajo es **asíncrono**: la solicitud responde `202 Accepted` de inmediato y el resultado llega por webhook o consultando el estado. :::caution[La anulación es irreversible] Una vez que el SII acepta la anulación, la boleta **no se puede reactivar**. Si necesitas el documento, tendrás que emitir uno nuevo con un folio nuevo. ::: --- ## Requisitos Previos 1. Un **Emisor Tributario** activo, con clave tributaria vigente (la misma que usas para emitir) 2. Una **API Key** con uno de estos roles: - BHE: `ADMIN`, `SUPER-ADMIN`, `SII-EMISOR-BHE` o `FULL-API` - BHET: `ADMIN`, `SUPER-ADMIN`, `SII-EMISOR-BHET` o `FULL-API` 3. El servicio correspondiente (`SII_BHE` / `SII_BHET`) habilitado en tu cuenta :::note[No hay validación de plan] La anulación **nunca** se rechaza por facturación. Corrige un documento que ya se emitió y que ya se cobró, así que no depende de tu saldo ni de los límites de tu plan. ::: --- ## Las dos vías Según dónde se emitió la boleta, cambia el endpoint que usas: | Vía | Endpoint | Cuándo | |---|---|---| | **Interna** | `POST /{tenantSlug}/bhe/{id}/anular` | La boleta la emitiste con nosotros y tienes su `id` | | **Externa** | `POST /{tenantSlug}/bhe/anulaciones` | La boleta se emitió en el portal del SII: no existe en la plataforma | ``` ¿La boleta la emitiste con nuestra API? │ ┌─────────────┴─────────────┐ │ Sí │ No ▼ ▼ POST /bhe/{id}/anular POST /bhe/anulaciones { causaSii } { causaSii, folioSii, emisorTributarioId } │ │ └─────────────┬─────────────┘ ▼ 202 { anulacionId } │ [Cola → SII] │ ┌─────────────┴─────────────┐ ▼ ▼ webhook bhe.anulada webhook bhe.anulacion_fallida ``` Para BHET son las mismas rutas bajo `/{tenantSlug}/bhet`. --- ## Causas de Anulación La causa se declara al SII y es **obligatoria**. Los códigos son distintos catálogos según el tipo de boleta — el texto es el que muestra el propio portal del SII. ### BHE | `causaSii` | Significado | |---|---| | `"1"` | No pago de honorarios | | `"2"` | Prestación de servicios no realizada | | `"3"` | Error en la digitación | ### BHET | `causaSii` | Significado | |---|---| | `"1"` | No pago | | `"2"` | Prestación de servicios no realizada | | `"3"` | Error en la digitación | Además puedes registrar un `motivoInterno` (hasta 1.000 caracteres) para tu propia trazabilidad. **Ese texto nunca se envía al SII** ni aparece en el comprobante que recibe el destinatario. --- ## Anular una boleta emitida en la plataforma ```bash POST /{tenantSlug}/bhe/{id}/anular Authorization: Bearer {api_key} Content-Type: application/json { "causaSii": "3", "motivoInterno": "Se digitó mal el monto de la prestación", "correlationId": "mi-referencia-123", "enviarComprobante": true } ``` | Campo | Tipo | Requerido | Descripción | |---|---|---|---| | `causaSii` | string | Sí | Código de causa (`"1"`, `"2"` o `"3"`) | | `motivoInterno` | string | No | Tu registro interno. Máx. 1.000 caracteres. No se envía al SII | | `correlationId` | string | No | Tu identificador para correlacionar. Vuelve en el webhook. Máx. 255 | | `enviarComprobante` | boolean | No | Enviar comprobante al destinatario. Default `true` | ### Response `202 Accepted` ```json { "anulacionId": "cmp7x2k9a0001jw0h4nqz8bcd", "estado": "PENDIENTE", "folioSii": "500", "tipoBoleta": "BHE", "mensaje": "Solicitud de anulación registrada. El resultado llegará en unos segundos." } ``` El `202` confirma que **la solicitud quedó registrada y encolada**, no que el SII haya anulado nada. Guarda el `anulacionId`: es lo que usas para consultar el estado y para reintentar. ### Response `409 Conflict` Si ya existe una anulación en curso o terminada para ese folio, el body incluye el `anulacionId` existente para que puedas hacer seguimiento en vez de reintentar a ciegas: ```json { "statusCode": 409, "message": "Ya hay una anulación en curso para el folio 500.", "anulacionId": "cmp7x2k9a0001jw0h4nqz8bcd", "estado": "PROCESANDO" } ``` --- ## Anular una boleta emitida fuera de la plataforma Sirve para boletas que el emisor tributario emitió directamente en el portal del SII. Como no hay boleta local que referenciar, la identificas por **folio + emisor**. ```bash POST /{tenantSlug}/bhe/anulaciones Authorization: Bearer {api_key} Content-Type: application/json { "folioSii": "500", "emisorTributarioId": "tu-emisor-tributario-id", "causaSii": "1", "motivoInterno": "El cliente nunca pagó", "notificarEmail": "cliente@ejemplo.cl" } ``` Acepta los mismos campos que la vía interna, más: | Campo | Tipo | Requerido | Descripción | |---|---|---|---| | `folioSii` | string | Sí | Folio de la boleta ante el SII. Solo dígitos, o un folio de [sandbox](#sandbox) | | `emisorTributarioId` | string | Sí | ID del emisor tributario dueño de la boleta | | `notificarEmail` | string | No | Destinatario del comprobante de anulación | :::note[Por qué `notificarEmail` solo existe acá] Sin boleta local, la plataforma no tiene forma de saber a quién se le emitió. Si no lo indicas, no se envía comprobante a nadie. En la vía interna el destinatario sale de la boleta. ::: --- ## Seguir el resultado ### Opción 1: Webhooks (recomendado) | Evento | Cuándo se dispara | |---|---| | `bhe.anulada` / `bhet.anulada` | El SII aceptó la anulación | | `bhe.anulacion_fallida` / `bhet.anulacion_fallida` | El SII la rechazó de forma permanente, o se agotaron los reintentos | :::note["Ya estaba anulada" no dispara webhook] Si el SII responde que la boleta ya estaba anulada, no se emite ningún evento: desde tu perspectiva nada cambió. Ese caso lo ves consultando el estado (`YA_ANULADA`). ::: #### Payload: `bhe.anulada` ```json { "event": "bhe.anulada", "tenantId": "8", "anulacionId": "cmp7x2k9a0001jw0h4nqz8bcd", "tipoBoleta": "BHE", "folioSii": "500", "bheId": "cms7xg4tr000kjw12kmxw8fej", "causaSii": "3", "codigoResultadoSii": "S", "fechaAnulacionSii": "2026-07-30T15:42:00.000Z", "correlationId": "mi-referencia-123" } ``` #### Payload: `bhe.anulacion_fallida` ```json { "event": "bhe.anulacion_fallida", "tenantId": "8", "anulacionId": "cmp7x2k9a0001jw0h4nqz8bcd", "tipoBoleta": "BHE", "folioSii": "500", "bheId": "cms7xg4tr000kjw12kmxw8fej", "causaSii": "3", "codigoResultadoSii": "C", "mensajeSii": "La boleta excede el monto máximo de anulación", "correlationId": "mi-referencia-123" } ``` En BHET los campos son idénticos, con `bhetId` en vez de `bheId`. :::caution[`codigoResultadoSii` puede venir vacío en `*.anulacion_fallida`] Ese campo trae el veredicto del SII, y **hay desenlaces donde el SII nunca alcanzó a dar uno**: si no se pudo establecer la comunicación tras agotar los reintentos, llega `null`. Lo mismo con `mensajeSii`, que en ese caso describe el fallo de la plataforma, no una respuesta del Servicio. No asumas que un `bhe.anulacion_fallida` significa "el SII lo rechazó". Significa que la anulación no se ejecutó — el `codigoResultadoSii` te dice si fue el SII quien lo decidió. ::: Revisa la [guía de Webhooks](/guias/webhooks) para la configuración, firma y reintentos. ### Cuánto puede tardar La mayoría de las anulaciones se resuelven en segundos. Pero cuando el SII no responde, la plataforma reintenta sola con espera creciente, y el desenlace puede tardar **horas**: | Capa | Comportamiento | |---|---| | Reintentos de la cola | Hasta 8 intentos con espera exponencial, que llega a ~64 minutos entre uno y otro | | Recuperación automática | Un proceso cada 10 minutos rescata las anulaciones que quedaron sin procesar | | Techo | Hasta 3 recuperaciones; después se cierra en `FALLIDA_DEFINITIVA` | **Toda anulación alcanza un estado final**: siempre vas a recibir el webhook de desenlace o ver un estado terminal al consultar. Si haces polling, no pongas un timeout de minutos — usa webhooks, o consulta con una frecuencia baja y sin límite de tiempo. ### Opción 2: Consultar el estado ```bash GET /{tenantSlug}/bhe/anulaciones/{anulacionId} Authorization: Bearer {api_key} ``` ```json { "id": "cmp7x2k9a0001jw0h4nqz8bcd", "estado": "ANULADA", "tipoBoleta": "BHE", "origenBoleta": "INTERNA", "folioSii": "500", "bheId": "cms7xg4tr000kjw12kmxw8fej", "causaSii": "3", "motivoInterno": "Se digitó mal el monto de la prestación", "emisorTributarioId": "tu-emisor-tributario-id", "emisorRut": "12345678-9", "emisorRazonSocial": "JUAN PÉREZ", "codigoResultadoSii": "S", "mensajeSii": null, "montoMaximoAnulacion": 1000000, "minutosMaximosAnulacion": 43200, "intentos": 1, "procesandoAt": "2026-07-30T15:41:58.000Z", "resueltaAt": "2026-07-30T15:42:03.000Z", "fechaAnulacionSii": "2026-07-30T15:42:00.000Z", "canal": "API_SYNC", "correlationId": "mi-referencia-123", "comprobanteSolicitado": true, "notificarEmail": null, "emailEnviado": true, "emailEnviadoAt": "2026-07-30T15:42:11.000Z", "intentosEmail": 1, "errorEmail": null, "createdAt": "2026-07-30T15:41:55.000Z", "updatedAt": "2026-07-30T15:42:11.000Z" } ``` `montoMaximoAnulacion` y `minutosMaximosAnulacion` los informa el SII en su respuesta y **solo aparecen en BHE**. Son los umbrales que el SII aplicó a esa solicitud puntual. --- ## Estados de la Anulación | Estado | Terminal | Descripción | |---|---|---| | `PENDIENTE` | No | Solicitud aceptada, esperando su turno en la cola | | `PROCESANDO` | No | Hablando con el SII | | `ANULADA` | Sí | El SII aceptó la anulación | | `YA_ANULADA` | Sí | El SII informa que la boleta ya estaba anulada (se anuló fuera de la plataforma) | | `NO_ANULABLE` | Sí | El SII rechazó de forma permanente: fuera de plazo, sobre el monto, folio inexistente | | `FALLIDA_DEFINITIVA` | Sí | No se pudo completar: se agotaron los reintentos por errores transitorios (red, caída del SII), o el techo de recuperación automática. **El SII no rechazó nada** — `codigoResultadoSii` viene `null` | `NO_ANULABLE` y `FALLIDA_DEFINITIVA` admiten [reintento manual](#reintentar-una-anulación). --- ## Códigos del SII El campo `codigoResultadoSii` trae el código crudo que devolvió el SII, sin traducir. Así se interpreta en BHE: | Código | Estado resultante | ¿Se cobra? | Significado | |---|---|---|---| | `S` | `ANULADA` | Sí | Anulada | | `V` | `ANULADA` | Sí | El SII aceptó y espera que el receptor confirme u objete | | `v` | `NO_ANULABLE` | No | Receptor extranjero: el SII no puede pedir la confirmación. **La anulación no se ejecutó** | | `A` | `YA_ANULADA` | No | La boleta ya estaba anulada | | `E` | `NO_ANULABLE` | No | El folio no existe | | `C` / `M` / `R` / `L` | `NO_ANULABLE` | No | Rechazo por límites o reglas del SII | | `X` | reintentable | No | Error transitorio: se reintenta automáticamente | :::caution[`V` mayúscula y `v` minúscula no son lo mismo] `V` significa que el SII aceptó la anulación. `v` significa que **no la ejecutó** porque el receptor es extranjero y no se le puede pedir la confirmación; ahí el SII exige que el trámite se haga en una oficina. Si tu integración distingue casos por este código, compáralo respetando mayúsculas. ::: Un código que no esté en la tabla se trata como transitorio y se reintenta. --- ## Plazos y Límites Los límites que puede aplicar el SII no son los mismos para los dos tipos de boleta. ### BHET — se validan antes de llamar al SII | Límite | Valor | |---|---| | Antigüedad máxima | **10 días corridos** desde la fecha de emisión | | Monto neto máximo | **$1.000.000** (líquido a pagar al tercero) | Si la boleta está fuera de estos límites, la API responde `400` de inmediato, sin gastar una llamada al SII. Ahí la anulación electrónica ya no está disponible: hay que presentar el **Formulario 2117** en una oficina del SII. ### BHE — el SII decide, no se puede anticipar El SII **no publica** el plazo máximo para anular una boleta de honorarios, y el umbral llega recién en su respuesta (`minutosMaximosAnulacion`). No hay prevalidación posible: se intenta y se interpreta el rechazo. Diseña tu integración asumiendo que una anulación de BHE puede volver `NO_ANULABLE` sin aviso previo. --- ## Comprobante al Destinatario Cuando el SII confirma la anulación, la plataforma envía por correo un **comprobante de anulación** al destinatario de la boleta, con el PDF marcado como anulado adjunto por enlace. | Situación | A quién se le envía | |---|---| | BHE con destinatario | Al email del destinatario de la boleta | | BHET | Al email del tercero | | Vía externa | Solo a `notificarEmail`, si lo indicaste | Para desactivarlo —por ejemplo, si tú manejas tu propia comunicación con el cliente— manda `enviarComprobante: false` en la solicitud: ```json { "causaSii": "3", "enviarComprobante": false } ``` El default es `true`: la opción segura es que el destinatario se entere. :::note[El comprobante solo sale si la anulación se concretó] Se envía únicamente cuando el estado final es `ANULADA`. Un `YA_ANULADA`, `NO_ANULABLE` o `FALLIDA_DEFINITIVA` no genera correo — no tendría sentido avisarle a alguien de algo que no ocurrió. ::: Los campos `emailEnviado`, `emailEnviadoAt`, `intentosEmail` y `errorEmail` del estado te dejan verificar el envío. El correo se despacha con reintentos automáticos; si el PDF marcado no se puede generar, el correo sale igual, sin el enlace. --- ## Reintentar una Anulación Solo desde `NO_ANULABLE` o `FALLIDA_DEFINITIVA`. Sirve para corregir la causa declarada o para volver a intentar después de una caída del SII. ```bash POST /{tenantSlug}/bhe/anulaciones/{anulacionId}/reintentar Authorization: Bearer {api_key} Content-Type: application/json { "causaSii": "1" } ``` `causaSii` es opcional: si lo omites, se reintenta con la causa original. Responde `202` con el mismo contrato de la solicitud inicial. Reintentar desde cualquier otro estado responde `409`. El reintento valida lo mismo que la solicitud inicial: con una API Key de sandbox sobre una boleta real, responde `400 SANDBOX_BOLETA_REAL`. Ver [Sandbox](#sandbox). --- ## Listar Anulaciones ```bash GET /{tenantSlug}/bhe/anulaciones?estado=NO_ANULABLE&desde=2026-07-01&limit=50&offset=0 Authorization: Bearer {api_key} ``` | Parámetro | Tipo | Descripción | |---|---|---| | `estado` | enum | `PENDIENTE`, `PROCESANDO`, `ANULADA`, `YA_ANULADA`, `NO_ANULABLE`, `FALLIDA_DEFINITIVA` | | `folioSii` | string | Filtrar por folio | | `emisorTributarioId` | string | Filtrar por emisor | | `desde` / `hasta` | ISO 8601 | Rango sobre la fecha de solicitud | | `limit` | number | Registros por página. Default `20`, máximo `300` (`PAGINATION_MAX_LIMIT`) | | `offset` | number | Registros a saltar. Default `0` | ```json { "data": [ { "id": "cmp7x2k9a0001jw0h4nqz8bcd", "estado": "ANULADA", "...": "..." } ], "total": 137, "offset": 0, "limit": 50 } ``` El listado está acotado al tipo de boleta del endpoint: `/bhe/anulaciones` nunca devuelve anulaciones de BHET, ni al revés. ### El estado también viaja en el listado de boletas `GET /{tenantSlug}/bhe` incluye en cada boleta un objeto liviano con su anulación, si tiene. Te evita una request extra para pintar un listado: ```json { "id": "cms7xg4tr000kjw12kmxw8fej", "folioSii": "500", "estado": "ANULADA", "anulacion": { "id": "cmp7x2k9a0001jw0h4nqz8bcd", "estado": "ANULADA", "causaSii": "3", "resueltaAt": "2026-07-30T15:42:03.000Z" } } ``` Es `null` cuando nunca se intentó anular. Sirve para distinguir una anulación **rechazada** de una que **nunca se pidió** — cosa que el `estado` de la boleta por sí solo no te dice. --- ## Errores Estas condiciones se rechazan antes de encolar nada: | Situación | Status | |---|---| | `causaSii` ausente o fuera del catálogo | `400` | | La boleta no tiene folio del SII (nunca llegó a emitirse) | `400` | | La boleta está en un estado distinto de `EMITIDA` | `400` | | El emisor está inactivo, revocado o sin credenciales | `400` | | **BHET:** fuera de los 10 días o sobre $1.000.000 netos | `400` | | API Key de sandbox sobre una boleta real — `SANDBOX_BOLETA_REAL` | `400` | | API Key de producción declarando un folio de sandbox — `FOLIO_SANDBOX_EN_PRODUCCION` | `400` | | El emisor o la boleta no existen en tu cuenta | `404` | | Ya hay una anulación en curso o terminada para ese folio | `409` | | Reintento desde un estado que no lo admite | `409` | | API Key inválida, expirada o revocada | `401` | | El rol de tu API Key no alcanza para el recurso | `403` | | No se pudo verificar tu credencial (transitorio) | `503` | :::caution[El 503 no significa credencial inválida] Ante un `503` **no rotes tu API Key**: el error es del lado del servicio y es transitorio. Reintenta respetando el header `Retry-After`. ::: Los rechazos del propio SII **no** son errores HTTP: la solicitud se aceptó con `202` y el rechazo llega como estado terminal (`NO_ANULABLE`) más el webhook `*.anulacion_fallida`. --- ## Cobro **CLP 35 por anulación efectiva**, mismo valor para BHE y BHET. Se cobra **una sola vez y solo cuando el SII aceptó la anulación** (`ANULADA`). No se cobra por: - Boletas que ya estaban anuladas (`YA_ANULADA`) - Rechazos del SII (`NO_ANULABLE`), incluido el caso del receptor extranjero (`v`) - Reintentos por errores transitorios - Reintentos manuales de una anulación ya cobrada - Anulaciones simuladas en [sandbox](#sandbox) La emisión de la boleta ya se cobró en su momento: anularla no la reembolsa. Ver los [precios](https://redcumbre.cl/precios). --- ## Sandbox Con una API Key de sandbox, la anulación **se simula**: no se llama al SII, no se cobra y no se envía el comprobante al destinatario. Todo lo demás ocurre igual que en producción — la anulación queda `ANULADA`, la boleta pasa a `ANULADA`, y el webhook `bhe.anulada` / `bhet.anulada` te llega como siempre. Es el flujo completo para probar tu integración de punta a punta. :::caution[Solo puedes anular boletas emitidas en sandbox] Una API Key de sandbox **no puede anular una boleta real**. Si lo intentas, la respuesta es `400` con el código `SANDBOX_BOLETA_REAL`: ```json { "statusCode": 400, "error": "SANDBOX_BOLETA_REAL", "message": "El folio 123456 es de una boleta real y esta API Key es de sandbox. En sandbox solo se pueden anular boletas emitidas en sandbox: emite una con esta misma API Key y anula ese folio.", "folioSii": "123456" } ``` El motivo es que simular sobre un folio real dejaría la boleta `ANULADA` en Redcumbre mientras sigue **vigente ante el SII**. Para probar el flujo, emite una boleta con tu API Key de sandbox y anula ese folio: los folios de sandbox se reconocen por su prefijo (`SANDBOX-` en BHE, `SBX` en BHET). ::: ### Qué se simula y qué se rechaza Lo que decide si la anulación se simula es **el folio de la boleta**, no la API Key con la que la pides: | Tu API Key | El folio | Resultado | |---|---|---| | Sandbox | Real | `400 SANDBOX_BOLETA_REAL` | | Sandbox | De sandbox | Se simula | | Producción | De sandbox, **vía externa** | `400 FOLIO_SANDBOX_EN_PRODUCCION` | | Producción | De sandbox, **vía interna** | Se simula igual | | Producción | Real | Anulación real ante el SII | Las dos últimas filas no son una inconsistencia. Una boleta emitida en sandbox **no existe en el SII**: intentar anularla de verdad fallaría igual, así que se simula sin importar con qué credencial la pidas. En cambio, en la vía externa el folio lo escribes tú y no hay boleta local que lo respalde — por eso ahí sí se rechaza: ```json { "statusCode": 400, "error": "FOLIO_SANDBOX_EN_PRODUCCION", "message": "El folio SANDBOX-1765567603056 tiene el prefijo reservado para boletas de sandbox y esta API Key es de producción. Envía el folio real que el SII asignó a la boleta.", "folioSii": "SANDBOX-1765567603056" } ``` Sin ese corte, cualquiera obtendría una anulación aceptada — sin cobro y sin tocar al SII — inventando un folio con el prefijo. Las mismas reglas aplican a `POST …/anulaciones/{anulacionId}/reintentar`. El sandbox resuelve siempre con una anulación aceptada. Los rechazos del SII (`NO_ANULABLE`, `YA_ANULADA`) no se simulan: para ejercitar esas ramas, usa las validaciones previas que sí responden sin tocar al SII (`400` / `404` / `409`). --- ## API Reference Para el detalle técnico completo de cada endpoint: 👉 [Anulación de BHE en Swagger](https://api.redcumbre.cl/api-docs#/SII%20Boletas%20de%20Honorarios%20%E2%80%94%20Anulaci%C3%B3n) 👉 [Anulación de BHET en Swagger](https://api.redcumbre.cl/api-docs#/SII%20Boletas%20de%20Terceros%20%E2%80%94%20Anulaci%C3%B3n) Guías relacionadas: - [Boletas de Honorarios](/guias/boletas-honorarios) — emisión de BHE - [Boletas de Terceros](/guias/boletas-terceros) — emisión de BHET - [Webhooks](/guias/webhooks) — configuración, firma y reintentos - [Emisores](/guias/emisores) — alta y credenciales del emisor tributario --- # Autorizaciones de Documentos Fuente: https://docs.redcumbre.cl/guias/autorizaciones/ :::tip[TL;DR - Acceso Rápido] **Endpoint:** `POST/GET /{tenantSlug}/autorizaciones-v2` 👉 [Ver endpoints en Swagger](https://api.redcumbre.cl/api-docs#/Autorizaciones%20de%20Documentos) ::: El sistema de autorizaciones permite gestionar excepciones comerciales que requieren aprobación de un supervisor antes de proceder. Casos típicos incluyen descuentos que exceden las políticas de la empresa, precios especiales para clientes VIP, y excepciones a límites de crédito. :::note[Integración Actual] Actualmente solo **BHE (Boletas de Honorarios Electrónicas)** tiene integración completa con el flujo de autorizaciones. Para otros tipos de documento, las solicitudes se crean y resuelven correctamente, pero el estado del documento origen debe manejarse manualmente. ::: --- ## Requisitos Previos Antes de usar la API de autorizaciones necesitas: 1. Una **API Key** con uno de estos roles: `ADMIN`, `SUPER-ADMIN`, `SUPERVISOR-VENTAS`, `OPERADOR`, o `FULL-API` 2. El **flujo de autorización habilitado** en la configuración del tenant 3. Para aprobar/rechazar: rol de supervisor (`ADMIN`, `SUPER-ADMIN`, `SUPERVISOR-VENTAS`, `FULL-API`) --- ## Conceptos Clave ### Solicitud vs Items El sistema utiliza un modelo **consolidado**: una solicitud agrupa múltiples items de autorización relacionados con un mismo documento. Esto permite: - **Una sola notificación** por documento (no una por cada línea) - **Resolución eficiente**: aprobar/rechazar todo o resolver items individualmente - **Trazabilidad completa**: historial consolidado por documento ### Estados de la Solicitud | Estado | Descripción | Siguiente acción | |--------|-------------|------------------| | `PENDIENTE` | Esperando resolución de supervisor | Aprobar, rechazar o resolver items | | `APROBADA` | Todos los items aprobados | Documento listo para procesar | | `RECHAZADA` | Todos los items rechazados | Operador debe corregir y re-solicitar | | `PARCIAL` | Algunos items aprobados, otros rechazados | Operador debe corregir items rechazados | | `EXPIRADA` | Tiempo de espera agotado | Operador debe crear nueva solicitud | ### Tipos de Documento | Tipo | Integración | Descripción | |------|:-----------:|-------------| | `BHE` | ✅ Completa | Boleta de Honorarios Electrónica | | `FACTURA` | ⚠️ Manual | Factura Electrónica | | `BOLETA` | ⚠️ Manual | Boleta de Venta | | `COTIZACION` | ⚠️ Manual | Cotización | | `NOTA_CREDITO` | ⚠️ Manual | Nota de Crédito | ### Tipos de Autorización | Tipo | Descripción | |------|-------------| | `DESCUENTO_LINEA` | Descuento por línea de producto/servicio | | `PRECIO_ESPECIAL_LINEA` | Precio especial por línea | | `LIMITE_CREDITO` | Excede límite de crédito del cliente | | `EXCEPCION_COMERCIAL` | Excepción a política comercial | | `PLAZO_PAGO_EXTENDIDO` | Plazo de pago mayor al permitido | | `MONTO_MAXIMO_DOCUMENTO` | Documento excede monto máximo | | `OTRO` | Otro tipo de autorización | ### Alcance | Alcance | Descripción | |---------|-------------| | `LINEA` | Autorización específica para una línea del documento | | `DOCUMENTO` | Autorización que aplica a todo el documento | --- ## Flujo de Autorización ``` Operador Redcumbre Supervisor │ │ │ │── POST /autorizaciones-v2 ──▶│ │ │ (crea solicitud) │ │ │ │ │ │◀── Response ────────────────│ │ │ (id, estado: PENDIENTE) │ │ │ │── Notificación ───────────▶│ │ │ (push, email, in-app) │ │ │ │ │ │◀── POST /:id/aprobar ──────│ │ │ o POST /:id/rechazar │ │ │ o POST /:id/resolver │ │ │ │ │◀── Notificación ────────────│ │ │ (resultado) │ │ │ │ │ │ [Si BHE: Borrador → │ │ │ AUTORIZADO/RECHAZADO] │ │ ``` **Tiempos de expiración:** - Default: 1 semana (configurable por tenant) - Solicitudes expiradas se marcan automáticamente como `EXPIRADA` - El operador debe crear una nueva solicitud si expira --- ## Crear Solicitud **Endpoint:** `POST /{tenantSlug}/autorizaciones-v2` Crea una nueva solicitud de autorización consolidada con uno o más items. ### Request ```bash curl -X POST "https://api.redcumbre.cl/{tenantSlug}/autorizaciones-v2" \ -H "Authorization: Bearer {api_key}" \ -H "Content-Type: application/json" \ -d '{ "tipoDocumento": "BHE", "borradorId": "cm5borrador123", "items": [ { "tipo": "DESCUENTO_LINEA", "alcance": "LINEA", "lineaId": "linea-001", "productoId": "prod-abc123", "productoNombre": "Consultoría Tributaria", "codigoSku": "CONS-TRIB-01", "valorPermitido": 10, "valorSolicitado": 25, "unidad": "PORCENTAJE", "motivo": "Cliente VIP con contrato anual", "contexto": { "cantidad": 1, "precioUnitario": 500000 } } ] }' ``` ### Campos del Request | Campo | Tipo | Requerido | Descripción | |-------|------|:---------:|-------------| | `tipoDocumento` | enum | Sí | Tipo de documento (BHE, FACTURA, etc.) | | `borradorId` | string | No | ID del borrador asociado (solo BHE) | | `items` | array | Sí | Lista de items de autorización (mín 1) | ### Campos de cada Item | Campo | Tipo | Requerido | Descripción | |-------|------|:---------:|-------------| | `tipo` | enum | Sí | Tipo de autorización | | `alcance` | enum | Sí | `LINEA` o `DOCUMENTO` | | `lineaId` | string | Condicional | ID de la línea (requerido si alcance = LINEA) | | `productoId` | string | No | ID del producto asociado | | `productoNombre` | string | No | Nombre del producto (snapshot) | | `codigoSku` | string | No | Código SKU del producto | | `valorPermitido` | number | No | Valor máximo según política | | `valorSolicitado` | number | No | Valor que se solicita | | `unidad` | enum | No | `PORCENTAJE`, `MONTO`, `DIAS` | | `motivo` | string | Sí | Justificación de la solicitud | | `contexto` | object | No | Datos adicionales según tipo | ### Response (201 Created) ```json { "id": "cm5solicitud123", "tenantId": "8", "tipoDocumento": "BHE", "borradorId": "cm5borrador123", "solicitanteUserId": "user-001", "solicitanteNombre": "Juan Pérez", "autorizadorUserId": null, "autorizadorNombre": null, "estado": "PENDIENTE", "createdAt": "2025-12-19T10:00:00Z", "expiresAt": "2025-12-26T10:00:00Z", "resueltaAt": null, "items": [ { "id": "cm5item001", "tipo": "DESCUENTO_LINEA", "alcance": "LINEA", "lineaId": "linea-001", "productoId": "prod-abc123", "productoNombre": "Consultoría Tributaria", "codigoSku": "CONS-TRIB-01", "valorPermitido": 10, "valorSolicitado": 25, "unidad": "PORCENTAJE", "motivo": "Cliente VIP con contrato anual", "contexto": { "cantidad": 1, "precioUnitario": 500000 }, "estado": "PENDIENTE", "comentarioResolucion": null } ] } ``` --- ## Listar Solicitudes **Endpoint:** `GET /{tenantSlug}/autorizaciones-v2` Lista solicitudes con filtros y paginación. ### Query Parameters | Parámetro | Tipo | Descripción | |-----------|------|-------------| | `estado` | enum | Filtrar por estado (default: `PENDIENTE`) | | `tipoDocumento` | enum | Filtrar por tipo de documento | | `tipoAutorizacion` | enum | Filtrar por tipo de autorización | | `solicitanteUserId` | string | Filtrar por solicitante (solo admins) | | `page` | number | Página (default: 1) | | `limit` | number | Resultados por página. Default `20`, máximo `300` (`PAGINATION_MAX_LIMIT`) | ### Comportamiento por Rol | Rol | Comportamiento | |-----|----------------| | `SUPER-ADMIN`, `ADMIN`, `SUPERVISOR-VENTAS`, `FULL-API` | Ve todas las solicitudes del tenant | | `OPERADOR` | Solo ve sus propias solicitudes | ### Request ```bash curl -X GET "https://api.redcumbre.cl/{tenantSlug}/autorizaciones-v2?estado=PENDIENTE&limit=20" \ -H "Authorization: Bearer {api_key}" ``` ### Response (200 OK) ```json { "data": [ { "id": "cm5solicitud123", "tipoDocumento": "BHE", "solicitanteNombre": "Juan Pérez", "estado": "PENDIENTE", "createdAt": "2025-12-19T10:00:00Z", "expiresAt": "2025-12-26T10:00:00Z", "items": [...] } ], "total": 15, "page": 1, "limit": 20, "totalPages": 1 } ``` --- ## Obtener Detalle **Endpoint:** `GET /{tenantSlug}/autorizaciones-v2/{id}` Obtiene el detalle completo de una solicitud incluyendo items y borrador asociado. ### Request ```bash curl -X GET "https://api.redcumbre.cl/{tenantSlug}/autorizaciones-v2/{id}" \ -H "Authorization: Bearer {api_key}" ``` ### Response (200 OK) ```json { "id": "cm5solicitud123", "tenantId": "8", "tipoDocumento": "BHE", "borradorId": "cm5borrador123", "solicitanteUserId": "user-001", "solicitanteNombre": "Juan Pérez", "autorizadorUserId": null, "autorizadorNombre": null, "estado": "PENDIENTE", "createdAt": "2025-12-19T10:00:00Z", "expiresAt": "2025-12-26T10:00:00Z", "resueltaAt": null, "items": [...], "bheBorrador": { "id": "cm5borrador123", "estado": "EN_AUTORIZACION", "montoBruto": 450000 } } ``` El borrador trae además `ppm` y `montoLiquido`, que se derivan del `montoBruto` con la tasa de PPM del año de emisión. No los reproducimos acá para no fijar una tasa que cambia: la vigente se consulta en `GET /global/ppm`. Ver [Cálculo de montos](/guias/boletas-honorarios/#cálculo-de-montos). --- ## Aprobar Solicitud **Endpoint:** `POST /{tenantSlug}/autorizaciones-v2/{id}/aprobar` Aprueba todos los items de una solicitud pendiente. :::note[Roles Requeridos] Requiere rol de supervisor: `SUPER-ADMIN`, `ADMIN`, `SUPERVISOR-VENTAS`, o `FULL-API` ::: ### Efectos - Todos los items pasan a estado `APROBADO` - La solicitud pasa a estado `APROBADA` - Si hay borrador BHE asociado, se marca como `AUTORIZADO` (listo para emitir) - Se notifica al solicitante ### Request ```bash curl -X POST "https://api.redcumbre.cl/{tenantSlug}/autorizaciones-v2/{id}/aprobar" \ -H "Authorization: Bearer {api_key}" ``` ### Response (200 OK) ```json { "id": "cm5solicitud123", "estado": "APROBADA", "autorizadorUserId": "supervisor-001", "autorizadorNombre": "María García", "resueltaAt": "2025-12-19T12:30:00Z", "items": [ { "id": "cm5item001", "estado": "APROBADO" } ] } ``` --- ## Rechazar Solicitud **Endpoint:** `POST /{tenantSlug}/autorizaciones-v2/{id}/rechazar` Rechaza todos los items de una solicitud pendiente. ### Request ```bash curl -X POST "https://api.redcumbre.cl/{tenantSlug}/autorizaciones-v2/{id}/rechazar" \ -H "Authorization: Bearer {api_key}" \ -H "Content-Type: application/json" \ -d '{ "comentario": "Descuento demasiado alto para este cliente" }' ``` ### Campos del Request | Campo | Tipo | Requerido | Descripción | |-------|------|:---------:|-------------| | `comentario` | string | No | Motivo del rechazo | ### Efectos - Todos los items pasan a estado `RECHAZADO` - La solicitud pasa a estado `RECHAZADA` - Si hay borrador BHE asociado, se marca como `RECHAZADO` - Se notifica al solicitante con el comentario - El solicitante puede editar el borrador y volver a solicitar autorización ### Response (200 OK) ```json { "id": "cm5solicitud123", "estado": "RECHAZADA", "autorizadorUserId": "supervisor-001", "autorizadorNombre": "María García", "resueltaAt": "2025-12-19T12:30:00Z", "items": [ { "id": "cm5item001", "estado": "RECHAZADO", "comentarioResolucion": "Descuento demasiado alto para este cliente" } ] } ``` --- ## Resolver Items Individualmente **Endpoint:** `POST /{tenantSlug}/autorizaciones-v2/{id}/resolver` Resuelve una solicitud aprobando o rechazando items de forma individual (resolución parcial). ### Request ```bash curl -X POST "https://api.redcumbre.cl/{tenantSlug}/autorizaciones-v2/{id}/resolver" \ -H "Authorization: Bearer {api_key}" \ -H "Content-Type: application/json" \ -d '{ "resoluciones": [ { "itemId": "cm5item001", "aprobado": true }, { "itemId": "cm5item002", "aprobado": false, "comentario": "Descuento excesivo" } ] }' ``` ### Campos de cada Resolución | Campo | Tipo | Requerido | Descripción | |-------|------|:---------:|-------------| | `itemId` | string | Sí | ID del item a resolver | | `aprobado` | boolean | Sí | `true` para aprobar, `false` para rechazar | | `comentario` | string | No | Comentario (típicamente para rechazos) | ### Estado Final | Resultado | Estado Solicitud | Estado Borrador BHE | |-----------|------------------|---------------------| | Todos aprobados | `APROBADA` | `AUTORIZADO` | | Todos rechazados | `RECHAZADA` | `RECHAZADO` | | Mixto | `PARCIAL` | `RECHAZADO` | :::caution[Resolución Parcial] Si la resolución es `PARCIAL`, el borrador BHE queda en estado `RECHAZADO`. El operador debe corregir los items rechazados y volver a solicitar autorización. ::: ### Response (200 OK) ```json { "id": "cm5solicitud123", "estado": "PARCIAL", "autorizadorUserId": "supervisor-001", "autorizadorNombre": "María García", "resueltaAt": "2025-12-19T12:30:00Z", "items": [ { "id": "cm5item001", "estado": "APROBADO", "comentarioResolucion": null }, { "id": "cm5item002", "estado": "RECHAZADO", "comentarioResolucion": "Descuento excesivo" } ] } ``` --- ## Integración con BHE (Borradores) Cuando un borrador BHE requiere autorización (ej: descuento excede el máximo), el flujo completo es: ``` Operador Redcumbre Supervisor │ │ │ │── POST /bhe/borradores ──▶│ │ │ (con descuento > max) │ │ │ │ │ │◀── Response ──────────────│ │ │ (requiereAutorizacion) │ │ │ │ │ │── POST /borradores/:id/ │ │ │ solicitar-autorizacion ▶│ │ │ │ │ │ [Borrador → │ │ │ EN_AUTORIZACION] │ │ │ │── Notifica ───────────────▶│ │ │ │ │ │◀── Aprueba ────────────────│ │ │ │ │◀── Notificación ──────────│ │ │ (AUTORIZADO) │ │ │ │ │ │── POST /borradores/:id/ │ │ │ emitir ────────────────▶│ │ │ │ │ │◀── BHE Emitida ───────────│ │ ``` ### La emisión directa no abre solicitudes: responde 400 El flujo de autorización cuelga de un **borrador**. Si emites directamente —sin pasar por un borrador— un documento cuyo descuento excede el máximo del producto, o cuyo precio queda por debajo del mínimo permitido, la respuesta es `400` y no se asigna folio: ```json { "statusCode": 400, "message": "El descuento de 25.0% sobre \"Vino Reserva\" excede el máximo permitido (10.0%). Guarde el documento como borrador y solicite autorización desde ahí." } ``` ```json { "statusCode": 400, "message": "El precio del producto \"Vino Reserva\" está por debajo del mínimo permitido. Mínimo: $800, enviado: $500." } ``` Para vender fuera de política, el camino es el de arriba: guardar como borrador, solicitar autorización y emitir una vez aprobado. :::note[Identidad de máquina] Con **API key** no se aplican ni el tope de descuento ni el precio mínimo. Un integrador que trae su propio sistema de precios gobierna el suyo, y el catálogo de Redcumbre no es su fuente de verdad: el precio que envías se persiste tal cual. ::: ### Estados del Borrador | Estado Borrador | Descripción | |-----------------|-------------| | `BORRADOR` | Editable, puede solicitar autorización | | `EN_AUTORIZACION` | Esperando aprobación | | `AUTORIZADO` | Aprobado, listo para emitir | | `RECHAZADO` | Rechazado, puede editarse y re-solicitar | | `EMITIDO` | BHE emitida exitosamente | --- ## Modo Sandbox (Testing) El modo sandbox permite probar la API sin persistir datos ni afectar el sistema real. ### Activación El modo sandbox se activa automáticamente cuando tu API Key tiene `isSandbox: true`. ### IDs de Prueba Usa estos IDs para probar diferentes escenarios: | ID | GET /:id | POST aprobar | POST rechazar | POST resolver | |----|----------|--------------|---------------|---------------| | `sandbox-sol-pending` | Estado PENDIENTE | ✅ Éxito | ✅ Éxito | ✅ Éxito | | `sandbox-sol-approved` | Estado APROBADA | ❌ Error 400 | ❌ Error 400 | ❌ Error 400 | | `sandbox-sol-rejected` | Estado RECHAZADA | ❌ Error 400 | ❌ Error 400 | ❌ Error 400 | | `sandbox-sol-partial` | Estado PARCIAL | ❌ Error 400 | ❌ Error 400 | ❌ Error 400 | | `sandbox-sol-expired` | Estado EXPIRADA | ❌ Error 400 | ❌ Error 400 | ❌ Error 400 | | `sandbox-sol-notfound` | ❌ Error 404 | ❌ Error 404 | ❌ Error 404 | ❌ Error 404 | ### ItemIds de Prueba Para usar con `POST /:id/resolver` y el ID `sandbox-sol-pending`: | ItemId | Descripción | |--------|-------------| | `sandbox-item-001` | Item tipo DESCUENTO_LINEA | | `sandbox-item-002` | Item tipo PRECIO_ESPECIAL_LINEA | ### Ejemplo en Sandbox ```bash # Crear solicitud (no persiste) curl -X POST "https://api.redcumbre.cl/{tenantSlug}/autorizaciones-v2" \ -H "Authorization: Bearer {sandbox_api_key}" \ -H "Content-Type: application/json" \ -d '{ "tipoDocumento": "BHE", "items": [{ "tipo": "DESCUENTO_LINEA", "alcance": "LINEA", "lineaId": "linea-001", "valorPermitido": 10, "valorSolicitado": 25, "unidad": "PORCENTAJE", "motivo": "Test sandbox" }] }' # Consultar solicitud pendiente curl -X GET "https://api.redcumbre.cl/{tenantSlug}/autorizaciones-v2/sandbox-sol-pending" \ -H "Authorization: Bearer {sandbox_api_key}" # Aprobar solicitud curl -X POST "https://api.redcumbre.cl/{tenantSlug}/autorizaciones-v2/sandbox-sol-pending/aprobar" \ -H "Authorization: Bearer {sandbox_api_key}" ``` --- ## Códigos de Error ### Errores de Validación (400) | Error | Descripción | |-------|-------------| | `Debe incluir al menos un item de autorización` | Array `items` vacío o no enviado | | `La solicitud ya fue aprobada/rechazada` | Intento de resolver solicitud no pendiente | | `La solicitud ha expirado` | Solicitud pasó su fecha de expiración | | `El flujo de autorización no está habilitado` | Tenant no tiene habilitada la funcionalidad | | `Item X no pertenece a esta solicitud` | ItemId inválido en resolución | ### Errores de Acceso (403) | Error | Descripción | |-------|-------------| | `No tienes acceso a esta solicitud` | Operador intentando ver solicitud de otro | | `Forbidden` | Rol sin permisos para la operación | ### Errores de Recurso (404) | Error | Descripción | |-------|-------------| | `Solicitud X no encontrada` | ID de solicitud no existe o no pertenece al tenant | --- ## Expiración Automática Las solicitudes pendientes expiran automáticamente según la configuración del tenant: | Configuración | Default | Descripción | |---------------|---------|-------------| | `timeoutAutorizacionMinutos` | 10080 (1 semana) | Tiempo máximo de espera | ### Proceso de Expiración 1. Un cron job verifica solicitudes pendientes con `expiresAt < now` 2. Los items se marcan como `RECHAZADO` con comentario "Solicitud expirada por timeout" 3. La solicitud pasa a estado `EXPIRADA` 4. Si hay borrador BHE asociado, se marca como `RECHAZADO` 5. Se notifica al solicitante --- ## API Reference Para detalles técnicos completos y especificaciones de todos los endpoints: 👉 [Ver endpoints de Autorizaciones en Swagger](https://api.redcumbre.cl/api-docs#/Autorizaciones%20de%20Documentos) --- # Boletas de Honorarios Electrónicas (BHE) Fuente: https://docs.redcumbre.cl/guias/boletas-honorarios/ :::note[Flujo disponible solo en la aplicación web] Los borradores, la solicitud de autorización, la clonación y el envío manual por email se operan desde la aplicación web de Redcumbre, no por API. La API cubre el flujo de emisión directa, la consulta y la descarga de documentos. ::: :::tip[TL;DR - Acceso Rápido] **Endpoint:** `POST /{tenantSlug}/bhe` 👉 [Ver endpoints de BHE en Swagger](https://api.redcumbre.cl/api-docs#/SII%20Boletas%20de%20Honorarios) ::: Las Boletas de Honorarios Electrónicas son documentos tributarios que emiten las **personas naturales** por servicios profesionales prestados. Nuestra API permite emitir BHE con diferentes modos de emisión, gestionar PDFs y recibir notificaciones vía webhook. --- ## Requisitos Previos Antes de emitir una BHE necesitas: 1. Un **Emisor Tributario** activo con **clave tributaria** delegada (no certificado digital) 2. El **`emisorTributarioId`** de ese emisor — va en el cuerpo de toda emisión 3. Una **API Key** con uno de estos roles: `ADMIN`, `SUPER-ADMIN`, `SII-EMISOR-BHE`, o `FULL-API` 4. Los datos del destinatario (o usar `sinDestinatario: true` para emitir sin destinatario) :::tip[¿De dónde sale el `emisorTributarioId`?] Lo devuelve el flujo de tokenización: viene como `emisorId` en la respuesta de `POST /{tenantSlug}/tokenizacion/exchange` y en el webhook `tokenizacion.completada`. También está visible en el panel web. Guárdalo al recibirlo — no hay endpoint de listado de emisores con API key. Ver la guía de [Emisores](/guias/emisores/). ::: :::note[Importante] Las BHE requieren **clave tributaria**, no certificado digital. Solo personas naturales pueden emitir BHE. ::: --- ## Modos de Emisión El sistema soporta **3 modos de emisión** que permiten balancear entre respuesta inmediata y tolerancia a fallas del SII. | Modo | Descripción | Respuesta | Recomendado | |------|-------------|-----------|-------------| | `SINC_COMPLETO` | Todo síncrono: emitir + PDF interno + PDF SII | BHE completa con ambos PDFs | Cuando necesitas todo inmediato | | `SINC_PARCIAL` | Emitir + PDF interno síncronos, PDF SII en background | BHE con folio + PDF interno | **Default recomendado** | | `ASINCRONO` | Solo valida y encola. Todo en background | ID de intento para consulta | Alta tolerancia a fallas | --- ### Flujo SINC_PARCIAL (Default) ``` Tu App Redcumbre SII │ │ │ │── POST /bhe ─────────────▶│ │ │ (modo: SINC_PARCIAL) │ │ │ │── Emitir BHE ─────────▶│ │ │◀── folioSii ──────────│ │ │ │ │ │── Genera PDF interno │ │ │ │ │◀── Response ──────────────│ │ │ (folioSii + pdfInternoUrl) │ │ (estadoPdfSii: PENDIENTE) │ │ │ │ │ [Background] │ │ │── Descarga PDF SII ───▶│ │ │◀── PDF ───────────────│ │ │ │ │◀── Webhook ───────────────│ │ │ (bhe.emision_terminada) │ │ ``` **Ventajas:** - Respuesta rápida con folio y PDF personalizado - PDF SII se descarga en background con reintentos automáticos (hasta 7 días) - Si el SII tiene problemas, ya tienes el folio y un PDF válido --- ### Flujo ASINCRONO ``` Tu App Redcumbre SII │ │ │ │── POST /bhe ─────────────▶│ │ │ (modo: ASINCRONO) │ │ │ │── Valida datos │ │ │── Encola emisión │ │ │ │ │◀── Response ──────────────│ │ │ (intentoId, estado: PENDIENTE) │ │ │ │ │ [Background] │ │ │── Emitir BHE ─────────▶│ │ │◀── folioSii ──────────│ │ │── Genera PDF interno │ │ │ │ │◀── Webhook ───────────────│ │ │ (bhe.emitida) │ │ ``` **Ventajas:** - Respuesta instantánea (solo valida y encola) - Reintentos automáticos si el SII falla, durante 18–22,6 horas (10 intentos con espaciado creciente desde 5 min, máximo 6 h entre uno y otro). Ver la [curva completa](/guias/webhooks#reintentos-de-la-emisión-asíncrona) - Ideal para procesos batch o cuando no necesitas el resultado inmediato **Consultar estado:** ```bash GET /{tenantSlug}/bhe/intento/{intentoId} ``` --- ## Modos de Destinatario Cuando `sinDestinatario=false`, existen **4 formas mutuamente excluyentes** de especificar el destinatario: | Modo | Campo | Descripción | Cobra SII Lookup | |------|-------|-------------|------------------| | **1** | `destinatarioId` | RUT de contribuyente guardado en sistema | No | | **2** | `destinatario` | Datos completos on-the-fly | No | | **3** | `siiLookup` | Objeto lookup SII ya obtenido | No | | **4** | `lookupRut` | Solo RUT, sistema ejecuta lookup al SII | **Sí** | :::note[Sobre el cobro] El cobro en esta tabla se refiere únicamente a la **consulta al SII** para obtener datos del contribuyente. Los modos 1-3 no generan consulta al SII porque ya tienes los datos. La **emisión de la BHE siempre se cobra** de acuerdo al plan contratado, independiente del modo de destinatario utilizado. ::: :::caution[Exclusividad] Solo puede especificarse **UN modo**. Si se envía más de uno, se retorna error `MULTIPLE_DESTINATARIO_MODES`. ::: --- ### Diagrama de Decisión ``` ¿Tienes datos completos del destinatario? ├── SÍ → ¿Están guardados en el sistema? │ ├── SÍ → Usar Modo 1 (destinatarioId) │ └── NO → Usar Modo 2 (destinatario) └── NO → ¿Ya hiciste lookup al SII previamente? ├── SÍ → Usar Modo 3 (siiLookup) └── NO → Usar Modo 4 (lookupRut) ⚠️ Cobra SII Lookup ``` --- ### Modo 1: `destinatarioId` (Contribuyente Guardado) El destinatario ya está guardado en el sistema (ContribuyenteMaestro). ```json { "emisorTributarioId": "tu-emisor-tributario-id", "sinDestinatario": false, "destinatarioId": "12345678-9", "prestaciones": [ { "descripcion": "Servicio de consultoría", "valor": "150000" } ], "tipoRetencion": "RETRECEPTOR" } ``` --- ### Modo 2: `destinatario` (Datos On-The-Fly) Pasas todos los datos manualmente sin necesidad de lookup. ```json { "emisorTributarioId": "tu-emisor-tributario-id", "sinDestinatario": false, "destinatario": { "rut": "12345678-9", "nombres": "EMPRESA CLIENTE SA", "direccion": "Av. Providencia 1234, Of. 501", "codigoRegion": "13", "codigoComuna": "13101", "email": "contacto@empresa.cl" }, "prestaciones": [ { "descripcion": "Servicio de consultoría", "valor": "150000" } ], "tipoRetencion": "RETRECEPTOR" } ``` **Campos del objeto `destinatario`:** | Campo | Tipo | Requerido | Descripción | |-------|------|-----------|-------------| | `rut` | string | Sí | RUT del destinatario (formato `12345678-9`) | | `nombres` | string | Sí | Razón social o nombre completo | | `direccion` | string | Sí | Dirección completa | | `codigoRegion` | string | Sí | Código de región SII (ej: `"13"`) | | `codigoComuna` | string | Sí | Código de comuna SII (ej: `"13101"`) | | `email` | string | No | Email para envío de boleta | --- ### Modo 3: `siiLookup` (Objeto Lookup Completo) Ya tienes el resultado de un lookup previo al SII. ```json { "emisorTributarioId": "tu-emisor-tributario-id", "sinDestinatario": false, "siiLookup": { "rut": "12345678-9", "razonSocial": "EMPRESA CLIENTE SA", "direcciones": [ { "direccion": "AV PROVIDENCIA 1234 OF 501", "codigoComuna": "13101", "comuna": "SANTIAGO" } ], "email": "contacto@empresa.cl" }, "prestaciones": [ { "descripcion": "Servicio de consultoría", "valor": "150000" } ], "tipoRetencion": "RETRECEPTOR" } ``` :::tip Usa este modo cuando emites múltiples BHE al mismo destinatario. El lookup se reutiliza sin costo adicional. ::: --- ### Modo 4: `lookupRut` (Ejecutar Lookup) Solo pasas el RUT y el sistema ejecuta el lookup al SII. ```json { "emisorTributarioId": "tu-emisor-tributario-id", "sinDestinatario": false, "lookupRut": "12345678-9", "prestaciones": [ { "descripcion": "Servicio de consultoría", "valor": "150000" } ], "tipoRetencion": "RETRECEPTOR" } ``` :::caution[Cobro por SII Lookup] Este modo **cobra por la consulta al SII** para obtener los datos del contribuyente. ::: --- ## Emitir Boleta ### Request Completo ```bash POST /{tenantSlug}/bhe Content-Type: application/json Authorization: Bearer tu-api-key ``` ```json { "emisorTributarioId": "tu-emisor-tributario-id", "sinDestinatario": false, "destinatario": { "rut": "12345678-9", "nombres": "EMPRESA CLIENTE SA", "direccion": "Av. Providencia 1234, Of. 501", "codigoRegion": "13", "codigoComuna": "13101", "email": "contacto@empresa.cl" }, "prestaciones": [ { "descripcion": "Servicio de consultoría en sistemas", "valor": "150000" }, { "descripcion": "Desarrollo de software", "valor": "75000" } ], "tipoRetencion": "RETRECEPTOR", "modo": "SINC_PARCIAL", "enviarBoletaPorEmail": true, "correlationId": "mi-referencia-123" } ``` **Campos del Request:** | Campo | Tipo | Requerido | Descripción | |-------|------|-----------|-------------| | `emisorTributarioId` | string | Sí | ID del emisor tributario | | `sinDestinatario` | boolean | Sí | `true` para emitir sin destinatario | | `prestaciones` | array | Sí | Lista de servicios (mín 1, máx 4) | | `tipoRetencion` | enum | Sí | `RETRECEPTOR` o `RETCONTRIBUYENTE`. Con `sinDestinatario: true` solo se acepta `RETCONTRIBUYENTE` (ver [Tipos de Retención](#tipos-de-retención)) | | `validarCatalogo` | boolean | No | Forzar validación contra catálogo de productos | | `modo` | enum | No | `SINC_COMPLETO`, `SINC_PARCIAL` (default), `ASINCRONO` | | `enviarBoletaPorEmail` | boolean | No | Enviar PDF al email del destinatario | | `templateId` | string | No | Template con que se representa el PDF interno de esta boleta. Si no lo envías, se usa el de tu empresa (ver [Elegir el template del PDF interno](#elegir-el-template-del-pdf-interno)) | | `correlationId` | string | No | ID de correlación para tracking. **No es una clave**: puedes repetirlo entre boletas distintas | | `idempotencyKey` | string | No | Clave de idempotencia, máx. 128 caracteres. Protege contra emisiones duplicadas por reintentos (ver [Idempotencia](#idempotencia)) | | `fechaEmision` | string | No | Fecha de emisión personalizada (ver sección siguiente) | --- ## Idempotencia Si tu sistema puede reintentar una emisión —por timeout, error de red o doble click— usa `idempotencyKey` para que un reintento no genere una segunda boleta ante el SII. ```json { "emisorTributarioId": "cm5abc123", "idempotencyKey": "orden-compra-4471", "prestaciones": [{ "descripcion": "Asesoría contable", "valor": "150000" }], "tipoRetencion": "RETRECEPTOR" } ``` **Cómo funciona:** - El alcance de la clave es **por emisor tributario**: dos emisores pueden usar el mismo valor sin colisionar. - Si repites la clave y ya hay una boleta emitida con ella, se retorna **esa misma boleta** —mismo `id`, mismo `folioSii`— sin volver a emitir ante el SII ni consumir un folio nuevo. - En ese caso se **vuelve a disparar el webhook** `bhe.emitida`, porque si repetiste la clave lo más probable es que no hayas recibido la notificación anterior. - Si la emisión **falló**, la clave no queda reservada: puedes corregir el dato que el SII rechazó y reenviar con la misma clave. - Si la boleta se **anula**, la clave se libera y puede volver a usarse. - En modo `ASINCRONO` la protección cubre también la emisión **en vuelo**: si reintentas mientras la primera todavía está en la cola o entre reintentos, recibes el `intentoId` original en vez de encolar una segunda. Con la ventana de reintentos de 18–22,6 h, esa protección puede estar activa durante horas — usa `idempotencyKey` si tu sistema reintenta por su cuenta. :::tip[No uses `correlationId` para esto] `correlationId` es solo trazabilidad y no tiene ninguna restricción de unicidad — puedes mandar el mismo valor en cien boletas distintas. La protección contra duplicados la da `idempotencyKey`, y son campos independientes que puedes usar juntos. ::: --- ## Fecha de Emisión Personalizada Por defecto, las boletas se emiten con la **fecha actual del servidor**. Sin embargo, en casos excepcionales puedes especificar una fecha anterior usando el campo `fechaEmision`. :::caution[Uso Especial] Este campo debe usarse **solo en casos excepcionales**: - Regularización de servicios prestados en fechas pasadas - Corrección de emisiones pendientes por problemas técnicos **El usuario es responsable** de cumplir con los plazos legales del SII para emisión de documentos tributarios. ::: ### Formato El campo acepta formato ISO `YYYY-MM-DD`: ```json { "emisorTributarioId": "tu-emisor-tributario-id", "sinDestinatario": false, "destinatarioId": "12345678-9", "prestaciones": [ { "descripcion": "Servicio de consultoría", "valor": "150000" } ], "tipoRetencion": "RETRECEPTOR", "fechaEmision": "2025-12-01" } ``` ### Restricciones | Restricción | Descripción | |-------------|-------------| | **No futuras** | La fecha no puede ser posterior a hoy | | **Sin validación de plazos** | No se valida contra plazos legales del SII | | **Fecha oficial** | El SII registra esta fecha como fecha oficial del documento | ### Comportamiento en PDFs | Campo en PDF | Valor | |--------------|-------| | **Fecha de Emisión** | La fecha especificada (o fecha actual si no se especificó) | | **Fecha de Impresión** | Siempre la fecha actual al generar/regenerar el PDF | Esto permite identificar cuándo se prestó el servicio vs cuándo se generó el documento físico. **Campos de `prestaciones[]`:** Cada prestación puede especificarse de **dos formas mutuamente excluyentes**: | Modo | Campos Permitidos | Campos Prohibidos | |------|-------------------|-------------------| | **Item libre** | `descripcion`, `valor` | `productoId` | | **Producto del catálogo** | `productoId`, `cantidad`, `descuentoPorcentaje` | `descripcion`, `valor`, `precioUnitario`, `unidadMedida`, `codigoSku` | **Con productoId:** - Solo enviar: `productoId` (requerido), `cantidad` (default: 1), `descuentoPorcentaje` (opcional, 0-100%) - El sistema calcula automáticamente: precio, descripción, unidad de medida, valor total - Si envías campos prohibidos, recibirás error 400 **Sin productoId (item libre):** - Enviar: `descripcion` (requerido, máx 80 chars), `valor` (requerido, string numérico en CLP) - No hay validación de catálogo :::caution[Exclusividad Mutua: Precio vs Descuento] Un producto puede tener **O** precio modificado **O** descuento, **nunca ambos simultáneamente**. - Si envías `precioUnitario`, el `descuentoPorcentaje` debe ser 0 o no enviarse - Si envías `descuentoPorcentaje`, el `precioUnitario` no debe enviarse ::: **Cuándo se permite `precioUnitario` con productos del catálogo:** | Condición del Producto | ¿Permite `precioUnitario`? | |------------------------|:--------------------------:| | `esquemaPrecio: SIN_PRECIO` | Sí (obligatorio) | | `permisoEdicion: LIBRE` | Sí | | `permisoEdicion: DENTRO_RANGO_DESCUENTO` | Sí (dentro del rango) | | `permisoEdicion: NO_PERMITIDA` | No | --- ## Emisión con Productos del Catálogo :::note[El catálogo se administra desde la aplicación web] Crear, editar y consultar productos y grupos se hace en la aplicación web de Redcumbre. La API usa el catálogo pero no lo administra: acá solo referencias un `productoId` que ya existe. ::: El sistema soporta **dos modos** de especificar prestaciones: ### Modo 1: Items Libres (Sin Catálogo) Especifica `descripcion` y `valor` directamente. Ideal para servicios únicos o personalizados. ```json { "prestaciones": [ { "descripcion": "Servicio de consultoría", "valor": "150000" } ] } ``` ### Modo 2: Productos del Catálogo Usa `productoId` para referenciar un producto configurado en el catálogo. El sistema calcula precios, descuentos y cantidades automáticamente. ```json { "prestaciones": [ { "productoId": "clxyz123abc", "cantidad": 3, "descuentoPorcentaje": 10 } ] } ``` :::tip[Modo Mixto] Puedes combinar ambos modos en una misma emisión: ```json { "prestaciones": [ { "productoId": "clxyz123abc", "cantidad": 2 }, { "descripcion": "Servicio adicional personalizado", "valor": "75000" } ] } ``` ::: --- ### Validaciones del Catálogo Cuando usas `productoId`, el sistema valida automáticamente: | Validación | Descripción | |------------|-------------| | Existencia | El producto debe existir y pertenecer al tenant | | Estado | El producto debe estar en estado `ACTIVO` | | Tipo | El producto debe ser tipo `SERVICIO` (no PRODUCTO) | | ILA | El producto NO debe tener impuesto adicional (ILA) | | Visibilidad | El producto debe tener `visibleEnBhe: true` | | Descuento | El `descuentoPorcentaje` no puede exceder el máximo configurado en el producto | :::note[Cálculo Automático] Cuando usas `productoId`, el sistema calcula automáticamente el precio, descripción y unidad de medida desde el catálogo. No necesitas enviar estos campos. ::: --- ### Response: Modo Síncrono (SINC_COMPLETO / SINC_PARCIAL) :::caution[La boleta NO está en la raíz de `data` — cuelga de `data.bhe`] `data` es el **resultado de la emisión**, no la boleta. Ahí viven `modo`, `descargas`, `mensaje`, `correlationId` y `pdfInternoUrl`; la boleta va anidada en **`data.bhe`**. El folio se lee en `data.bhe.folioSii`, no en `data.folioSii`. Leerlo del lugar equivocado no falla: devuelve `undefined` sobre una emisión que sí funcionó. ::: ```json { "success": true, "data": { "modo": "SINC_PARCIAL", "descargas": { "pdfInterno": "https://app.redcumbre.cl/acme/tributario/boletas-honorarios/cm5abc123def456/descargar?tipo=interno", "pdfSii": "https://app.redcumbre.cl/acme/tributario/boletas-honorarios/cm5abc123def456/descargar?tipo=sii" }, "bhe": { "id": "cm5abc123def456", "folioSii": "12345678", "codigoBarras": "1234567800388FB4702C", "estado": "EMITIDA", "estadoPdfSii": "PENDIENTE", "modoEmision": "SINC_PARCIAL", "pdfInternoUrl": "https://app.redcumbre.cl/p/boleta/eyJhbGciOiJIUzI1NiJ9...", "montoBruto": 210000, "montoLiquido": 177975 }, "correlationId": "mi-referencia-123", "mensaje": "BHE emitida. PDF SII en proceso de descarga (se notificará via webhook)", "pdfInternoUrl": "https://app.redcumbre.cl/p/boleta/eyJhbGciOiJIUzI1NiJ9..." } } ``` **`SINC_COMPLETO` responde lo mismo**, con cuatro diferencias: `modo` y `bhe.modoEmision` valen `SINC_COMPLETO`; `mensaje` es `"BHE emitida exitosamente"` en vez del de más arriba; y si el respaldo del SII alcanzó a descargarse dentro del request aparece `data.pdfSiiUrl` (también en `data.bhe.pdfSiiUrl`) con `bhe.estadoPdfSii: "DISPONIBLE"`. Si no alcanzó, queda en `PENDIENTE` y se descarga en background, igual que en `SINC_PARCIAL`. :::caution[No decidas nada leyendo `mensaje`] `mensaje` es texto para mostrar, no un código de estado: cambia entre modos y puede cambiar de redacción. Para ramificar usa `modo`, `bhe.estado` y `bhe.estadoPdfSii`. ::: :::note[Lo que la respuesta de emisión NO trae] Emisor, destinatario, prestaciones, `tipoRetencion`, fechas y **el `ppm` retenido** no vienen acá: la respuesta de emisión es acotada a propósito. El documento completo, con el desglose del PPM, se obtiene con `GET /{tenantSlug}/bhe/{id}`. Si necesitas el PPM sin una segunda llamada, es `montoBruto - montoLiquido`. El `montoLiquido` del ejemplo está calculado con la tasa de PPM vigente en **2026** (15,25%). Esa resta te da el PPM de **esa** boleta; para conocer la tasa por adelantado usa `GET /global/ppm`. ::: :::tip[Dos canales de descarga, para dos públicos distintos] `pdfInternoUrl` / `pdfSiiUrl` son enlaces **públicos**: sirven para cualquier destinatario, y piden RUT + CAPTCHA antes de entregar el documento. `descargas` son enlaces para **usuarios de Redcumbre con sesión**: descargan directo, sin gate, y quedan registrados en el historial de la boleta. Ver [Enlaces de descarga para tus usuarios](#enlaces-de-descarga-para-tus-usuarios). ::: --- ### Response: Modo ASINCRONO ```json { "success": true, "data": { "modo": "ASINCRONO", "intentoId": "cm5xyz789abc123", "correlationId": "mi-referencia-123", "estado": "PENDIENTE", "mensaje": "Emisión encolada para procesamiento. Consulte el estado con GET /bhe/intento/{intentoId}" } } ``` El bloque `descargas` **no viene** en esta respuesta: todavía no existe el id de la boleta, y las URLs se calculan a partir de él. Llega en `GET /bhe/intento/{intentoId}` una vez emitida, y en el webhook `bhe.emitida`. --- ### Response: el SII rechaza la emisión Cuando el SII no acepta la boleta, **la BHE igual se persiste** en estado `ERROR` con su motivo, para que quede consultable y auditable. Lo que cambia es cómo te lo comunicamos, y eso depende de quién llama: | Quien emite | Qué recibe | |---|---| | La aplicación web de Redcumbre (sesión de usuario) | `201` con `success: false` — el formulario conserva lo escrito y muestra el motivo | | **Tu integración (API key)** | Un status de error HTTP según la causa: `422`, `502` o `503` | **Tu integración nunca recibe un `2xx` por una boleta rechazada.** No tienes que inspeccionar `success` para saber si se emitió: si la respuesta es `2xx`, se emitió. ```json { "statusCode": 422, "message": "El contribuyente no registra actividades de segunda categoría en el SII…", "error": "Unprocessable Entity", "errorCode": "SII_SIN_SEGUNDA_CATEGORIA", "boletaId": "cm5abc123def456" } ``` | Campo | Para qué sirve | |---|---| | `errorCode` | Lo único que conviene programar: es estable y clasifica la causa | | `message` | Texto legible, apto para mostrarle a tu usuario | | `boletaId` | El id de la fila que **sí** se persistió: `GET /{tenantSlug}/bhe/{boletaId}` responde `200` con la boleta en `ERROR` | #### Qué hacer con cada status | Status | Significa | Qué hacer | |---|---|---| | `422` | El SII entendió la solicitud y la rechazó por su contenido o por el estado del contribuyente | **No reintentar** sin corregir. Reintentar produce el mismo rechazo | | `502` | El SII respondió algo que no se puede usar (error interno suyo, sesión caída, servicio degradado) | Reintentar con backoff exponencial | | `503` | El emisor tiene demasiadas emisiones en curso ante el SII y hay que esperar turno | Reintentar **después** de los segundos que indica el header `Retry-After` | | `500` | Un defecto nuestro. Dos formas, según el `errorCode` | Ver abajo. No es un problema de tu request | ##### El `500` tiene dos formas, y una de ellas **no se reintenta** | `errorCode` | Qué pasó | Qué hacer | |---|---|---| | *(ausente)* | La emisión falló con un motivo que la plataforma no tiene clasificado. La boleta quedó persistida en `ERROR` | Reportarlo con el `boletaId` | | `BHE_EMITIDA_NO_PERSISTIDA` | **La boleta SÍ se emitió ante el SII** y la plataforma no pudo guardarla. El cuerpo trae `folioSii` en vez de `boletaId` | **No reintentar**: emitirías una segunda boleta real. Reportar el `folioSii` a soporte | Es el único caso en que un error **no** significa "no se emitió". :::caution[La idempotencia es tu red de seguridad] Un reintento sin `idempotencyKey` puede emitir una boleta duplicada y quemar un folio. Ver [Idempotencia](#idempotencia) antes de armar tu loop de reintentos. ::: --- ## Consultar Estado Asíncrono Para el modo `ASINCRONO`, consulta el estado del intento: ```bash GET /{tenantSlug}/bhe/intento/{intentoId} Authorization: Bearer tu-api-key ``` ### Response ```json { "success": true, "data": { "id": "cm5xyz789abc123", "estado": "EMITIDA", "bhe": { "id": "cm5abc123xyz", "folioSii": "123456789", "codigoBarras": "ABC123...", "estado": "EMITIDA", "estadoPdfSii": "DISPONIBLE", "pdfInternoUrl": "https://...", "pdfSiiUrl": "https://...", "descargas": { "pdfInterno": "https://app.redcumbre.cl/acme/tributario/boletas-honorarios/cm5abc123xyz/descargar?tipo=interno", "pdfSii": "https://app.redcumbre.cl/acme/tributario/boletas-honorarios/cm5abc123xyz/descargar?tipo=sii" } }, "intentos": 1, "ultimoIntento": "2025-12-08T15:30:00Z", "correlationId": "mi-referencia-123", "createdAt": "2025-12-08T15:00:00Z", "updatedAt": "2025-12-08T15:30:00Z" } } ``` Acá el intento va en `data` y la boleta en `data.bhe`, igual que en la emisión síncrona. Mientras el intento no llegue a `EMITIDA`, `data.bhe` no viene. **Estados del intento:** | Estado | Descripción | |--------|-------------| | `PENDIENTE` | Registrado en cola, pendiente de procesamiento | | `PROCESANDO` | En proceso de emisión | | `EMITIDA` | Emitido exitosamente (tiene BHE asociada) | | `REINTENTANDO` | Un intento falló y el sistema va a volver a intentarlo. **No es un desenlace**: todavía hay algo que esperar | | `FALLIDA` | Falló definitivamente: se agotaron los 10 intentos (18–22,6 h) o el error del SII no se recupera reintentando | --- ## Gestión de PDFs El sistema genera **dos tipos de PDF**: | Tipo | Descripción | Disponibilidad | |------|-------------|----------------| | **PDF Interno** | Generado con template personalizado del tenant | Inmediato (modo síncrono) | | **PDF SII** | Respaldo oficial descargado del SII | Depende de disponibilidad del SII | --- ### Elegir el template del PDF interno El PDF interno se representa con un **template**. Si no eliges ninguno, se usa el que tenga configurado tu empresa y, en su defecto, el estándar de la plataforma — que es lo que ocurre hoy si nunca tocaste esto. Para elegirlo explícitamente, manda `templateId` en la emisión: ```json { "emisorTributarioId": "cms6b8jtx0001sekpi26f8am7", "prestaciones": [{ "descripcion": "Servicio de consultoría", "valor": "150000" }], "templateId": "tpl_bhe_002" } ``` El identificador **queda fijo en la boleta**. Eso significa que toda representación posterior de ese documento usa el mismo template, incluido el comprobante con la marca de anulación que se genera después. Un cambio en la configuración de tu empresa NO altera boletas ya emitidas. :::caution[El template elegido también viaja por correo] El PDF interno es el que se adjunta al correo del destinatario cuando usas `enviarBoletaPorEmail`. Si eliges un formato de rollo térmico para imprimir en mostrador, el destinatario recibe ese mismo formato por correo. ::: Un `templateId` que no exista, o que pertenezca a otro tipo de documento, responde **400** y la boleta **no se emite**. No se resuelve en silencio a otro template. **Templates disponibles:** | Identificador | Formato | Uso típico | |---|---|---| | `tpl_bhe_001` | Carta | Default de la plataforma | | `tpl_bhe_002` | Rollo 80 mm | Impresora térmica de mostrador | | `tpl_bhe_003` | Rollo 57 mm | Impresora POS | El template con que se generó cada archivo viene en `templateId` dentro de `GET /{tenantSlug}/bhe/{id}/archivos`. Tu empresa puede tener templates propios además de estos: los ves en la interfaz web, en el detalle de la boleta. --- ### Estados del PDF SII | Estado | Descripción | |--------|-------------| | `NO_APLICA` | Modo SINC_COMPLETO exitoso (ya tiene PDF) o boleta no emitida | | `PENDIENTE` | En cola para descarga | | `DISPONIBLE` | Descargado exitosamente | | `FALLIDO` | Agotó reintentos después de 7 días | --- ### Descargar PDF ```bash # PDF Interno (template personalizado) GET /{tenantSlug}/bhe/{id}/pdf/interno # PDF SII (respaldo oficial) GET /{tenantSlug}/bhe/{id}/pdf/sii # Archivo específico del panel de archivos GET /{tenantSlug}/bhe/{id}/archivos/{archivoId}/download # PDF generado al momento con otro template (no se guarda) GET /{tenantSlug}/bhe/{id}/pdf/custom?templateId=tpl_bhe_002 ``` Todos **streamean el archivo** como `application/pdf` (`Content-Disposition: attachment`). No son redirects y nunca entregan una URL de bucket. Si el documento pedido no existe todavía, responden `404` con el motivo: ```json { "success": false, "error": { "code": "PDF_SII_NOT_AVAILABLE", "message": "El PDF SII de esta boleta no está disponible. Estado: PENDIENTE", "estadoPdfSii": "PENDIENTE" } } ``` Usa `error.estadoPdfSii` para distinguir "todavía se está generando" (`PENDIENTE`, reintenta más tarde) de "falló definitivamente" (`FALLIDO`, usa el PDF interno). :::caution[Modo sandbox] Con una API key en modo sandbox estos endpoints devuelven un **PDF de ejemplo**, nunca el documento real, aunque el id corresponda a una boleta real. ::: --- ### Enlaces de descarga para tus usuarios Si tu sistema tiene una interfaz propia y tus usuarios **también son usuarios de Redcumbre**, el bloque `descargas` te da un enlace listo para pintar: ```html Descargar boleta Respaldo SII ``` | Campo | Para qué sirve | |-------|----------------| | `descargas.pdfInterno` | Enlace al PDF interno. Descarga directa, sin RUT ni CAPTCHA | | `descargas.pdfSii` | Ídem para el respaldo SII. **Viene siempre**, aunque el documento aún no exista | | `estadoPdfSii` | Semáforo: con `DISPONIBLE` el respaldo ya está; con `PENDIENTE` el enlace muestra "se está generando" | **Cómo funcionan:** - Son enlaces de **navegación** (``, `window.location`). **No funcionan con `fetch` ni `axios`** desde tu dominio: la cookie de sesión de Redcumbre es `SameSite=Lax` y no viaja en peticiones XHR cross-site. - Requieren un usuario con sesión en Redcumbre y rol del módulo (`SUPER-ADMIN`, `ADMIN`, `SII-EMISOR-BHE` o `FULL-API`). Si no hay sesión, el enlace pasa por el login y vuelve solo al documento. - La descarga queda registrada en el historial de actividad de la boleta. - Son estables: se calculan desde el id y sobreviven a la regeneración del PDF. :::note[¿Tus usuarios no tienen cuenta en Redcumbre?] Entonces estos enlaces no les sirven. Usa `pdfInternoUrl` / `pdfSiiUrl`, que son públicos y piden RUT del receptor + CAPTCHA. Ambos canales vienen en la misma respuesta. ::: --- ### Listar Archivos PDF ```bash GET /{tenantSlug}/bhe/{id}/archivos ``` El campo `url` de cada archivo es el enlace para el **destinatario** de la boleta: abre la página pública de descarga, que le pide su RUT antes de entregar el archivo, y es el mismo enlace que recibe por correo. No es una URL de bucket. Para descargar el archivo desde tu integración usa `GET /{tenantSlug}/bhe/{id}/archivos/{archivoId}/download`. **Response:** ```json { "success": true, "data": [ { "id": "archivo-001", "tipo": "INTERNO", "templateId": "template-001", "templateName": "Template Carta", "fileName": "bhe_12345678_interno.pdf", "url": "https://...", "sizeBytes": 125000, "createdAt": "2025-12-05T15:30:00Z", "createdBy": "Sistema" }, { "id": "archivo-002", "tipo": "SII", "templateId": null, "templateName": null, "fileName": "bhe_12345678_sii.pdf", "url": "https://...", "sizeBytes": 98000, "createdAt": "2025-12-05T15:35:00Z", "createdBy": "SII" } ] } ``` --- ## Listar y Consultar ### Listar Boletas ```bash GET /{tenantSlug}/bhe?fechaDesde=2025-01-01&estado=EMITIDA&limit=50 Authorization: Bearer tu-api-key ``` **Filtros disponibles:** | Parámetro | Tipo | Descripción | |-----------|------|-------------| | `fechaDesde` | ISO 8601 | Fecha de inicio del rango | | `fechaHasta` | ISO 8601 | Fecha de fin del rango | | `emisorTributarioId` | string | Filtrar por emisor | | `destinatarioRut` | string | Filtrar por RUT del destinatario | | `estado` | enum | `EMITIDA`, `ANULADA`, `ERROR` | | `canalEmision` | enum | `UI`, `API_SYNC`, `API_ASYNC` | | `folioSii` | string | Buscar por folio (substring) | | `limit` | number | Registros por página (default: 50, max: 100) | | `offset` | number | Registros a saltar (default: 0) | **Response:** ```json { "data": [ { /* BheResponseDto */ } ], "total": 100, "limit": 50, "offset": 0 } ``` --- ### Obtener Detalle ```bash GET /{tenantSlug}/bhe/{id} Authorization: Bearer tu-api-key ``` Es el documento **completo**, y es más de lo que devuelve la emisión: emisor, destinatario, `prestaciones` con su desglose de descuentos, `tipoRetencion`, el `ppm` retenido, fechas, `descargas` y el estado de una eventual anulación. Todo eso llega en `data` (acá sí, plano — la boleta **es** el recurso). El listado de campos con sus tipos está en [Swagger](https://api.redcumbre.cl/api-docs#/SII%20Boletas%20de%20Honorarios). --- ## Webhooks El sistema envía webhooks para 4 eventos relacionados con BHE: | Evento | Descripción | Cuándo se dispara | |--------|-------------|-------------------| | `bhe.emitida` | BHE emitida exitosamente | Inmediatamente después de emitir | | `bhe.emision_terminada` | PDF SII disponible | Cuando se descarga el PDF SII en background | | `bhe.pdf_sii_fallido` | Descarga PDF SII falló | Después de 7 días de reintentos fallidos | | `bhe.emision_reintentando` | Un intento falló y va a reintentarse | En cada intento intermedio — informativo, no reemitas | | `bhe.emision_fallida` | Emisión asíncrona falló definitivamente | **Una sola vez**: al agotar los 10 intentos, o en el primero si el error es permanente | --- ### Payload: `bhe.emitida` ```json { "event": "bhe.emitida", "tenantId": "8", "bheId": "cm5abc123", "folioSii": "12345678", "emisor": { "rut": "78012039-8", "razonSocial": "JUAN PÉREZ GONZÁLEZ" }, "destinatario": { "rut": "12345678-9", "nombre": "EMPRESA CLIENTE SA", "sinDestinatario": false }, "montos": { "bruto": 210000, "ppm": 32025, "liquido": 177975 }, "tipoRetencion": "RETRECEPTOR", "canalEmision": "API_SYNC", "correlationId": "mi-referencia-123", "sandbox": false, "estadoPdfSii": "PENDIENTE", "descargas": { "pdfInterno": "https://app.redcumbre.cl/acme/tributario/boletas-honorarios/cm5abc123/descargar?tipo=interno", "pdfSii": "https://app.redcumbre.cl/acme/tributario/boletas-honorarios/cm5abc123/descargar?tipo=sii" }, "timestamp": "2025-12-05T15:30:00Z" } ``` --- ### Payload: `bhe.emision_terminada` Se envía cuando el PDF SII está disponible (solo para modos SINC_PARCIAL y ASINCRONO): ```json { "event": "bhe.emision_terminada", "tenantId": "8", "bheId": "cm5abc123", "folioSii": "12345678", "pdfInternoUrl": "https://app.redcumbre.cl/p/boleta/eyJhbGciOiJIUzI1NiJ9...", "pdfSiiUrl": "https://app.redcumbre.cl/p/boleta/eyJhbGciOiJIUzI1NiJ9...", "estadoPdfSii": "DISPONIBLE", "descargas": { "pdfInterno": "https://app.redcumbre.cl/acme/tributario/boletas-honorarios/cm5abc123/descargar?tipo=interno", "pdfSii": "https://app.redcumbre.cl/acme/tributario/boletas-honorarios/cm5abc123/descargar?tipo=sii" }, "timestamp": "2025-12-05T15:35:00Z" } ``` --- ### Payload: `bhe.pdf_sii_fallido` Se envía después de 7 días de reintentos fallidos: ```json { "event": "bhe.pdf_sii_fallido", "tenantId": "8", "bheId": "cm5abc123", "folioSii": "12345678", "intentosRealizados": 30, "primerIntento": "2025-12-01T15:30:00Z", "ultimoIntento": "2025-12-08T15:30:00Z", "ultimoError": "SII_PDF_NO_DISPONIBLE", "descargas": { "pdfInterno": "https://app.redcumbre.cl/acme/tributario/boletas-honorarios/cm5abc123/descargar?tipo=interno" }, "timestamp": "2025-12-08T15:30:00Z" } ``` `descargas` trae solo `pdfInterno`: el respaldo SII no llegó a existir. --- ### Payload: `bhe.emision_fallida` Se envía **una sola vez**, cuando se agotan los 10 intentos (18–22,6 h) o cuando el error del SII no se recupera reintentando. Los intentos intermedios viajan en [`bhe.emision_reintentando`](/guias/webhooks#bheemision_reintentando). ```json { "event": "bhe.emision_fallida", "tenantId": "8", "correlationId": "mi-referencia-123", "intentosRealizados": 10, "primerIntento": "2025-12-08T10:00:00Z", "ultimoIntento": "2025-12-09T08:36:00Z", "ultimoError": "SII_SERVICE_UNAVAILABLE", "timestamp": "2025-12-09T08:36:00Z" } ``` --- ## Cálculo de Montos El sistema calcula automáticamente los montos: ``` Monto Bruto = Suma de todas las prestaciones PPM = Monto Bruto × tasa de PPM (redondeado al peso) Monto Líquido = Monto Bruto - PPM ``` :::caution[No hardcodees la tasa de PPM] La tasa de PPM **cambia por año tributario**. Consúltala en `GET /global/ppm`, que la devuelve como fracción y como porcentaje: ```json { "success": true, "data": { "ppmRate": 0.1525, "ppmRatePercent": 15.25 } } ``` El valor del ejemplo es el vigente en **2026**; el endpoint devuelve el que rija al momento de consultarlo. Lo mismo aplica a la retención de las BHET, en `GET /global/bhet-retencion`. Ambos endpoints viven bajo `/global/` y piden cualquier API Key válida, sin exigir un rol en particular. Si calculas el líquido para mostrárselo al usuario **antes** de confirmar una emisión —que es irreversible—, esa pantalla tiene que usar la tasa del endpoint, no una constante en tu código. ::: :::note[Las emisiones fechadas en 2025 usan la tasa de 2025] Si envías `fechaEmision` con una fecha de **2025**, la boleta se calcula con la tasa fija de ese año (14,5%). Para cualquier otra fecha —incluido omitir el campo, que toma el día de hoy— se aplica la tasa vigente, la misma que devuelve `GET /global/ppm`. Es una excepción puntual para 2025, no una tabla de tasas por año: no asumas que una fecha de otro año pasado va a recuperar la tasa que regía entonces. El PPM efectivamente retenido en una boleta ya emitida se lee en `ppm` del detalle (`GET /{tenantSlug}/bhe/{id}`), o como `montoBruto - montoLiquido` sobre la respuesta de emisión. ::: ### Tipos de Retención | Tipo | Descripción | |------|-------------| | `RETRECEPTOR` | El receptor/pagador retiene el PPM (más común) | | `RETCONTRIBUYENTE` | El emisor retiene su propio PPM | :::caution[Boletas sin destinatario] Cuando emites con `sinDestinatario: true`, el único valor aceptado es `RETCONTRIBUYENTE`: si no hay receptor, no hay quién retenga el PPM. Enviar `RETRECEPTOR` en ese caso devuelve un `400` con el código `RETENCION_INVALIDA_SIN_DESTINATARIO`. Con `sinDestinatario: false` ambos valores son válidos y la elección es tuya: la determina el acuerdo con el pagador, no la plataforma. ::: --- ## Códigos de Error ### Errores de Validación (400) A diferencia de los códigos del SII, estos **no** llegan en un campo `errorCode`: viajan como prefijo del `message`, en la forma `CODIGO: detalle`. | Código | Descripción | |--------|-------------| | `MULTIPLE_DESTINATARIO_MODES` | Se especificó más de un modo de destinatario | | `RETENCION_INVALIDA_SIN_DESTINATARIO` | Se envió `tipoRetencion: RETRECEPTOR` junto con `sinDestinatario: true` | | `DESTINATARIO_REQUIRED` | No se especificó ningún modo (y `sinDestinatario=false`) | | `CONTRIBUYENTE_NOT_FOUND` | RUT no existe en ContribuyenteMaestro (modo 1) | | `INVALID_COMUNA_CODE` | `codigoComuna` no existe en catálogo (modo 2) | | `SII_LOOKUP_FAILED` | Lookup al SII falló (modo 4) | ### Errores del SII El `errorCode` llega en el cuerpo del error (canal de máquina) y en `data.bhe.errorCode` (canal web). El status te dice si conviene reintentar sin tener que interpretar el código. **No reintentables — corrige antes de volver a intentar (`422`)** | Código | Descripción | |--------|-------------| | `SII_CREDENCIAL_INVALIDA` | El SII rechazó la clave tributaria del emisor con un motivo explícito (incorrecta, vencida, o cuenta bloqueada). El motivo viene en `message` | | `EMISOR_CREDENCIAL_INVALIDA` | La credencial del emisor ya estaba marcada como inválida: la plataforma corta sin llamar al SII. El emisor recibe un correo para actualizarla | | `SII_SIN_INICIO_ACTIVIDADES` | El contribuyente no registra inicio de actividades | | `SII_SIN_SEGUNDA_CATEGORIA` | Contribuyente sin actividades de segunda categoría | | `SII_PERSONA_JURIDICA` | Las BHE solo son para personas naturales | | `SII_NO_HABILITADO` | Contribuyente no habilitado para emitir BHE | | `SII_RUT_INVALIDO` | RUT del emisor o destinatario no válido | | `SII_DATOS_INVALIDOS` | Datos de la boleta incorrectos | | `SII_FORM_ERROR` | El formulario del SII rechazó los datos enviados | | `SII_CERTIFICADO_ERROR` | Error con certificado digital | | `SII_EMISION_FAILED` | El SII rechazó la emisión con un mensaje propio (viene en `message`) | **Reintentables con backoff — el problema es del SII (`502`)** | Código | Descripción | |--------|-------------| | `SII_AUTH_FAILED` | El SII no devolvió TOKEN y no explicó por qué. Suele ser un problema pasajero de su lado | | `SII_ERROR` | Error genérico del SII durante la emisión | | `SII_RESPUESTA_INESPERADA` | El SII respondió algo que no se pudo interpretar | | `SII_ERROR_INTERNO` | Error interno declarado por el SII (código `IMT…`) | | `SII_SOLICITUD_FALLIDA` | "No ha sido posible completar su solicitud" | | `SII_SESION_EXPIRADA` | La sesión con el SII expiró durante la operación | | `SII_SERVICIO_NO_DISPONIBLE` | El servicio del SII no está disponible | **Reintentable con espera — hay turno por delante (`503`)** | Código | Descripción | |--------|-------------| | `SII_EMISOR_BUSY` | Demasiadas emisiones en curso para ese emisor. Respeta el header `Retry-After` | ### Errores de Recurso (404) | Código | Descripción | |--------|-------------| | `PDF_NOT_AVAILABLE` | No hay PDF disponible para descargar | | `PDF_SII_NOT_AVAILABLE` | PDF SII no disponible (revisa `estadoPdfSii`) | --- ## Estados de la Boleta | Estado | Descripción | |--------|-------------| | `EMITIDA` | Emitida correctamente en el SII (tiene folio y código de barras) | | `ANULADA` | Boleta anulada | | `ERROR` | Error inmediato no reintentable | --- ## Testing (Sandbox) Utiliza el **modo sandbox** para probar la integración sin costo: - API Keys con `isSandbox: true` retornan datos de prueba - No se realizan llamadas reales al SII - No se genera billing - Webhooks funcionan normalmente - **No sale ningún correo al destinatario**, ni al emitir con `enviarBoletaPorEmail: true`, ni al reenviar el PDF manualmente desde la aplicación web. La supresión se decide por el folio de la boleta, así que una boleta emitida en sandbox no despacha correo nunca, tampoco meses después desde el panel. Ver [Entornos y modo sandbox](/primeros-pasos/entornos/). :::caution[El sandbox no te ahorra dar de alta un emisor] El `emisorTributarioId` es obligatorio también en sandbox, y tiene que ser un emisor activo y **de tu propio tenant**. Un id que no existe, que pertenece a otro tenant o que fue revocado responde igual: `404 No se encontró un emisor tributario válido`. No hay identificador comodín ni emisor de prueba compartido. El sandbox tampoco relaja la configuración de ese emisor: en BHE se verifica igual que en producción que tenga su credencial cargada y el servicio `BHE` habilitado. Lo único que el sandbox reemplaza es la llamada al SII. Da de alta tu emisor con [Tokenización SII](/guias/emisores/) antes de probar. ::: --- ### RUTs de Prueba | RUT | Resultado | Descripción | |-----|-----------|-------------| | `78012039-8` | ✅ Éxito | Empresa completa (FIRERAISE SPA) | | `77425402-1` | ✅ Éxito | Empresa con múltiples direcciones | | `13830230-k` | ✅ Éxito | Persona natural (PDF SII pendiente) | | `99999999-9` | ❌ Error | Destinatario no existe en SII | **Ejemplo en sandbox:** ```bash POST /{tenantSlug}/bhe Authorization: Bearer sandbox-api-key { "emisorTributarioId": "tu-emisor-tributario-id", "sinDestinatario": false, "lookupRut": "78012039-8", "prestaciones": [ { "descripcion": "Servicio de prueba", "valor": "100000" } ], "tipoRetencion": "RETRECEPTOR" } ``` **Response (sandbox):** ```json { "success": true, "data": { "id": "sandbox-bhe-001", "folioSii": "SANDBOX-12345678", "codigoBarras": "SANDBOX7801203988FB4702C...", "estado": "EMITIDA", ... }, "sandbox": true } ``` --- ## API Reference Para detalles técnicos completos y especificaciones de todos los endpoints: 👉 [Ver endpoints de BHE en Swagger](https://api.redcumbre.cl/api-docs#/SII%20Boletas%20de%20Honorarios) --- ## ¿Necesitas anular una BHE? La anulación ante el SII tiene su propia guía: causas, seguimiento por webhook, comprobante al destinatario y límites de plazo. 👉 [Anulación de Boletas](/guias/anulacion-boletas) --- # Boletas de Honorarios de Terceros (BHET) Fuente: https://docs.redcumbre.cl/guias/boletas-terceros/ :::note[Flujo disponible solo en la aplicación web] Los borradores, la solicitud de autorización, la clonación y el envío manual por email se operan desde la aplicación web de Redcumbre, no por API. La API cubre el flujo de emisión directa, la consulta y la descarga de documentos. ::: :::tip[TL;DR - Acceso Rápido] **Endpoint:** `POST /{tenantSlug}/bhet` 👉 [Ver endpoints de BHET en Swagger](https://api.redcumbre.cl/api-docs#/SII%20Boletas%20de%20Terceros) ::: Las Boletas de Honorarios de Terceros son documentos tributarios que emiten las **empresas** cuando contratan servicios de **terceros** (personas naturales o jurídicas). A diferencia de las BHE donde el emisor presta el servicio, en las BHET el emisor es quien **contrata y paga**, mientras que el tercero es quien **presta el servicio**. --- ## Diferencias con BHE | Aspecto | BHE | BHET | |---------|-----|------| | Emisor | Persona natural (presta servicio) | Empresa (contrata servicio) | | Receptor | Destinatario (recibe servicio) | Tercero (presta servicio) | | Retención | PPM — tasa vigente en `GET /global/ppm` | Retención — tasa vigente en `GET /global/bhet-retencion` | | Sin receptor | `sinDestinatario` soportado | **Siempre requiere tercero** | | Campo monto | `montoLiquido` | `montoNeto` | | Campo impuesto | `ppm` | `impuesto` | --- ## Requisitos Previos Antes de emitir una BHET necesitas: 1. Un **Emisor Tributario** activo con **clave tributaria** delegada y servicio `BOLETAS_TERCEROS` habilitado 2. El **`emisorTributarioId`** de ese emisor — va en el cuerpo de toda emisión 3. Una **API Key** con uno de estos roles: `ADMIN`, `SUPER-ADMIN`, `SII-EMISOR-BHET`, o `FULL-API` 4. Los datos del tercero (siempre requerido, no existe opción sin tercero) :::tip[¿De dónde sale el `emisorTributarioId`?] Lo devuelve el flujo de tokenización: viene como `emisorId` en la respuesta de `POST /{tenantSlug}/tokenizacion/exchange` y en el webhook `tokenizacion.completada`. También está visible en el panel web. Guárdalo al recibirlo — no hay endpoint de listado de emisores con API key. Ver la guía de [Emisores](/guias/emisores/). ::: :::note[Importante] Las BHET requieren **clave tributaria**, no certificado digital. El emisor debe ser una empresa (persona jurídica) con el servicio BHET habilitado. ::: --- ## Modos de Emisión El sistema soporta **3 modos de emisión** que permiten balancear entre respuesta inmediata y tolerancia a fallas del SII. | Modo | Descripción | Respuesta | Recomendado | |------|-------------|-----------|-------------| | `SINC_COMPLETO` | Todo síncrono: emitir + PDF interno + PDF SII | BHET completa con ambos PDFs | Cuando necesitas todo inmediato | | `SINC_PARCIAL` | Emitir + PDF interno síncronos, PDF SII en background | BHET con folio + PDF interno | **Default recomendado** | | `ASINCRONO` | Solo valida y encola. Todo en background | ID de intento para consulta | Alta tolerancia a fallas | --- ### Flujo SINC_PARCIAL (Default) ``` Tu App Redcumbre SII │ │ │ │── POST /bhet ────────────▶│ │ │ (modo: SINC_PARCIAL) │ │ │ │── Emitir BHET ────────▶│ │ │◀── folioSii ──────────│ │ │ │ │ │── Genera PDF interno │ │ │ │ │◀── Response ──────────────│ │ │ (folioSii + pdfInternoUrl) │ │ (estadoPdfSii: PENDIENTE) │ │ │ │ │ [Background] │ │ │── Descarga PDF SII ───▶│ │ │◀── PDF ───────────────│ │ │ │ │◀── Webhook ───────────────│ │ │ (bhet.emision_terminada)│ │ ``` **Ventajas:** - Respuesta rápida con folio y PDF personalizado - PDF SII se descarga en background con reintentos automáticos (hasta 7 días) - Si el SII tiene problemas, ya tienes el folio y un PDF válido --- ### Flujo ASINCRONO ``` Tu App Redcumbre SII │ │ │ │── POST /bhet ────────────▶│ │ │ (modo: ASINCRONO) │ │ │ │── Valida datos │ │ │── Encola emisión │ │ │ │ │◀── Response ──────────────│ │ │ (intentoId, estado: PENDIENTE) │ │ │ │ │ [Background] │ │ │── Emitir BHET ────────▶│ │ │◀── folioSii ──────────│ │ │── Genera PDF interno │ │ │ │ │◀── Webhook ───────────────│ │ │ (bhet.emitida) │ │ ``` **Ventajas:** - Respuesta instantánea (solo valida y encola) - Reintentos automáticos si el SII falla, durante 18–22,6 horas (10 intentos con espaciado creciente desde 5 min, máximo 6 h entre uno y otro). Ver la [curva completa](/guias/webhooks#reintentos-de-la-emisión-asíncrona) - Ideal para procesos batch o cuando no necesitas el resultado inmediato **Consultar estado:** ```bash GET /{tenantSlug}/bhet/intento/{intentoId} ``` --- ## Modos de Tercero Existen **4 formas mutuamente excluyentes** de especificar el tercero: | Modo | Campo | Descripción | Cobra SII Lookup | |------|-------|-------------|------------------| | **1** | `terceroId` | ID o RUT de contribuyente guardado en sistema | No | | **2** | `tercero` | Datos completos on-the-fly | No | | **3** | `siiLookup` | Objeto lookup SII ya obtenido | No | | **4** | `lookupRut` | Solo RUT, sistema ejecuta lookup al SII | **Sí** | :::note[Sobre el cobro] El cobro en esta tabla se refiere únicamente a la **consulta al SII** para obtener datos del contribuyente. Los modos 1-3 no generan consulta al SII porque ya tienes los datos. La **emisión de la BHET siempre se cobra** de acuerdo al plan contratado, independiente del modo de tercero utilizado. ::: :::caution[Exclusividad] Solo puede especificarse **UN modo**. Si se envía más de uno, se retorna error `MULTIPLE_TERCERO_MODES`. ::: --- ### Diagrama de Decisión ``` ¿Tienes datos completos del tercero? ├── SÍ → ¿Están guardados en el sistema? │ ├── SÍ → Usar Modo 1 (terceroId) │ └── NO → Usar Modo 2 (tercero) └── NO → ¿Ya hiciste lookup al SII previamente? ├── SÍ → Usar Modo 3 (siiLookup) └── NO → Usar Modo 4 (lookupRut) ⚠️ Cobra SII Lookup ``` --- ### Modo 1: `terceroId` (Contribuyente Guardado) El tercero ya está guardado en el sistema (ContribuyenteMaestro). Puedes usar el ID o el RUT. ```json { "emisorTributarioId": "tu-emisor-tributario-id", "terceroId": "78012039-8", "prestaciones": [ { "descripcion": "Servicio de consultoría", "valor": "150000" } ] } ``` --- ### Modo 2: `tercero` (Datos On-The-Fly) Pasas todos los datos manualmente sin necesidad de lookup. ```json { "emisorTributarioId": "tu-emisor-tributario-id", "tercero": { "rut": "78012039-8", "nombres": "FIRERAISE SPA", "domicilio": "Av. Providencia 1234, Of. 501", "codigoRegion": "13", "codigoComuna": "13101", "email": "contacto@fireraise.cl" }, "prestaciones": [ { "descripcion": "Desarrollo de software", "valor": "250000" } ] } ``` **Campos del objeto `tercero`:** | Campo | Tipo | Requerido | Descripción | |-------|------|-----------|-------------| | `rut` | string | Sí | RUT del tercero (formato `12345678-9`) | | `nombres` | string | Sí | Razón social o nombre completo | | `domicilio` | string | Sí | Dirección completa | | `codigoRegion` | string | Sí | Código de región SII (ej: `"13"`) | | `codigoComuna` | string | Sí | Código de comuna SII (ej: `"13101"`) | | `email` | string | No | Email para envío de boleta | --- ### Modo 3: `siiLookup` (Objeto Lookup Completo) Ya tienes el resultado de un lookup previo al SII. ```json { "emisorTributarioId": "tu-emisor-tributario-id", "siiLookup": { "rut": "78012039-8", "razonSocial": "FIRERAISE SPA", "direcciones": [ { "direccion": "AV PROVIDENCIA 1234 OF 501", "codigoComuna": "13101", "comuna": "SANTIAGO" } ], "email": "contacto@fireraise.cl" }, "prestaciones": [ { "descripcion": "Consultoría estratégica", "valor": "180000" } ] } ``` :::tip Usa este modo cuando emites múltiples BHET al mismo tercero. El lookup se reutiliza sin costo adicional. ::: --- ### Modo 4: `lookupRut` (Ejecutar Lookup) Solo pasas el RUT y el sistema ejecuta el lookup al SII. ```json { "emisorTributarioId": "tu-emisor-tributario-id", "lookupRut": "78012039-8", "prestaciones": [ { "descripcion": "Asesoría legal", "valor": "200000" } ] } ``` :::caution[Cobro por SII Lookup] Este modo **cobra por la consulta al SII** para obtener los datos del contribuyente. ::: --- ## Emitir Boleta ### Request Completo ```bash POST /{tenantSlug}/bhet Content-Type: application/json Authorization: Bearer tu-api-key ``` ```json { "emisorTributarioId": "tu-emisor-tributario-id", "tercero": { "rut": "78012039-8", "nombres": "FIRERAISE SPA", "domicilio": "Av. Providencia 1234, Of. 501", "codigoRegion": "13", "codigoComuna": "13101", "email": "contacto@fireraise.cl" }, "prestaciones": [ { "descripcion": "Servicio de consultoría en sistemas", "valor": "150000" }, { "descripcion": "Desarrollo de software", "valor": "75000" } ], "modo": "SINC_PARCIAL", "enviarBoletaPorEmail": true, "correlationId": "mi-referencia-123" } ``` **Campos del Request:** | Campo | Tipo | Requerido | Descripción | |-------|------|-----------|-------------| | `emisorTributarioId` | string | Sí | ID del emisor tributario | | `prestaciones` | array | Sí | Lista de servicios (mín 1) | | `modo` | enum | No | `SINC_COMPLETO`, `SINC_PARCIAL` (default), `ASINCRONO` | | `enviarBoletaPorEmail` | boolean | No | Enviar PDF al email del tercero | | `templateId` | string | No | Template con que se representa el PDF interno de esta boleta. Si no lo envías, se usa el de tu empresa (ver [Elegir el template del PDF interno](#elegir-el-template-del-pdf-interno)) | | `correlationId` | string | No | ID de correlación para tracking. **No es una clave**: puedes repetirlo entre boletas distintas | | `idempotencyKey` | string | No | Clave de idempotencia, máx. 128 caracteres. Protege contra emisiones duplicadas por reintentos (ver [Idempotencia](#idempotencia)) | | `fechaEmision` | string | No | Fecha de emisión personalizada (ver sección siguiente) | --- ## Idempotencia Si tu sistema puede reintentar una emisión —por timeout, error de red o doble click— usa `idempotencyKey` para que un reintento no genere una segunda boleta ante el SII. ```json { "emisorTributarioId": "cm5abc123", "idempotencyKey": "orden-compra-4471", "tercero": { "rut": "12345678-9", "nombres": "Juan Pérez", "domicilio": "Av. Principal 123", "codigoRegion": "13", "codigoComuna": "13101" }, "prestaciones": [{ "descripcion": "Servicio de diseño", "valor": "150000" }] } ``` **Cómo funciona:** - El alcance de la clave es **por emisor tributario**: dos emisores pueden usar el mismo valor sin colisionar. - Si repites la clave y ya hay una boleta emitida con ella, se retorna **esa misma boleta** —mismo `id`, mismo `folioSii`— sin volver a emitir ante el SII ni consumir un folio nuevo. - En ese caso se **vuelve a disparar el webhook** `bhet.emitida`, porque si repetiste la clave lo más probable es que no hayas recibido la notificación anterior. - Si la emisión **falló**, la clave no queda reservada: puedes corregir el dato que el SII rechazó y reenviar con la misma clave. - Si la boleta se **anula**, la clave se libera y puede volver a usarse. - En modo `ASINCRONO` la protección cubre también la emisión **en vuelo**: si reintentas mientras la primera todavía está en la cola o entre reintentos, recibes el `intentoId` original en vez de encolar una segunda. Con la ventana de reintentos de 18–22,6 h, esa protección puede estar activa durante horas — usa `idempotencyKey` si tu sistema reintenta por su cuenta. :::tip[No uses `correlationId` para esto] `correlationId` es solo trazabilidad y no tiene ninguna restricción de unicidad — puedes mandar el mismo valor en cien boletas distintas. La protección contra duplicados la da `idempotencyKey`, y son campos independientes que puedes usar juntos. ::: --- ## Fecha de Emisión Personalizada Por defecto, las boletas se emiten con la **fecha actual del servidor**. Sin embargo, en casos excepcionales puedes especificar una fecha anterior usando el campo `fechaEmision`. :::caution[Uso Especial] Este campo debe usarse **solo en casos excepcionales**: - Regularización de servicios prestados en fechas pasadas - Corrección de emisiones pendientes por problemas técnicos **El usuario es responsable** de cumplir con los plazos legales del SII para emisión de documentos tributarios. ::: ### Formato El campo acepta formato ISO `YYYY-MM-DD`: ```json { "emisorTributarioId": "tu-emisor-tributario-id", "terceroId": "78012039-8", "prestaciones": [ { "descripcion": "Servicio de consultoría", "valor": "150000" } ], "fechaEmision": "2025-12-01" } ``` ### Restricciones | Restricción | Descripción | |-------------|-------------| | **No futuras** | La fecha no puede ser posterior a hoy | | **Sin validación de plazos** | No se valida contra plazos legales del SII | | **Fecha oficial** | El SII registra esta fecha como fecha oficial del documento | ### Comportamiento en PDFs | Campo en PDF | Valor | |--------------|-------| | **Fecha de Emisión** | La fecha especificada (o fecha actual si no se especificó) | | **Fecha de Impresión** | Siempre la fecha actual al generar/regenerar el PDF | Esto permite identificar cuándo se prestó el servicio vs cuándo se generó el documento físico. **Campos de `prestaciones[]`:** Cada prestación puede especificarse de **dos formas mutuamente excluyentes**: | Modo | Campos Permitidos | Campos Prohibidos | |------|-------------------|-------------------| | **Item libre** | `descripcion`, `valor` | `productoId` | | **Producto del catálogo** | `productoId`, `cantidad`, `descuentoPorcentaje` | `descripcion`, `valor`, `precioUnitario`, `unidadMedida`, `codigoSku` | :::caution[Exclusividad Mutua: Precio vs Descuento] Un producto puede tener **O** precio modificado **O** descuento, **nunca ambos simultáneamente**. - Si envías `precioUnitario`, el `descuentoPorcentaje` debe ser 0 o no enviarse - Si envías `descuentoPorcentaje`, el `precioUnitario` no debe enviarse ::: **Cuándo se permite `precioUnitario` con productos del catálogo:** | Condición del Producto | ¿Permite `precioUnitario`? | |------------------------|:--------------------------:| | `esquemaPrecio: SIN_PRECIO` | Sí (obligatorio) | | `permisoEdicion: LIBRE` | Sí | | `permisoEdicion: DENTRO_RANGO_DESCUENTO` | Sí (dentro del rango) | | `permisoEdicion: NO_PERMITIDA` | No | --- ## Emisión con Productos del Catálogo :::note[El catálogo se administra desde la aplicación web] Crear, editar y consultar productos y grupos se hace en la aplicación web de Redcumbre. La API usa el catálogo pero no lo administra: acá solo referencias un `productoId` que ya existe. ::: ### Modo 1: Items Libres (Sin Catálogo) Especifica `descripcion` y `valor` directamente. Ideal para servicios únicos o personalizados. ```json { "prestaciones": [ { "descripcion": "Servicio de consultoría", "valor": "150000" } ] } ``` ### Modo 2: Productos del Catálogo Usa `productoId` para referenciar un producto configurado en el catálogo. El sistema calcula precios, descuentos y cantidades automáticamente. ```json { "prestaciones": [ { "productoId": "clxyz123abc", "cantidad": 3, "descuentoPorcentaje": 10 } ] } ``` :::tip[Modo Mixto] Puedes combinar ambos modos en una misma emisión: ```json { "prestaciones": [ { "productoId": "clxyz123abc", "cantidad": 2 }, { "descripcion": "Servicio adicional personalizado", "valor": "75000" } ] } ``` ::: --- ### Response: Modo Síncrono (SINC_COMPLETO / SINC_PARCIAL) :::caution[La boleta NO está en la raíz de `data` — cuelga de `data.bhet`] `data` es el **resultado de la emisión**, no la boleta. Ahí viven `modo`, `descargas`, `mensaje`, `correlationId` y `pdfInternoUrl`; la boleta va anidada en **`data.bhet`**. El folio se lee en `data.bhet.folioSii`, no en `data.folioSii`. Leerlo del lugar equivocado no falla: devuelve `undefined` sobre una emisión que sí funcionó. ::: ```json { "success": true, "data": { "modo": "SINC_PARCIAL", "descargas": { "pdfInterno": "https://app.redcumbre.cl/acme/tributario/boletas-terceros/cm5abc123def456/descargar?tipo=interno", "pdfSii": "https://app.redcumbre.cl/acme/tributario/boletas-terceros/cm5abc123def456/descargar?tipo=sii" }, "bhet": { "id": "cm5abc123def456", "folioSii": "12345678", "codigoBarras": "1234567800388FB4702C", "estado": "EMITIDA", "estadoPdfSii": "PENDIENTE", "modoEmision": "SINC_PARCIAL", "pdfInternoUrl": "https://app.redcumbre.cl/p/boleta/eyJhbGciOiJIUzI1NiJ9...", "montoBruto": 150000, "tasaImpuesto": 15.25, "impuesto": 22875, "montoNeto": 127125, "terceroNombreSii": "FIRERAISE SPA" }, "correlationId": "mi-referencia-123", "mensaje": "BHET emitida exitosamente", "pdfInternoUrl": "https://app.redcumbre.cl/p/boleta/eyJhbGciOiJIUzI1NiJ9..." } } ``` El `tasaImpuesto` del ejemplo —y por lo tanto el `impuesto` y el `montoNeto`— corresponde a la tasa vigente en **2026** (15,25%). **No la copies como constante**: viene en cada respuesta, y también puedes pedirla por adelantado en `GET /global/bhet-retencion` (ver [Cálculo de montos](#cálculo-de-montos)). **`SINC_COMPLETO` responde lo mismo**, con cuatro diferencias: `modo` y `bhet.modoEmision` valen `SINC_COMPLETO`; `mensaje` deja de ser `"BHET emitida exitosamente"` y pasa a describir el estado del respaldo (`"BHET emitida exitosamente con respaldo SII disponible"` si alcanzó a quedar listo, `"BHET emitida. El respaldo SII se está generando (se notificará via webhook)"` si no); y si alcanzó a quedar listo dentro del request aparece `data.pdfSiiUrl` (también en `data.bhet.pdfSiiUrl`) con `bhet.estadoPdfSii: "DISPONIBLE"`. Si no alcanzó, queda en `PENDIENTE` y se genera en background, igual que en `SINC_PARCIAL`. :::caution[No decidas nada leyendo `mensaje`] `mensaje` es texto para mostrar, no un código de estado: cambia entre modos y puede cambiar de redacción. Para ramificar usa `modo`, `bhet.estado` y `bhet.estadoPdfSii`. ::: :::note[Lo que la respuesta de emisión NO trae] Emisor, tercero, prestaciones y fechas **no vienen acá**: la respuesta de emisión es acotada a propósito. El documento completo se obtiene con `GET /{tenantSlug}/bhet/{id}`. ::: :::tip[Dos canales de descarga, para dos públicos distintos] `pdfInternoUrl` / `pdfSiiUrl` son enlaces **públicos**: sirven para cualquier destinatario, y piden RUT + CAPTCHA antes de entregar el documento. `descargas` son enlaces para **usuarios de Redcumbre con sesión**: descargan directo, sin gate, y quedan registrados en el historial de la boleta. Ver [Enlaces de descarga para tus usuarios](#enlaces-de-descarga-para-tus-usuarios). ::: --- ### Response: Modo ASINCRONO El modo `ASINCRONO` **valida y encola**: la respuesta llega antes de que la boleta exista. Trae un `intentoId` para consultar el avance. ```json { "success": true, "data": { "modo": "ASINCRONO", "intentoId": "cm5xyz789abc123", "correlationId": "mi-referencia-123", "estado": "PENDIENTE", "mensaje": "Emisión encolada para procesamiento. Consulte el estado con GET /bhet/intento/{intentoId}" } } ``` El bloque `descargas` **no viene** en esta respuesta: todavía no existe el id de la boleta, y las URLs se calculan a partir de él. Llega en `GET /bhet/intento/{intentoId}` una vez emitida, y en el webhook `bhet.emitida`. --- ### Response: el SII rechaza la emisión :::caution[Cambio de contrato — si ya tienes una integración, léelo] Hasta ahora un rechazo del SII respondía **`400`** con este cuerpo: ```json { "success": false, "error": { "code": "SII_EMISOR_BUSY", "message": "…" }, "bhetId": "cm5…" } ``` Ahora el status **distingue la causa** (`422`, `502` o `503`) y el cuerpo usa el formato estándar de la plataforma: `error` pasa a ser texto y aparece `message` en la raíz. Si tu código lee `error.code`, muévelo a `errorCode`; si distingue el fallo por `status === 400`, ese chequeo deja de dispararse. ::: Cuando el SII no acepta la boleta, **la BHET igual se persiste** en estado `ERROR` con su motivo, para que quede consultable y auditable. Cómo te lo comunicamos depende de quién llama: | Quien emite | Qué recibe | |---|---| | La aplicación web de Redcumbre (sesión de usuario) | `201` con `success: false` — el formulario conserva lo escrito y muestra el motivo | | **Tu integración (API key)** | Un status de error HTTP según la causa: `422`, `502` o `503` | **Tu integración nunca recibe un `2xx` por una boleta rechazada.** Si la respuesta es `2xx`, se emitió. ```json { "statusCode": 422, "message": "El tercero no es válido para recibir una boleta de terceros…", "error": "Unprocessable Entity", "errorCode": "SII_TERCERO_INVALIDO", "boletaId": "cm5abc123def456" } ``` | Campo | Para qué sirve | |---|---| | `errorCode` | Lo único que conviene programar: es estable y clasifica la causa | | `message` | Texto legible, apto para mostrarle a tu usuario | | `boletaId` | El id de la fila que **sí** se persistió: `GET /{tenantSlug}/bhet/{boletaId}` responde `200` con la boleta en `ERROR` | #### Qué hacer con cada status | Status | Significa | Qué hacer | |---|---|---| | `422` | El SII entendió la solicitud y la rechazó por su contenido o por el estado del contribuyente | **No reintentar** sin corregir | | `502` | El SII respondió algo que no se puede usar (error interno suyo, sesión caída, servicio degradado) | Reintentar con backoff exponencial | | `503` | El emisor tiene demasiadas emisiones en curso ante el SII y hay que esperar turno | Reintentar **después** de los segundos del header `Retry-After` | | `500` | Un defecto nuestro. Dos formas, según el `errorCode` | Ver abajo | ##### El `500` tiene dos formas, y una de ellas **no se reintenta** | `errorCode` | Qué pasó | Qué hacer | |---|---|---| | *(ausente)* | La emisión falló con un motivo que la plataforma no tiene clasificado. La boleta quedó persistida en `ERROR` | Reportarlo con el `boletaId` | | `BHET_EMITIDA_NO_PERSISTIDA` | **La boleta SÍ se emitió ante el SII** y la plataforma no pudo guardarla. El cuerpo trae `folioSii` y `codigoBarras` en vez de `boletaId` | **No reintentar**: emitirías una segunda boleta real. Reportar el `folioSii` a soporte | ```json { "statusCode": 500, "message": "La boleta se emitió ante el SII con el folio 489, pero la plataforma no pudo guardarla…", "error": "Internal Server Error", "errorCode": "BHET_EMITIDA_NO_PERSISTIDA", "folioSii": "489", "codigoBarras": "77438768004898E5E12E" } ``` Es el único caso en que un error **no** significa "no se emitió". Por eso el cuerpo trae el folio: es el documento que ya existe ante el SII. :::caution[La idempotencia es tu red de seguridad] Un reintento sin `idempotencyKey` puede emitir una boleta duplicada y quemar un folio. Ver [Idempotencia](#idempotencia) antes de armar tu loop de reintentos. ::: --- ## Consultar Estado Asíncrono Para el modo `ASINCRONO`, consulta el estado del intento: ```bash GET /{tenantSlug}/bhet/intento/{intentoId} Authorization: Bearer tu-api-key ``` ### Response ```json { "success": true, "data": { "id": "cm5xyz789abc123", "estado": "EMITIDA", "bhet": { "id": "cm5abc123xyz", "folioSii": "123456789", "codigoBarras": "ABC123...", "estado": "EMITIDA", "estadoPdfSii": "DISPONIBLE", "pdfInternoUrl": "https://...", "pdfSiiUrl": "https://...", "descargas": { "pdfInterno": "https://app.redcumbre.cl/acme/tributario/boletas-terceros/cm5abc123xyz/descargar?tipo=interno", "pdfSii": "https://app.redcumbre.cl/acme/tributario/boletas-terceros/cm5abc123xyz/descargar?tipo=sii" } }, "intentos": 1, "ultimoIntento": "2025-12-08T15:30:00Z", "correlationId": "mi-referencia-123", "createdAt": "2025-12-08T15:00:00Z", "updatedAt": "2025-12-08T15:30:00Z" } } ``` Acá el intento va en `data` y la boleta en `data.bhet`, igual que en la emisión síncrona. Mientras el intento no llegue a `EMITIDA`, `data.bhet` no viene. **Estados del intento:** | Estado | Descripción | |--------|-------------| | `PENDIENTE` | Registrado en cola, pendiente de procesamiento | | `PROCESANDO` | En proceso de emisión | | `EMITIDA` | Emitido exitosamente (tiene BHET asociada) | | `REINTENTANDO` | Un intento falló y el sistema va a volver a intentarlo. **No es un desenlace**: todavía hay algo que esperar | | `FALLIDA` | Falló definitivamente: se agotaron los 10 intentos (18–22,6 h) o el error del SII no se recupera reintentando | --- ## Gestión de PDFs El sistema genera **dos tipos de PDF**: | Tipo | Descripción | Disponibilidad | |------|-------------|----------------| | **PDF Interno** | Generado con template personalizado del tenant | Inmediato (modo síncrono) | | **PDF SII** | Respaldo oficial descargado del SII | Depende de disponibilidad del SII | --- ### Elegir el template del PDF interno El PDF interno se representa con un **template**. Si no eliges ninguno, se usa el que tenga configurado tu empresa y, en su defecto, el estándar de la plataforma — que es lo que ocurre hoy si nunca tocaste esto. Para elegirlo explícitamente, manda `templateId` en la emisión: ```json { "emisorTributarioId": "cmrkobzro001psetmqwfyo7py", "prestaciones": [{ "descripcion": "Servicio prestado", "valor": "150000" }], "templateId": "tpl_bhet_002" } ``` El identificador **queda fijo en la boleta**. Eso significa que toda representación posterior de ese documento usa el mismo template, incluido el comprobante con la marca de anulación que se genera después. Un cambio en la configuración de tu empresa NO altera boletas ya emitidas. :::caution[El template elegido también viaja por correo] El PDF interno es el que se adjunta al correo del tercero cuando usas `enviarBoletaPorEmail`. Si eliges un formato de rollo térmico para imprimir en mostrador, el tercero recibe ese mismo formato por correo. ::: Un `templateId` que no exista, o que pertenezca a otro tipo de documento, responde **400** y la boleta **no se emite**. No se resuelve en silencio a otro template. **Templates disponibles:** | Identificador | Formato | Uso típico | |---|---|---| | `tpl_bhet_001` | Carta | Default de la plataforma | | `tpl_bhet_002` | Rollo 80 mm | Impresora térmica de mostrador | | `tpl_bhet_003` | Rollo 57 mm | Impresora POS | El template con que se generó cada archivo viene en `templateId` dentro de `GET /{tenantSlug}/bhet/{id}/archivos`. Tu empresa puede tener templates propios además de estos: los ves en la interfaz web, en el detalle de la boleta. --- ### Estados del PDF SII | Estado | Descripción | |--------|-------------| | `NO_APLICA` | Modo SINC_COMPLETO exitoso o sandbox | | `PENDIENTE` | En cola para descarga | | `DISPONIBLE` | Descargado exitosamente | | `FALLIDO` | Agotó reintentos después de 7 días | --- ### Descargar PDF ```bash # PDF Interno (template personalizado) GET /{tenantSlug}/bhet/{id}/pdf/interno # PDF SII (respaldo oficial) GET /{tenantSlug}/bhet/{id}/pdf/sii # Archivo específico del panel de archivos GET /{tenantSlug}/bhet/{id}/archivos/{archivoId}/download # PDF generado al momento con otro template (no se guarda) GET /{tenantSlug}/bhet/{id}/pdf/custom?templateId=tpl_bhet_002 ``` Todos **streamean el archivo** como `application/pdf` (`Content-Disposition: attachment`). No son redirects y nunca entregan una URL de bucket. Si el documento pedido no existe todavía, responden `404` con el motivo: ```json { "success": false, "error": { "code": "PDF_SII_NOT_AVAILABLE", "message": "El PDF SII de esta boleta no está disponible. Estado: PENDIENTE", "estadoPdfSii": "PENDIENTE" } } ``` Usa `error.estadoPdfSii` para distinguir "todavía se está generando" (`PENDIENTE`, reintenta más tarde) de "falló definitivamente" (`FALLIDO`, usa el PDF interno). :::caution[Modo sandbox] Con una API key en modo sandbox estos endpoints devuelven un **PDF de ejemplo**, nunca el documento real, aunque el id corresponda a una boleta real. ::: --- ### Enlaces de descarga para tus usuarios Si tu sistema tiene una interfaz propia y tus usuarios **también son usuarios de Redcumbre**, el bloque `descargas` te da un enlace listo para pintar: ```html Descargar boleta Respaldo SII ``` | Campo | Para qué sirve | |-------|----------------| | `descargas.pdfInterno` | Enlace al PDF interno. Descarga directa, sin RUT ni CAPTCHA | | `descargas.pdfSii` | Ídem para el respaldo SII. **Viene siempre**, aunque el documento aún no exista | | `estadoPdfSii` | Semáforo: con `DISPONIBLE` el respaldo ya está; con `PENDIENTE` el enlace muestra "se está generando" | **Cómo funcionan:** - Son enlaces de **navegación** (``, `window.location`). **No funcionan con `fetch` ni `axios`** desde tu dominio: la cookie de sesión de Redcumbre es `SameSite=Lax` y no viaja en peticiones XHR cross-site. - Requieren un usuario con sesión en Redcumbre y rol del módulo (`SUPER-ADMIN`, `ADMIN`, `SII-EMISOR-BHET` o `FULL-API`). Si no hay sesión, el enlace pasa por el login y vuelve solo al documento. - La descarga queda registrada en el historial de actividad de la boleta. - Son estables: se calculan desde el id y sobreviven a la regeneración del PDF. :::note[¿Tus usuarios no tienen cuenta en Redcumbre?] Entonces estos enlaces no les sirven. Usa `pdfInternoUrl` / `pdfSiiUrl`, que son públicos y piden RUT del tercero + CAPTCHA. Ambos canales vienen en la misma respuesta. ::: --- ### Listar Archivos PDF ```bash GET /{tenantSlug}/bhet/{id}/archivos ``` El campo `url` de cada archivo es el enlace para el **destinatario** de la boleta: abre la página pública de descarga, que le pide su RUT antes de entregar el archivo, y es el mismo enlace que recibe por correo. No es una URL de bucket. Para descargar el archivo desde tu integración usa `GET /{tenantSlug}/bhet/{id}/archivos/{archivoId}/download`. **Response:** ```json { "success": true, "data": [ { "id": "archivo-001", "tipo": "INTERNO", "templateId": "template-001", "templateName": "Template Carta", "fileName": "bhet_12345678_interno.pdf", "url": "https://...", "sizeBytes": 125000, "createdAt": "2025-12-05T15:30:00Z", "createdBy": "Sistema" }, { "id": "archivo-002", "tipo": "SII", "templateId": null, "templateName": null, "fileName": "bhet_12345678_sii.pdf", "url": "https://...", "sizeBytes": 98000, "createdAt": "2025-12-05T15:35:00Z", "createdBy": "SII" } ] } ``` --- ## Listar y Consultar ### Listar Boletas ```bash GET /{tenantSlug}/bhet?fechaDesde=2025-01-01&estado=EMITIDA&limit=50 Authorization: Bearer tu-api-key ``` **Filtros disponibles:** | Parámetro | Tipo | Descripción | |-----------|------|-------------| | `fechaDesde` | ISO 8601 | Fecha de inicio del rango | | `fechaHasta` | ISO 8601 | Fecha de fin del rango | | `emisorTributarioId` | string | Filtrar por emisor | | `terceroRut` | string | Filtrar por RUT del tercero | | `estado` | enum | `EMITIDA`, `ERROR` | | `canalEmision` | enum | `UI`, `API_SYNC`, `API_ASYNC` | | `folioSii` | string | Buscar por folio (substring) | | `limit` | number | Registros por página (default: 50, max: 100) | | `offset` | number | Registros a saltar (default: 0) | --- ### Obtener Detalle ```bash GET /{tenantSlug}/bhet/{id} Authorization: Bearer tu-api-key ``` Es el documento **completo**, y es más de lo que devuelve la emisión: emisor, tercero, `prestaciones` con su desglose de descuentos, `tasaImpuesto`, fechas, `descargas` y el estado de una eventual anulación. Todo eso llega en `data` (acá sí, plano — la boleta **es** el recurso). El listado de campos con sus tipos está en [Swagger](https://api.redcumbre.cl/api-docs#/SII%20Boletas%20de%20Terceros). --- ## Webhooks El sistema envía webhooks para 4 eventos relacionados con BHET: | Evento | Descripción | Cuándo se dispara | |--------|-------------|-------------------| | `bhet.emitida` | BHET emitida exitosamente | Inmediatamente después de emitir | | `bhet.emision_terminada` | PDF SII disponible | Cuando se descarga el PDF SII en background | | `bhet.pdf_sii_fallido` | Descarga PDF SII falló | Después de 7 días de reintentos fallidos | | `bhet.emision_reintentando` | Un intento falló y va a reintentarse | En cada intento intermedio — informativo, no reemitas | | `bhet.emision_fallida` | Emisión asíncrona falló definitivamente | **Una sola vez**: al agotar los 10 intentos, o en el primero si el error es permanente | --- ### Payload: `bhet.emitida` ```json { "event": "bhet.emitida", "tenantId": "8", "bhetId": "cm5abc123", "folioSii": "12345678", "emisor": { "rut": "76123456-7", "razonSocial": "EMPRESA CONTRATANTE SPA" }, "tercero": { "rut": "78012039-8", "nombre": "FIRERAISE SPA" }, "montos": { "bruto": 150000, "impuesto": 22875, "neto": 127125 }, "canalEmision": "API_SYNC", "correlationId": "mi-referencia-123", "sandbox": false, "timestamp": "2025-12-05T15:30:00Z" } ``` --- ## Cálculo de Montos El sistema calcula automáticamente los montos: ``` Monto Bruto = Suma de todas las prestaciones Impuesto = Monto Bruto × tasa de retención (redondeado al peso) Monto Neto = Monto Bruto - Impuesto ``` :::caution[No hardcodees la tasa de retención] La tasa de retención de BHET **cambia por año tributario**. Consúltala en `GET /global/bhet-retencion`, que la devuelve como fracción y como porcentaje: ```json { "success": true, "data": { "bhetTasaRetencion": 0.1525, "bhetTasaRetencionPercent": 15.25 } } ``` El valor del ejemplo es el vigente en **2026**; el endpoint devuelve el que rija al momento de consultarlo. Lo mismo aplica al PPM de las BHE, en `GET /global/ppm`. Ambos endpoints viven bajo `/global/` y piden cualquier API Key válida, sin exigir un rol en particular. Si calculas el neto para mostrárselo al usuario **antes** de confirmar una emisión —que es irreversible—, esa pantalla tiene que usar la tasa del endpoint, no una constante en tu código. ::: :::note[Las emisiones fechadas en 2025 usan la tasa de 2025] Si envías `fechaEmision` con una fecha de **2025**, la boleta se calcula con la tasa fija de ese año (14,5%). Para cualquier otra fecha —incluido omitir el campo, que toma el día de hoy— se aplica la tasa vigente, la misma que devuelve `GET /global/bhet-retencion`. Es una excepción puntual para 2025, no una tabla de tasas por año: no asumas que una fecha de otro año pasado va a recuperar la tasa que regía entonces. Por eso conviene leer siempre `data.bhet.tasaImpuesto` de la respuesta: es la tasa que efectivamente se aplicó a **esa** boleta. ::: --- ## Códigos de Error ### Errores de Validación (400) | Código | Descripción | |--------|-------------| | `MULTIPLE_TERCERO_MODES` | Se especificó más de un modo de tercero | | `TERCERO_REQUIRED` | No se especificó ningún modo de tercero | | `CONTRIBUYENTE_NOT_FOUND` | ID/RUT no existe en ContribuyenteMaestro (modo 1) | | `INVALID_COMUNA_CODE` | `codigoComuna` no existe en catálogo (modo 2) | | `SII_LOOKUP_FAILED` | Lookup al SII falló (modo 4) | | `EMISOR_NO_HABILITADO` | El emisor no tiene el servicio BHET habilitado | ### Errores del SII El `errorCode` llega en el cuerpo del error (canal de máquina) y en `data.bhet.errorCode` (canal web). El status te dice si conviene reintentar sin tener que interpretar el código. **No reintentables — corrige antes de volver a intentar (`422`)** | Código | Descripción | |--------|-------------| | `SII_RUT_INVALIDO` | RUT del emisor o tercero no válido | | `SII_TERCERO_INVALIDO` | El tercero no es válido para recibir una BHET | | `SII_DATOS_INVALIDOS` | Datos de la boleta incorrectos | | `SII_FORM_ERROR` | El formulario del SII rechazó los datos enviados | | `SII_NO_HABILITADO` | El emisor no está habilitado ante el SII | | `SII_CERTIFICADO_ERROR` | Error con certificado digital | | `SII_EMISION_FAILED` | El SII rechazó la emisión con un mensaje propio (viene en `message`) | **Reintentables con backoff — el problema es del SII (`502`)** | Código | Descripción | |--------|-------------| | `SII_AUTH_FAILED` | El SII no devolvió TOKEN y no explicó por qué. Suele ser un problema pasajero de su lado | | `SII_ERROR` | Error genérico del SII durante la emisión | | `SII_RESPUESTA_INESPERADA` | El SII respondió algo que no se pudo interpretar | | `SII_ERROR_INTERNO` | Error interno declarado por el SII (código `IMT…`) | | `SII_SOLICITUD_FALLIDA` | "No ha sido posible completar su solicitud" | | `SII_SESION_EXPIRADA` | La sesión con el SII expiró durante la operación | | `SII_SERVICIO_NO_DISPONIBLE` | El servicio del SII no está disponible | **Reintentable con espera — hay turno por delante (`503`)** | Código | Descripción | |--------|-------------| | `SII_EMISOR_BUSY` | Demasiadas emisiones en curso para ese emisor. Respeta el header `Retry-After` | --- ## Estados de la Boleta | Estado | Descripción | |--------|-------------| | `EMITIDA` | Emitida correctamente en el SII (tiene folio y código de barras) | | `ERROR` | Error inmediato no reintentable | --- ## Testing (Sandbox) Utiliza el **modo sandbox** para probar la integración sin costo: - API Keys con `isSandbox: true` retornan datos de prueba - No se realizan llamadas reales al SII - No se genera billing - Webhooks funcionan normalmente - **No sale ningún correo al destinatario**, ni al emitir con `enviarBoletaPorEmail: true`, ni al reenviar el PDF manualmente desde la aplicación web. La supresión se decide por el folio de la boleta, así que una boleta emitida en sandbox no despacha correo nunca, tampoco meses después desde el panel. Ver [Entornos y modo sandbox](/primeros-pasos/entornos/). :::caution[El sandbox no te ahorra dar de alta un emisor] El `emisorTributarioId` es obligatorio también en sandbox, y tiene que ser un emisor **real de tu tenant**: el que no existe responde `404 Emisor tributario no encontrado`, y el de otro tenant, `400 El emisor no pertenece a este tenant`. No hay identificador comodín ni emisor de prueba compartido. Da de alta el tuyo con [Tokenización SII](/guias/emisores/) antes de probar. ::: --- ### RUTs de Prueba (Tercero) | RUT | Resultado | Descripción | |-----|-----------|-------------| | `78012039-8` | ✅ Éxito | Empresa completa (FIRERAISE SPA) | | `77425402-1` | ✅ Éxito | Empresa con múltiples direcciones | | `13830230-k` | ✅ Éxito | Persona natural (PDF SII pendiente) | | `99999999-9` | ❌ Error | Tercero no existe en SII | **Ejemplo en sandbox:** ```bash POST /{tenantSlug}/bhet Authorization: Bearer sandbox-api-key { "emisorTributarioId": "tu-emisor-tributario-id", "lookupRut": "78012039-8", "prestaciones": [ { "descripcion": "Servicio de prueba", "valor": "100000" } ] } ``` **Response (sandbox):** ```json { "success": true, "data": { "id": "sandbox-bhet-001", "folioSii": "SBX1234567890", "codigoBarras": "78012039SBX1234567890SANDBOX", "estado": "EMITIDA", ... }, "sandbox": true } ``` --- ## API Reference Para detalles técnicos completos y especificaciones de todos los endpoints: 👉 [Ver endpoints de BHET en Swagger](https://api.redcumbre.cl/api-docs#/SII%20Boletas%20de%20Terceros) --- ## ¿Necesitas anular una BHET? La anulación ante el SII tiene su propia guía: causas, seguimiento por webhook, comprobante al tercero y los límites de 10 días / $1.000.000. 👉 [Anulación de Boletas](/guias/anulacion-boletas) --- # Comunas y Regiones de Chile Fuente: https://docs.redcumbre.cl/guias/comunas-regiones/ :::tip[TL;DR - Acceso Rápido] **Endpoints:** - `GET /global/regiones` - 16 regiones de Chile - `GET /global/comunas` - 346 comunas de Chile - `GET /global/comunas-regiones` - Ambos listados en una sola llamada 👉 [Ver endpoints en Swagger](https://api.redcumbre.cl/api-docs#/Cat%C3%A1logo%20global) ::: Estos endpoints permiten obtener el catálogo completo de división político-administrativa de Chile. Los datos incluyen códigos SII, códigos BHE y relaciones entre comunas y regiones. Son útiles para poblar selectores en formularios, validar datos de entrada, o sincronizar catálogos con tu sistema. --- ## Requisitos Previos Estos endpoints exigen **una credencial válida, sin ningún rol en particular**: sirve cualquier API Key de tu empresa, y también la sesión de un usuario en la aplicación web. La respuesta es idéntica para todos. :::note[Datos Estáticos] Estos endpoints retornan datos estáticos del sistema. **No generan cobros** ni requieren configuración adicional de la empresa. Se piden con credencial porque para consumir la plataforma hay que ser cliente, no por su costo. ::: :::caution[Las direcciones anteriores cambiaron] Hasta agosto de 2026 estas rutas colgaban de la raíz (`/comunas`, `/regiones`, `/comunas-regiones`) y respondían sin credencial. Hoy viven bajo `/global/` y exigen identidad. **Si llamás a una dirección anterior con tu API Key vas a recibir un `403`** — `API_KEY_TENANT_MISMATCH` con credencial de máquina, `Access denied for the requested tenant` con sesión. No es un problema de tu credencial, de tu empresa ni de tus permisos: es que la ruta se movió, y su primer segmento pasó a leerse como el nombre de una empresa. Cambiá la URL a `/global/…` y funciona. Ese mensaje es deliberadamente inespecífico —es el mismo que devuelve cualquier empresa que no existe, para que nadie pueda enumerar empresas probando URLs—, así que la respuesta no puede decírtelo. Por eso está escrito acá. ::: --- ## Endpoints ### GET /global/regiones Retorna las 16 regiones de Chile. #### Request ``` GET /global/regiones ``` #### Headers ``` Authorization: Bearer {api_key} ``` #### Ejemplo con curl ```bash curl -X GET "https://api.redcumbre.cl/global/regiones" \ -H "Authorization: Bearer {api_key}" ``` #### Respuesta Exitosa (200) ```json { "success": true, "data": [ { "id": 1, "nombre": "Tarapacá" }, { "id": 2, "nombre": "Antofagasta" }, { "id": 3, "nombre": "Atacama" }, { "id": 4, "nombre": "Coquimbo" }, { "id": 5, "nombre": "Valparaíso" }, { "id": 6, "nombre": "O'Higgins" }, { "id": 7, "nombre": "Maule" }, { "id": 8, "nombre": "Biobío" }, { "id": 9, "nombre": "La Araucanía" }, { "id": 10, "nombre": "Los Lagos" }, { "id": 11, "nombre": "Aysén" }, { "id": 12, "nombre": "Magallanes" }, { "id": 13, "nombre": "Metropolitana" }, { "id": 14, "nombre": "Los Ríos" }, { "id": 15, "nombre": "Arica y Parinacota" }, { "id": 16, "nombre": "Ñuble" } ] } ``` #### Campos de Región | Campo | Tipo | Descripción | |-------|------|-------------| | `id` | number | ID de la región (1-16) | | `nombre` | string | Nombre oficial de la región | --- ### GET /global/comunas Retorna las 346 comunas de Chile con información detallada. #### Request ``` GET /global/comunas ``` #### Headers ``` Authorization: Bearer {api_key} ``` #### Ejemplo con curl ```bash curl -X GET "https://api.redcumbre.cl/global/comunas" \ -H "Authorization: Bearer {api_key}" ``` #### Respuesta Exitosa (200) ```json { "success": true, "data": [ { "id": 1, "nombre": "Arica", "numero": 1101, "codigoSii": "15101", "regionId": 15, "regionNombre": "Arica y Parinacota", "bheCodigo": 15101, "bheNombre": "ARICA", "bheRegionCodigo": 15, "bheRegionNombre": "ARICA Y PARINACOTA", "unidadSii": { "direccionRegional": "Dirección Regional de Arica y Parinacota", "nombreCorto": "DR Arica y Parinacota", "codigoRegion": "15", "unidad": "Unidad de Arica" } } ] } ``` #### Campos de Comuna | Campo | Tipo | Descripción | |-------|------|-------------| | `id` | number | ID secuencial de la comuna | | `nombre` | string | Nombre oficial de la comuna | | `numero` | number | Código numérico de la comuna | | `codigoSii` | string | Código SII de la comuna (5 dígitos) | | `regionId` | number | ID de la región a la que pertenece | | `regionNombre` | string | Nombre de la región | | `bheCodigo` | number | Código BHE de la comuna | | `bheNombre` | string | Nombre de la comuna según BHE | | `bheRegionCodigo` | number | Código de región según BHE | | `bheRegionNombre` | string | Nombre de región según BHE | | `unidadSii` | object \| null | Información de la unidad SII correspondiente | #### Estructura de Unidad SII | Campo | Tipo | Descripción | |-------|------|-------------| | `direccionRegional` | string | Nombre de la Dirección Regional | | `nombreCorto` | string | Nombre corto de la DR | | `codigoRegion` | string | Código de la región | | `unidad` | string | Nombre de la unidad SII | --- ### GET /global/comunas-regiones Retorna ambos listados en una sola llamada. Útil para cargar todos los datos de una vez. #### Request ``` GET /global/comunas-regiones ``` #### Headers ``` Authorization: Bearer {api_key} ``` #### Ejemplo con curl ```bash curl -X GET "https://api.redcumbre.cl/global/comunas-regiones" \ -H "Authorization: Bearer {api_key}" ``` #### Respuesta Exitosa (200) ```json { "success": true, "data": { "regiones": [ { "id": 1, "nombre": "Tarapacá" }, { "id": 2, "nombre": "Antofagasta" } ], "comunas": [ { "id": 1, "nombre": "Arica", "regionId": 15, "codigoSii": "15101" } ] } } ``` --- ## Casos de Uso ### Poblar Selectores en Formularios Usa `/global/comunas-regiones` para cargar ambos catálogos y crear selectores dependientes: ```javascript const response = await fetch('https://api.redcumbre.cl/global/comunas-regiones', { headers: { 'Authorization': `Bearer ${apiKey}` } }); const { data } = await response.json(); // Poblar selector de regiones const regionSelect = document.getElementById('region'); data.regiones.forEach(region => { const option = new Option(region.nombre, region.id); regionSelect.add(option); }); // Filtrar comunas por región seleccionada regionSelect.addEventListener('change', (e) => { const comunaSelect = document.getElementById('comuna'); comunaSelect.innerHTML = ''; const comunasFiltradas = data.comunas.filter(c => c.regionId === parseInt(e.target.value)); comunasFiltradas.forEach(comuna => { const option = new Option(comuna.nombre, comuna.codigoSii); comunaSelect.add(option); }); }); ``` ### Obtener Código SII de una Comuna Para enviar datos al SII necesitas el `codigoSii` de la comuna: ```javascript const comunas = await fetch('https://api.redcumbre.cl/global/comunas', { headers: { 'Authorization': `Bearer ${apiKey}` } }).then(r => r.json()); const santiago = comunas.data.find(c => c.nombre === 'Santiago'); console.log(santiago.codigoSii); // "13101" ``` ### Validar Código de Comuna Verifica que un código de comuna sea válido antes de enviar datos: ```javascript function validarCodigoComuna(codigo, comunas) { return comunas.some(c => c.codigoSii === codigo); } ``` --- ## Códigos de Error | Código | Descripción | Causa | |:------:|-------------|-------| | 401 | No autorizado | API Key inválida, expirada, revocada o no proporcionada | | 403 | Acceso denegado | Estás llamando a la **dirección anterior** (`/comunas`, `/regiones`, `/comunas-regiones`) en vez de a `/global/…`. Tu credencial está bien | | 404 | No encontrado | La ruta no existe. Revisá que el prefijo sea `/global/` | --- 👉 [Ver endpoints en Swagger](https://api.redcumbre.cl/api-docs#/Cat%C3%A1logo%20global) --- # DTE XML Format1 — Compatibilidad SOAP Fuente: https://docs.redcumbre.cl/guias/dte-xml-format1/ :::tip[TL;DR - Acceso Rápido] **Endpoint:** `POST /{tenantSlug}/dte_xml_format1` **Content-Type:** `application/xml` o `text/xml` **Respuesta:** `text/xml; charset=utf-8` **Modo:** Síncrono parcial (SINC_PARCIAL) ::: Este endpoint permite que sistemas que actualmente emiten DTEs usando el protocolo SOAP XML migren a Redcumbre **sin modificar su código**. El único cambio necesario es la URL del servicio y las credenciales de autenticación. El endpoint acepta un request SOAP XML con la estructura `ProcesaDte`, procesa el DTE usando el pipeline de emisión estándar de Redcumbre, y responde en el mismo formato SOAP XML `ProcesaDteResponse`. --- ## Requisitos Previos - **API Key** configurada en tu tenant con rol `FULL-API` (o `DTE_ROL_FULL` / `DTE_ROL_EMISOR`) - **Emisor DTE activo** registrado en el tenant con el RUT que usarás en el XML - **Content-Type** del request configurado como `application/xml` o `text/xml` --- ## Autenticación La autenticación se realiza mediante **API Key (JWT Bearer Token)** en el header HTTP: ``` Authorization: Bearer ``` El acceso es por tenant: la URL incluye el `{tenantSlug}` para identificar la organización. | Requisito | Valor | |-----------|-------| | **Header** | `Authorization: Bearer ` | | **Rol requerido** | `FULL-API`, `DTE_ROL_FULL` o `DTE_ROL_EMISOR` | | **Tenant** | Identificado por `{tenantSlug}` en la URL | :::caution[Diferencia con otros sistemas] La autenticación **NO** usa el campo `` del XML. Este campo se ignora por completo. Toda la autenticación se realiza a través del header HTTP `Authorization`. ::: --- ## Estructura del Request SOAP XML El request usa un envelope SOAP estándar: ```xml tpl_dte41_002 False 1 ... ``` ### Campos de ProcesaDte | Campo | Tipo | Obligatorio | Descripción | |-------|------|:-----------:|-------------| | `STRINGXML` | CDATA | Sí | DTE XML estándar SII embebido en CDATA | | `STRINGXMLADICIONAL` | CDATA | No | XML con campos adicionales (metadata personalizada) | | `TIPOIMPRESO` | String | No | ID del template PDF (ej: `tpl_dte41_002`). Si no se envía o el ID no existe en el catálogo, usa el default del tenant | | `IdempotencyKey` | String | No | Clave de idempotencia (max 128 chars). Protege contra emisiones duplicadas por reintentos. Ver [Idempotencia](#idempotencia) | | `SHORTURLS` | String | No | **IGNORADO** — las URLs siempre salen en formato corto. Ver [URLs de descarga](#urls-de-descarga) | | `ASIGNAFOLIO` | String | No | **IGNORADO** — el folio siempre se asigna internamente | | `AMBIENTE` | Integer | No | **IGNORADO** — el ambiente se determina por la configuración del emisor | | `TOKEN` | String | No | **IGNORADO** — la autenticación es por API Key en header HTTP | --- ## Estructura del DTE XML (STRINGXML) Dentro de `STRINGXML` va el DTE XML con formato estándar SII. La estructura general es: ```xml ... ... ... ... ... ``` ### Campos de IdDoc | Campo | Tipo | Obligatorio | Descripción | |-------|------|:-----------:|-------------| | `TipoDTE` | Integer | Sí | Código SII del tipo de documento (ver tabla de tipos) | | `Folio` | Integer | No | **IGNORADO** — el folio se asigna internamente por el SII | | `FchEmis` | String | No | Fecha de emisión en formato `YYYY-MM-DD`. Si no se envía, usa la fecha actual | | `FchVenc` | String | No | Fecha de vencimiento en formato `YYYY-MM-DD` | | `IndServicio` | Integer | No | Tipo de venta/servicio: `1` = Servicio periódico domiciliario, `2` = Otros servicios periódicos, `3` = Ventas y servicios, `4` = Espectáculo por cuenta de terceros | ### Campos de Emisor | Campo | Tipo | Obligatorio | Descripción | |-------|------|:-----------:|-------------| | `RUTEmisor` | String | Sí | RUT del emisor (ej: `76123456-7`). Se usa para buscar el emisor activo en el tenant | | `RznSocEmisor` | String | No | Razón social del emisor (informativo, no se usa para la emisión) | :::note[Resolución de Emisor] El sistema busca automáticamente un emisor DTE activo con el RUT indicado dentro del tenant. Si no se encuentra, retorna un error. No es necesario enviar un `emisorDteId`. ::: ### Campos de Receptor | Campo | Tipo | Obligatorio | Descripción | |-------|------|:-----------:|-------------| | `RUTRecep` | String | Sí | RUT del receptor | | `RznSocRecep` | String | Sí | Razón social del receptor | | `GiroRecep` | String | No | Giro comercial del receptor | | `DirRecep` | String | No | Dirección del receptor | | `CmnaRecep` | String | No | Comuna del receptor | | `CiudadRecep` | String | No | **Ignorado.** La ciudad del receptor se deriva siempre de `CmnaRecep`. Si lo envías, se descarta sin error | | `CorreoRecep` | String | No | Email del receptor. Si se incluye, se envía automáticamente el PDF del documento por email al receptor después de la emisión exitosa | El teléfono del receptor **no tiene campo propio en el `STRINGXML`**: entra por el adicional posicional `Treintaytres`, que es legacy — ver [Adicionales posicionales que sí tienen efecto](#adicionales-posicionales-que-si-tienen-efecto). ### Campos de Detalle (líneas del documento) Cada `` representa una línea del documento. Puede haber múltiples elementos ``. | Campo | Tipo | Obligatorio | Descripción | |-------|------|:-----------:|-------------| | `NroLinDet` | Integer | No | Número de línea (informativo) | | `NmbItem` | String | Sí | Nombre del ítem o servicio | | `QtyItem` | Decimal | Sí | Cantidad | | `PrcItem` | Decimal | Sí | Precio unitario | | `MontoItem` | Decimal | Sí | Subtotal de la línea (cantidad × precio) | | `IndExe` | Integer | No | Si es `1`, la línea está exenta de IVA | --- ## Metadata Adicional (STRINGXMLADICIONAL) Opcionalmente se puede enviar metadata adicional dentro de `STRINGXMLADICIONAL`. Esta información se almacena como JSON en el campo `metadataAdicional` del DTE y queda disponible para templates PDF personalizados. :::note[También disponible via REST] El campo `metadataAdicional` también se puede enviar en la API REST (`POST /{tenantSlug}/dte`) como un objeto JSON de pares clave-valor. Esto permite que cualquier integración (no solo SOAP XML) almacene metadata personalizada en el DTE. ::: ```xml valor1 valor2 valor3 ``` Los campos son numerados del `Uno` al `Veintiocho`. Todos son opcionales y de tipo string. Los campos vacíos se preservan como strings vacíos. ### Adicionales posicionales que sí tienen efecto Dos posiciones no son metadata inerte: el parser las lee y las convierte en campos del receptor. Existen por compatibilidad con integraciones antiguas y **no deben usarse en integraciones nuevas**. | Posición | Se convierte en | Condición | Equivalente moderno | |---|---|---|---| | `Treintaydos` | `receptor.email` | Sólo si no viene `CorreoRecep` y el valor es un email válido | `CorreoRecep` del `STRINGXML`, o `receptor.email` en la API REST | | `Treintaytres` | `receptor.telefono` | Sólo si el valor son exactamente 9 dígitos; se guarda como `+56` + esos 9 dígitos | `receptor.telefono` en la API REST | Un valor que no cumple la condición se descarta sin error: el documento se emite igual, sin teléfono ni email. Lo que llega por `Treintaytres` es un teléfono del receptor con todos sus efectos: se guarda en el documento, queda en la agenda de destinatarios DTE del cliente, y recibe el SMS de la emisión si `NotificarSmsDte` está activo. Ver [Notificar al receptor](/guias/dte/#notificar-al-receptor) en la guía de DTE. --- ## Tipos de DTE Soportados | Código SII | Tipo de Documento | |:----------:|-------------------| | 33 | Factura Afecta | | 34 | Factura Exenta | | 39 | Boleta Afecta | | 41 | Boleta Exenta | | 43 | Liquidación Factura | | 46 | Factura de Compra | | 52 | Guía de Despacho | | 56 | Nota de Débito | | 61 | Nota de Crédito | --- ## Estructura del Response SOAP XML La respuesta siempre es un SOAP XML con la estructura `ProcesaDteResponse`. ### Response Exitoso ```xml TVAL 0 DTE procesado correctamente. https://app.redcumbre.cl/d/DHp51REmpiqt/pdf https://app.redcumbre.cl/d/DHp51REmpiqt/xml https://app.redcumbre.cl/d/DHp51REmpiqt/xml 37898 cm8x1y2z3abc456def789 0.96504092 ``` ### Response de Error ```xml ERROR 1 No se encontró un emisor DTE activo con RUT 99999999-9 en el tenant 0 0.12345000 ``` ### Campos del Response | Campo | Tipo | Éxito | Error | Descripción | |-------|------|:-----:|:-----:|-------------| | `DescripcionResultado` | String | `TVAL` | `ERROR` | Código de resultado | | `IdResultadoFE` | Integer | `0` | `1` | `0` = éxito, otro valor = error | | `ResultadoFE` | String | Mensaje de éxito | Mensaje descriptivo del error | Descripción del resultado | | `UrlPdf` | URL | URL del PDF | vacío | URL pública para descargar el PDF del DTE (ver [URLs de descarga](#urls-de-descarga)) | | `UrlXmlSii` | URL | URL del XML | vacío | URL pública para descargar el XML del DTE. Mismo formato que UrlPdf | | `UrlXmlReceptor` | URL | URL del XML | vacío | URL pública del XML del DTE (misma que UrlXmlSii) | | `FolioAsignado` | Integer | Folio real | `0` | Folio asignado por el SII | | `IdDte` | String | CUID | vacío | Identificador único del DTE generado en el sistema | | `TiempoEjecucion` | Decimal | Segundos | Segundos | Tiempo de procesamiento en segundos | --- ## Campos Ignorados y Comportamiento Automático :::caution[Importante para integradores] Si migrás desde otro sistema, tené en cuenta que los siguientes campos se comportan diferente: | Campo | Comportamiento en Redcumbre | |-------|-----------------------------| | `` en el XML | **IGNORADO** — el folio se asigna automáticamente por el SII (Portal MiPyme o CAF según el tipo de DTE) | | `` | **IGNORADO** — siempre se asigna internamente | | `` | **IGNORADO** — el ambiente (certificación/producción) se determina por la configuración del emisor | | `` | **IGNORADO** — la autenticación es por API Key en el header HTTP `Authorization` | Podés seguir enviando estos campos por compatibilidad, pero no tienen efecto. ::: :::caution[El precio que envías es el precio final] Un DTE emitido por este endpoint **no recibe las promociones vigentes del tenant**. El precio de cada línea es el que se factura, sin descuentos automáticos. Es deliberado: quien integra ya calculó lo que va a cobrar, y un documento que saliera con otro total rompería ese cálculo. Si tu tenant configuró promociones en el punto de venta, esa misma venta puede salir con precios distintos según por dónde entre — calcula el descuento en tu sistema y envía el precio ya rebajado. Aplica igual al endpoint JSON de emisión; está explicado en [Documentos Tributarios Electrónicos](/guias/dte/#el-precio-que-mandas-es-el-precio-final-las-promociones-no-se-aplican-por-api). ::: --- ## Flujo de Procesamiento ``` Tu Sistema Redcumbre │ │ │── POST /{tenantSlug}/dte_xml_format1 ──▶│ │ Headers: │ │ - Authorization: Bearer │ │ - Content-Type: application/xml │ │ Body: SOAP XML (ProcesaDte) │ │ │ │ │── Parsea SOAP envelope │ │── Extrae DTE XML de STRINGXML │ │── Resuelve emisor por RUT │ │── Emite DTE via SII │ │── Genera PDF y XML │ │ │◀── SOAP XML (ProcesaDteResponse) ─│ │ - FolioAsignado │ │ - UrlPdf │ │ - UrlXmlSii │ ``` --- ## Ejemplo Completo ### Request ```xml 41 0 2026-02-26 1 2026-03-26 76123456-7 MI EMPRESA SPA 12345678-5 JUAN PEREZ GONZALEZ AV. PRINCIPAL 1234 SANTIAGO juan.perez@ejemplo.cl 15000 15000 1 1 Servicio de Agua Potable - Enero 2026 1 15000 15000 ]]> 1001 Enero 2026 ]]> tpl_dte41_002 servicio-agua-enero-2026 False 1 ``` ### Ejemplo con curl ```bash curl -X POST "https://api.redcumbre.cl/{tenantSlug}/dte_xml_format1" \ -H "Authorization: Bearer " \ -H "Content-Type: application/xml" \ -d @request.xml ``` Donde `request.xml` contiene el SOAP envelope completo del ejemplo anterior. --- ## URLs de descarga La respuesta trae tres enlaces —`UrlPdf`, `UrlXmlSii` y `UrlXmlReceptor`— que descargan el documento **sin autenticación**. Podés guardarlos, mandárselos al receptor por tu propio canal o publicarlos en tu portal. ### Formato ``` https://{tu-dominio}/d/{shortToken}/{pdf|xml|pdf-sii} ``` `{tu-dominio}` es el dominio con el que opera tu empresa en la plataforma; `{shortToken}` es un identificador de 12 caracteres que la plataforma genera para cada documento y no cambia nunca. ```xml https://app.redcumbre.cl/d/DHp51REmpiqt/pdf https://app.redcumbre.cl/d/DHp51REmpiqt/xml https://app.redcumbre.cl/d/DHp51REmpiqt/xml ``` Alrededor de 45 caracteres. **Si tu sistema guarda estas URLs en campos `VARCHAR(255)`, no tenés que hacer nada:** es el único formato que emite la plataforma. ### Notas - El `shortToken` se genera la primera vez y se reutiliza para el mismo documento en llamadas posteriores. Los emails de reenvío llevan la misma URL. - Las URLs son permanentes: no expiran. - `UrlXmlSii` y `UrlXmlReceptor` traen el mismo XML — son dos campos por compatibilidad histórica del contrato SOAP, no dos documentos. ### `SHORTURLS` ya no hace nada Hubo dos formatos de URL: uno largo con un token JWT de ~260 caracteres y uno corto, y `True` servía para pedir el corto. **El formato largo se retiró**, así que hoy el corto es el único que existe y el tag quedó sin efecto. Enviarlo no produce error y no cambia la respuesta. Podés dejar de enviarlo cuando te sea cómodo; no hay apuro ni nada que migrar. --- ## Idempotencia Si tu sistema puede reintentar requests (por timeout, error de red, o doble-click), podés usar el tag `` para proteger contra emisiones duplicadas. ### Qué es La idempotencia garantiza que un mismo request enviado múltiples veces produzca el mismo resultado: solo se emite un DTE, y los reintentos retornan el DTE original sin crear uno nuevo ni consumir folio. ### Cómo usarla Agregá el tag `` dentro de `` con un valor único por operación: ```xml order-12345-factura ``` **Reglas:** - Máximo **128 caracteres** - El scope de unicidad es: **emisor + tipo de DTE + key**. Esto significa que podés usar la misma key para diferentes emisores o diferentes tipos de DTE sin colisión - La key es **permanente** — no expira. Un DTE emitido con una key siempre retornará el mismo resultado ante reintentos - Solo DTEs con estado **EMITIDO** se consideran para idempotencia. Si la primera emisión falló, podés reintentar con la misma key y se creará un DTE nuevo - El campo es **opcional**. Si no se envía, el comportamiento no cambia ### Ejemplo XML con idempotencia ```xml 33 2026-03-03 76123456-7 77438768-4 EMPRESA EJEMPLO SPA VENTA AL POR MENOR AV PROVIDENCIA 1234 PROVIDENCIA Servicio de consultoría 1 100000 100000 ]]> order-12345-factura ``` ### Comportamiento en reintentos | Situación | Resultado | |-----------|-----------| | Primera emisión con key | DTE se emite normalmente, key se guarda | | Reintento con misma key (DTE ya emitido) | Retorna el DTE original sin crear nuevo | | Misma key pero diferente emisor o tipo DTE | Se emite un DTE nuevo (scope diferente) | | Misma key pero DTE original en estado ERROR | Se emite un DTE nuevo (solo EMITIDO cuenta) | :::tip[También disponible via REST] En la API REST (`POST /{tenantSlug}/dte`), usá el campo `"idempotencyKey"` en el body JSON para la misma funcionalidad. ::: --- ## Errores Comunes | Error | Causa | Solución | |-------|-------|----------| | Request body vacío o no es XML | Content-Type incorrecto o body vacío | Verificar que `Content-Type` sea `application/xml` o `text/xml` | | SOAP Envelope no encontrado | XML no tiene estructura SOAP | Verificar el namespace `soapenv` y la estructura del envelope | | Campo STRINGXML es obligatorio | No se incluyó el campo STRINGXML | Agregar el campo con el DTE XML en CDATA | | TipoDTE no es un número válido | Código de tipo no reconocido | Usar uno de los códigos soportados (33, 34, 39, 41, etc.) | | No se encontró un emisor DTE activo con RUT X | El RUT no tiene emisor activo en el tenant | Verificar que el RUT del emisor esté registrado y activo | --- # Documentos Tributarios Electrónicos (DTE) Fuente: https://docs.redcumbre.cl/guias/dte/ Un DTE es un documento tributario que se emite ante el SII de Chile. La API cubre nueve tipos, desde la factura afecta hasta la liquidación factura, con un mismo endpoint de emisión. Esta guía es conceptual: explica el flujo, los modos y las restricciones que se descubren en runtime. El detalle exhaustivo de campos, tipos y esquemas de respuesta vive en la [especificación OpenAPI](https://api.redcumbre.cl/api-docs-json) — acá no se duplica. ## Antes de emitir Tres cosas tienen que existir en tu tenant antes del primer `POST`: | Requisito | Qué es | ¿Por API? | |---|---|---| | **Emisor DTE** | La empresa que emite, con su certificado digital vigente delegado a la plataforma. Su identificador es el `emisorDteId` que pide el endpoint de emisión | **Sí**, por tokenización SII | | **Folios CAF** | Los rangos de folios que el SII autoriza por tipo de documento. Sin folios disponibles, la emisión falla | No | | **`dirOrigen`** | La dirección de origen configurada en el emisor. El SII la exige en el encabezado | No | El alta de un emisor DTE **sí se hace íntegramente por API**: el camino es la tokenización SII, donde el titular entrega su certificado en un wizard de Redcumbre y tú recibes el emisor de vuelta. Está documentado en [Emisores Tributarios y Emisores DTE](/guias/emisores/). Los folios CAF y la `dirOrigen` no se resuelven por API: si te faltan, escribe a tu contacto en REDCUMBRE. :::note[De dónde sacas el `emisorDteId`] Es configuración: se fija una vez por emisor y después no se toca. Hay dos caminos según cómo se dio de alta, y los dos entregan el mismo valor. - **Panel web** — **Configuración → Emisores**: abre el emisor y copia el campo **ID**. Es el camino normal cuando el emisor ya estaba provisionado en tu tenant. - **Tokenización SII** — si das de alta el emisor desde tu backend, el id viene en la respuesta de `POST /{tenantSlug}/tokenizacion/exchange` (`data.emisorId`) y en el webhook `tokenizacion.completada`, con `tipoEmisor: "dte"`. **Guárdalo en tu sistema junto al RUT.** No hay endpoint de listado de emisores con API key: si lo pierdes, tienes que volver al panel o re-tokenizar el mismo RUT. Ver [De dónde sale el `emisorDteId`](/guias/emisores/#de-dónde-sale-el-emisordteid). ::: ## Tipos soportados | Código | `tipoDte` | Documento | |---|---|---| | 33 | `FACTURA_AFECTA` | Factura Electrónica Afecta | | 34 | `FACTURA_EXENTA` | Factura Electrónica Exenta | | 39 | `BOLETA_AFECTA` | Boleta Electrónica Afecta | | 41 | `BOLETA_EXENTA` | Boleta Electrónica Exenta | | 43 | `LIQUIDACION_FACTURA` | Liquidación Factura | | 46 | `FACTURA_COMPRA` | Factura de Compra (cambio de sujeto) | | 52 | `GUIA_DESPACHO` | Guía de Despacho | | 56 | `NOTA_DEBITO` | Nota de Débito | | 61 | `NOTA_CREDITO` | Nota de Crédito | ## El canal de emisión es del emisor, no del tipo de documento Es lo primero que conviene entender, porque de ahí cuelgan el límite de líneas, el modo de emisión y la forma exacta de la respuesta. El canal es una propiedad del **emisor DTE** configurado en tu tenant. Una misma factura afecta se emite por un canal o por otro según cómo esté habilitado tu emisor: no lo decide el tipo de documento ni lo eliges en la petición. Te lo indica tu contacto en REDCUMBRE, junto con el resto de los prerequisitos. | Canal | Cómo emite | Qué cambia para ti | |---|---|---| | **Portal MiPyme** | La plataforma opera el portal del SII con el certificado delegado | Máximo de 10 líneas de detalle. Los tres modos de emisión aplican | | **Firma local** (`FULL_DTE`) | La plataforma arma el XML, lo firma y lo envía al SII en lote | Hasta 60 líneas de detalle. El `modo` que pidas no aplica: la respuesta siempre viene con `modo: "ASINCRONO"`, con el documento y su folio ya asignados, y con `estadoSii: "PENDIENTE_ENVIO"` | | **API Oficial SII** | Sólo las boletas 39 y 41, con CAF y certificado digital propios | Hasta 500 líneas de detalle | | **Integración a medida** (`API_EXTERNA`) | La plataforma delega la emisión en el sistema que ya usas | El máximo de líneas lo fija ese sistema. El `modo` que pidas no aplica: la respuesta viene con `modo: "ASYNC_EXTERNO"`, `estado: "PENDIENTE_EXTERNO"` y `folio: 0` — el folio lo asigna el sistema externo y llega después | ### Un RUT puede tener dos emisores: facturación y boletas Las boletas electrónicas (39 y 41) salen de un emisor propio, el de **boletas**, distinto del de facturación. Un mismo RUT puede tener los dos, y cada uno tiene su `emisorDteId`: el que mandas en la petición decide por cuál sale el documento. :::caution[La nota de crédito que anula una boleta la emite el emisor de facturación] La nota de crédito (61) es un documento de facturación. **El emisor de boletas no la emite**: para anular una boleta necesitas el emisor de facturación del mismo RUT y su `emisorDteId`. Si el RUT sólo tiene el emisor de boletas, esa anulación no se puede hacer por API: se hace directamente en el Portal MiPyme del SII. ::: Cómo se dan de alta los dos, y cómo llegan sus ids, está en [Emisores](/guias/emisores/#paso-2-declarar-qué-emisores-quieres). ## Emitir ``` POST /{tenantSlug}/dte ``` Cuatro campos son obligatorios: `emisorDteId`, `tipoDte`, `receptor` y `lineas`. El `emisorDteId` sale del panel web o de la tokenización — ver [Antes de emitir](#antes-de-emitir). Dentro de `receptor`, el `giro` es obligatorio en todos los tipos salvo las boletas 39 y 41: si falta, la petición responde `400` con `receptor.El giro del receptor es obligatorio para este tipo de DTE`. ### El giro del receptor El camino natural para obtenerlo es [`POST /{tenantSlug}/herramientas/lookup-rut`](/guias/lookup-rut/), pero **`giroGlosa` normalmente llega `null`**: el SII solo entrega el giro cuando el RUT consultado es el mismo del certificado que consulta, y al facturarle a un cliente nunca lo es. Hay que resolverlo en cascada: ```js const { data } = await lookupRut(rutReceptor); const giro = data.giroGlosa // null salvo lookup auto-referencial ?? data.actividadesEconomicas?.[0]?.descripcion // la actividad principal del SII ?? await pedirGiroAlUsuario(); // último recurso ``` Los nombres de campo son literales: el array es `data.actividadesEconomicas` —no `data.actividades`— y cada elemento trae `{ codigo, descripcion }`. El SII trunca el giro a **40 caracteres** al recibir el DTE, así que conviene mandarlo ya recortado y revisado, no una glosa larga cortada a ciegas. ### Factura Afecta (33) ```bash curl -X POST https://api.redcumbre.cl/tu-tenant/dte \ -H "Authorization: Bearer $REDCUMBRE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "emisorDteId": "clxyz123abc456def", "tipoDte": "FACTURA_AFECTA", "formaPago": "CREDITO", "receptor": { "rut": "77438768-4", "razonSocial": "REDCUMBRE SPA", "giro": "VENTA AL POR MENOR DE PRODUCTOS EN COMERCIOS ESPECIALIZADOS", "direccion": "LOS PEUMOS ST. 3 LT B", "comuna": "BULNES", "email": "contacto@empresa.cl" }, "lineas": [ { "nombre": "Servicio de consultoría especializada", "cantidad": 1, "precioUnitario": 100000, "subtotal": 100000 } ], "idempotencyKey": "orden-12345" }' ``` Respuesta (`201`). El documento va en `data.dte` y los montos en `data.montos` — no sueltos en `data`: ```json { "success": true, "data": { "modo": "SINC_PARCIAL", "dte": { "id": "dte_5fe4ff45-...", "tipoDte": "FACTURA_AFECTA", "folio": 25403, "dhdrCodigo": "2831022103", "estado": "EMITIDO", "modoEmision": "SINC_PARCIAL" }, "montos": { "montoNeto": 100000, "montoExento": 0, "montoIva": 19000, "montoTotal": 119000 }, "urls": { "pdfUrl": "https://api.redcumbre.cl/public/documentos/{token}/pdf", "xmlUrl": "https://api.redcumbre.cl/public/documentos/{token}/xml", "pdfSiiUrl": null, "webUrl": "https://app.redcumbre.cl/tu-tenant/dte/dte_5fe4ff45-..." }, "estadoPdfSii": "PENDIENTE", "mensaje": "DTE emitido exitosamente" } } ``` :::tip[Ramifica por el contenido, no por el `modo`] Si la respuesta trae `data.dte`, el documento existe y tiene folio. Si trae `data.intentoId`, quedó encolado y todavía no. Esa es la única regla que vale en los tres canales: el `modo` devuelto no te dice cuál de las dos formas recibiste. ::: :::caution[Un `2xx` significa emitido — `estadoSii` no es el criterio de éxito] Si la respuesta es `2xx`, el documento se emitió. `estadoSii` es el trámite **posterior** ante el SII y evoluciona por su cuenta durante los minutos siguientes: en Portal MiPyme pasa por `PROCESO_SII` antes de llegar a `ACEPTADO`, y con firma local nace en `PENDIENTE_ENVIO`. En la respuesta de emisión por Portal MiPyme el campo no viene. Una integración que exija `estadoSii: "ACEPTADO"` para dar la emisión por buena marca como fallidas facturas que salieron perfectas, y si reintenta sin `idempotencyKey` quema otro folio. Para seguir el trámite usa [webhooks](/guias/webhooks/) o `verificar-estado-sii`. ::: :::caution[Guarda el `folio` que devuelve la API] El `folio` de la respuesta es el que el SII asignó, y es el único con el que después puedes consultar el documento. Si tu sistema guarda su propio correlativo y descarta este, cualquier consulta posterior por folio no va a encontrar nada. Es el error de integración más caro que vemos. ::: ### Factura Exenta (34) Igual que la 33, cambiando el tipo. Sin IVA: `montoNeto` y `montoTotal` coinciden. ```bash curl -X POST https://api.redcumbre.cl/tu-tenant/dte \ -H "Authorization: Bearer $REDCUMBRE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "emisorDteId": "clxyz123abc456def", "tipoDte": "FACTURA_EXENTA", "receptor": { "rut": "77438768-4", "razonSocial": "REDCUMBRE SPA", "giro": "VENTA AL POR MENOR DE PRODUCTOS EN COMERCIOS ESPECIALIZADOS", "direccion": "LOS PEUMOS ST. 3 LT B", "comuna": "BULNES" }, "lineas": [ { "nombre": "Servicio educacional exento de IVA", "cantidad": 1, "precioUnitario": 250000, "subtotal": 250000 } ] }' ``` ### Liquidación Factura (43) Es el tipo con más particularidades. Se usa cuando un mandatario liquida ventas a su mandante: las líneas describen lo vendido y las **comisiones** describen lo que el mandatario retiene. ```bash curl -X POST https://api.redcumbre.cl/tu-tenant/dte \ -H "Authorization: Bearer $REDCUMBRE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "emisorDteId": "clxyz123abc456def", "tipoDte": "LIQUIDACION_FACTURA", "receptor": { "rut": "77438768-4", "razonSocial": "REDCUMBRE SPA", "giro": "VENTA AL POR MENOR DE PRODUCTOS EN COMERCIOS ESPECIALIZADOS", "direccion": "LOS PEUMOS ST. 3 LT B", "comuna": "BULNES" }, "lineas": [ { "nombre": "NETO FACTURAS ELECTRONICAS", "cantidad": 8, "precioUnitario": 18000, "subtotal": 144000, "tipoDocumentoLiquidar": 33 }, { "nombre": "EXENTO FACTURAS ELECTRONICAS", "cantidad": 3, "precioUnitario": 12000, "subtotal": 36000, "esExento": true, "tipoDocumentoLiquidar": 33 } ], "comisiones": [ { "tipoMovimiento": "C", "glosa": "Comisión por venta", "valorNeto": 5000 } ] }' ``` **`tipoDocumentoLiquidar`** identifica qué tipo de documento se está liquidando en esa línea. Es exclusivo del 43: | Código | Documento | |---|---| | `0` | Sin especificar (es el valor que se envía si omites el campo) | | `33` | Factura electrónica | | `34` | Factura exenta | | `39` | Boleta electrónica | | `48` | Pago de cheque | | `52` | Guía de despacho | `esExento` en una línea permite mezclar afectos y exentos en el mismo documento, cosa que los demás tipos no admiten. ## Modos de emisión El `modo` aplica a los emisores que emiten por **Portal MiPyme**. Con firma local se ignora, y la respuesta viene siempre con `modo: "ASINCRONO"` y el documento ya emitido. | `modo` | Qué hace | Cuándo usarlo | |---|---|---| | `SINC_COMPLETO` | Emite y genera el PDF del SII antes de responder | Necesitas el PDF en la misma petición | | `SINC_PARCIAL` | **Default.** Emite en forma síncrona; el PDF del SII se genera en segundo plano con reintentos | El caso general | | `ASINCRONO` | Solo valida y encola. Todo ocurre en segundo plano | Estás emitiendo desde una petición web y no quieres que el usuario espere al SII | ### Ramifica por `data.dte`, nunca por `modo` El `modo` de la respuesta informa cómo operó el carril; **no anuncia la forma de la respuesta**. Un emisor de firma local responde `modo: "ASINCRONO"` y trae el documento igual, porque lo que ocurre en segundo plano es el envío al SII, no la emisión. La regla que sí se cumple en todos los canales es una sola: ```js if (respuesta.data.dte) { // El documento existe y tiene folio. Sigue con tu flujo. } else { // Quedó encolado: consulta respuesta.data.intentoId. } ``` Un cliente escrito como `if (modo === "ASINCRONO") poll(intentoId)` busca un `intentoId` que no existe en la mayoría de los canales. Cuando la emisión queda encolada, la respuesta trae un `intentoId` en lugar del documento: ```json { "success": true, "data": { "modo": "ASINCRONO", "intentoId": "dtei_3f2a1b8c-...", "estado": "PENDIENTE", "mensaje": "Solicitud de emisión registrada. El DTE se procesará en segundo plano." } } ``` El `estado` del intento recorre `PENDIENTE`, `PROCESANDO`, `REINTENTANDO` y termina en `EMITIDO` o `FALLIDO`. Se consulta con `GET /{tenantSlug}/dte/intento/{intentoId}`. Para enterarte sin hacer polling, usa [webhooks](/guias/webhooks/). ## Borradores Con `"saveAsDraft": true` el documento se guarda sin emitir. La respuesta es plana —no trae `dte` ni `montos`— y no consume folio: ```json { "success": true, "data": { "id": "dte_3b4e9827-...", "estado": "BORRADOR", "lineas": [{ "nombre": "Servicio de consultoría", "cantidad": 1, "precioUnitario": 100000, "subtotal": 100000 }], "trazaPrecios": [] } } ``` Se puede editar y se emite después con `POST /{tenantSlug}/dte/{id}/emitir`. :::tip[Es también la forma de probar un payload sin emitir nada] Un borrador pasa por la misma validación que una emisión real, así que `saveAsDraft: true` seguido de `DELETE /{tenantSlug}/dte/{id}` te dice si tu petición es válida sin consumir folio ni generar un documento ante el SII. Es la manera recomendada de estrenar una integración, incluso contra producción. ::: ## Notificar al receptor La emisión puede avisarle al receptor por email y por SMS. Son dos carriles independientes, y en ambos los destinatarios salen de la suma de tres orígenes, deduplicada: | Campo | Qué hace | |---|---| | `receptor.email` | Email del receptor. Recibe el PDF del documento | | `receptor.telefono` | Teléfono del receptor. Recibe el SMS con el enlace al PDF si `notificarSmsDte` está activo. Para el SMS tiene que ser un móvil chileno (`569XXXXXXXX`) | | `emailsAdicionales` | Hasta 20 destinatarios extra de email — el contador del cliente, por ejemplo | | `telefonosSmsAdicionales` | Hasta 10 destinatarios extra de SMS, móviles chilenos | | `notificarSmsDte` | Activa el SMS. Si no lo mandas, queda activo cuando tu tenant tiene el servicio de SMS habilitado | | `emailVoid` / `smsVoid` | Excluyen destinatarios **de esta emisión**. No borran nada de la agenda | ### La agenda del cliente se puebla sola Todo contacto que mandas en una emisión —los cuatro campos de arriba— queda guardado en la **agenda de destinatarios DTE del cliente** dentro de tu empresa, normalizado y sin duplicar. Desde la emisión siguiente al mismo receptor no hace falta repetirlo: ya está en la agenda y se notifica igual. Eso vale también para los borradores: si guardas un borrador con contactos, la agenda se puebla ahí mismo, sin haber emitido. Tres detalles que conviene tener presentes: - **El teléfono se normaliza al guardarlo** (`+56 9 8430 6244` → `56984306244`) y sólo entran móviles chilenos. Un fijo se acepta en el documento pero no llega a la agenda ni recibe SMS. - **Las boletas a público general (`66666666-6`) no alimentan la agenda.** La ficha sería compartida por todos los compradores anónimos. - **`emailVoid` y `smsVoid` no borran de la agenda.** Excluyen del envío de esa emisión y nada más; para sacar un destinatario definitivamente están los endpoints de la agenda del cliente. El detalle de campos, largos y validaciones está en la [especificación OpenAPI](https://api.redcumbre.cl/api-docs-json). ## Metadata para la representación impresa `metadataAdicional` es un objeto de pares clave-valor que se guarda como JSON junto al documento. **No viaja al SII**: no forma parte del XML firmado ni entra en el timbre. Sirve para alimentar la plantilla PDF de tu empresa con datos que el formato tributario no tiene dónde poner —el número de orden de compra, el centro de costo, el período facturado— y para dejarlos consultables después. ```json { "emisorDteId": "clxyz123abc456def", "tipoDte": "FACTURA_AFECTA", "receptor": { "rut": "77438768-4", "razonSocial": "REDCUMBRE SPA", "giro": "VENTA AL POR MENOR", "direccion": "LOS PEUMOS ST. 3 LT B", "comuna": "BULNES" }, "lineas": [{ "nombre": "Servicio de consultoría", "cantidad": 1, "precioUnitario": 100000, "subtotal": 100000 }], "metadataAdicional": { "NumOrdenCompra": "OC-12345", "CentroCosto": "CC-001", "PeriodoFacturado": "FEB de 2026" } } ``` Cuatro cosas que conviene saber antes de usarlo: - **Las claves las define tu plantilla PDF, no la API.** No hay lista de nombres válidos ni tope de cantidad: lo que mandes se guarda tal cual. Si tu empresa no tiene plantilla personalizada, el dato queda almacenado y consultable, pero no se imprime en ninguna parte. - **Manda los valores como string.** Es lo que declara el schema (`additionalProperties: string`) y lo que la plantilla espera recibir. - **No lo uses para datos que sí tienen campo propio.** Un dato que el SII debe ver —referencias, descuentos, glosa del ítem— puesto acá no llega al documento tributario. - **Vuelve en el detalle del documento.** `GET /{tenantSlug}/dte/{id}` lo devuelve en `metadataAdicional`, y `null` si no mandaste nada. :::caution[Si vienes de la guía SOAP, una convención no se traslada] [La integración XML Format1](/guias/dte-xml-format1/) llena este mismo campo desde ``, con claves numeradas `Uno`…`Veintiocho`, y ahí dos posiciones tienen efecto sobre el receptor: `Treintaydos` puede convertirse en su email y `Treintaytres` en su teléfono. **Por REST eso no ocurre**: acá `metadataAdicional` es inerte y los contactos van en `receptor.email` y `receptor.telefono`. La numeración tampoco es un requisito de la API REST — es el vocabulario de ese XML. ::: ## Idempotencia `idempotencyKey` (máximo 128 caracteres) protege contra reintentos por timeout o doble click. Si ya existe un documento emitido para la misma combinación de **emisor, ambiente, tipo y clave**, se devuelve **el original** sin crear uno nuevo ni consumir folio. Dos cosas que conviene tener claras antes de integrar: - **El ambiente forma parte del alcance.** La misma clave puede usarse una vez en certificación y una vez en producción. Cuando pases tu emisor a producción, las claves que gastaste probando no te devuelven los documentos de prueba: emiten los reales. - **Anular libera la clave.** Un documento anulado deja de ocuparla y la misma clave vuelve a emitir uno nuevo. Un reintento sobre una clave ya usada responde `201` con `"idempotent": true` y el documento original en `data.dte` — con el folio, el receptor y los montos de la **primera** emisión, no los del payload que acabas de mandar. El campo a mirar para distinguir "emití" de "ya estaba emitido" es `idempotent`, no el status. Ver [Reglas transversales](/primeros-pasos/reglas-transversales/#idempotencia). ## Restricciones que conviene conocer antes Estas son las que se descubren con un `400` en runtime si no se saben de antemano. ### El precio que mandas es el precio final: las promociones no se aplican por API Una venta emitida por la API de integración **no recibe las promociones vigentes del tenant**. El `precioUnitario` que envías en cada línea es el que se factura, sin descuentos automáticos. Es deliberado, no una limitación pendiente: **quien integra por API ya calculó lo que va a cobrar**. Un ERP que manda una línea de $10.000 y recibe una boleta por $6.666 lo trataría como un error — con razón, porque su propio cálculo dejó de ser el que salió en el documento. Lo mismo aplica al endpoint XML (`dte_xml_format1`). :::caution[Si tu tenant usa promociones en el punto de venta] Una misma venta puede salir con precios distintos según por dónde entre. Un *"3x2"* configurado en Redcumbre se aplica en el punto de venta y en el portal, y **no** se aplica en lo que emitas por esta API. Si necesitas que el descuento aparezca en el documento, **calcúlalo en tu sistema** y manda el precio ya rebajado. ::: ### Precio unitario: el mínimo es 1, no 0 Cada línea debe tener `precioUnitario >= 1`. Un `0` se rechaza: ```json { "statusCode": 400, "message": "El precio unitario debe ser al menos 1 en cada línea. 1 línea(s) tienen precio inválido." } ``` Hay tres excepciones, donde sí se aceptan líneas en `0`: - **Guía de despacho (52) sin precios**, cuando se envía marcada como tal - **Nota de crédito o débito (61 / 56) de corrección o anulación sin monto** — las que sólo corrigen un texto o anulan, con referencia de código `1` o `2`. El SII las certifica con total `0` - **Liquidación factura (43)**, que admite líneas glosa con precio `0` legítimo El DTO además rechaza valores negativos en `precioUnitario`. ### Cantidad de líneas: depende del canal **No es un número fijo.** El máximo lo determina el canal de integración del emisor: 10 líneas de detalle por Portal MiPyme, 60 con firma local y 500 por la API Oficial SII de boletas. Si te pasas: ```json { "statusCode": 400, "message": "El documento tiene 12 líneas de detalle, pero el emisor ... permite máximo 10. Reduzca la cantidad de líneas." } ``` El mensaje te dice el máximo aplicable a **tu** emisor. No lo asumas desde el código. ### Largo del nombre de ítem: se trunca, no se rechaza El campo `nombre` acepta hasta **255 caracteres** y no falla si te pasas de 80. Lo que ocurre es que **al enviarlo al SII se trunca a 80**, que es el límite del organismo. El dato completo queda guardado en la plataforma y aparece íntegro en el PDF interno; el que viaja al SII va cortado. Si el nombre importa entero en el documento tributario, mantenlo bajo 80 caracteres. Para detalle largo existe `descripcionExtendida` (hasta 1.000 caracteres), que no viaja al SII. ## Revertir una Liquidación Factura (43) **Un DTE 43 no se anula con nota de crédito ni de débito.** Si lo intentas: ```json { "statusCode": 400, "message": "Las Liquidaciones Factura (DTE 43) no pueden anularse con Nota de Crédito. Emita una nueva Liquidación Factura con valores negativos referenciando el documento original." } ``` La vía correcta es **emitir otra liquidación con valores negativos**, referenciando la original. Y acá hay una asimetría que conviene entender, porque contradice la intuición: - **Las líneas de detalle no admiten valores negativos.** El esquema XML del SII lo prohíbe. - **Las comisiones sí conservan el signo.** Una comisión negativa —una devolución o un reverso— debe ir con su signo. Si se enviara en valor absoluto, la suma por línea contradiría el resumen de totales y el SII rechazaría el documento. O sea: el signo de la reversión vive en las comisiones, no en las líneas. ## Consultar y descargar | Para | Endpoint | |---|---| | Listar documentos | `GET /{tenantSlug}/dte` | | Detalle de uno | `GET /{tenantSlug}/dte/{id}` | | PDF de la plataforma | `GET /{tenantSlug}/dte/{id}/pdf` | | PDF timbrado del SII | `GET /{tenantSlug}/dte/{id}/pdf-sii` | | Estado ante el SII | `POST /{tenantSlug}/dte/{id}/verificar-estado-sii` | | Archivos asociados (XML, PDF) | `GET /{tenantSlug}/dte/{id}/archivos` | | Historial de eventos | `GET /{tenantSlug}/dte/{id}/logs` | El índice completo de rutas del dominio está en [`/api-docs/index.txt`](https://api.redcumbre.cl/api-docs/index.txt). ## Errores y reintentos | Código | Causa típica | ¿Reintentar? | |---|---|---| | `400` | Payload inválido, precio bajo el mínimo, exceso de líneas, tipo de referencia no permitido | **No.** Corrige el payload | | `403` | La API key no alcanza el endpoint, o el servicio de DTE no está habilitado en el tenant | **No** | | `404` | El `emisorDteId` no existe en tu tenant | **No.** Verifícalo en el panel (**Configuración → Emisores**, campo **ID**) o en la respuesta de tu tokenización | | `409` | Tu emisor tiene conflictos de sincronización pendientes | **No.** Se resuelve sobre el emisor; escribe a tu contacto en REDCUMBRE | | `422` | Sin folios CAF disponibles, o límite de crédito del cliente excedido | **No.** Ver el aviso de abajo | | `5xx` | Falla de la API o del SII | **Sí**, con backoff. Usa `idempotencyKey` para que el reintento no duplique | Un rechazo **del SII** no es un `5xx`: el documento se emite y queda con `estadoSii` en estado de rechazo. Se consulta con `verificar-estado-sii`, no se reintenta el POST. :::caution[El `422` por falta de folios deja el documento guardado] Cuando no hay folios CAF disponibles, la petición falla con `422` **pero el documento queda guardado como borrador**. Reintentar el mismo envío no lo emite: acumula borradores. Lo que corresponde es conseguir folios y emitir el borrador existente con `POST /{tenantSlug}/dte/{id}/emitir`. ::: ## Siguiente paso - [Webhooks](/guias/webhooks/) — enterarte del resultado sin hacer polling - [Reglas transversales](/primeros-pasos/reglas-transversales/) — paginación, idempotencia, errores - [Procesos Batch](/guias/procesos-batch/) — emisión masiva desde Excel - [Especificación OpenAPI](https://api.redcumbre.cl/api-docs-json) — todos los campos, tipos y esquemas --- # Emisores Tributarios y Emisores DTE Fuente: https://docs.redcumbre.cl/guias/emisores/ :::tip[TL;DR - Acceso Rápido] **Endpoint principal:** `POST /{tenantSlug}/tokenizacion/init-session` **El alta de emisores por API se hace con Tokenización SII**, no con endpoints de creación directa. El flujo termina entregándote el `emisorId`, que es el mismo `emisorTributarioId` que piden `POST /{tenantSlug}/bhe` y `POST /{tenantSlug}/bhet`. 👉 [Ver documentación completa en Swagger](https://api.redcumbre.cl/api-docs#/Tokenizaci%C3%B3n%20SII) ::: El Sistema de Gestión de Emisores de Redcumbre permite administrar las entidades autorizadas para realizar operaciones tributarias ante el SII de Chile. Ya sea que necesites delegar credenciales de tus usuarios o gestionar múltiples sucursales para emitir documentos tributarios, nuestra plataforma lo resuelve de forma segura y eficiente. --- ## Tipos de Emisores Redcumbre ofrece dos tipos de emisores según tu caso de uso: | Aspecto | Emisores Tributarios | Emisores DTE | |---------|---------------------|--------------| | **Propósito** | Delegación de credenciales SII (OAuth2-like) | Gestión de emisores para documentos tributarios | | **Autenticación** | Clave tributaria **O** Certificado digital | **SOLO** Certificado digital | | **Uso principal** | BHE, consultas SII, reportes | Facturas, boletas, notas de crédito | | **Estructura de datos** | Simple (RUT, razón social) | Compleja (direcciones, actividades, zona SII) | | **Control del usuario** | Usuario final controla sus credenciales | Administrador gestiona emisores | | **Múltiples direcciones** | No | Sí (sucursales, casa matriz) | --- ## Qué se hace por API y qué desde el panel El alta de un emisor **sí se puede hacer íntegramente por API**, pero el camino es **Tokenización SII**: tú abres la sesión desde tu backend, el contribuyente entrega sus credenciales en un wizard de Redcumbre, y tú recibes el `emisorId` de vuelta. No existen —ni están previstos— endpoints de creación directa de emisores con API key: la clave tributaria y el certificado digital nunca viajan por tu integración. | Operación | Por API | Cómo | |-----------|:-------:|------| | Dar de alta un emisor tributario, o uno o dos emisores DTE | ✅ | `POST /{tenantSlug}/tokenizacion/init-session` o `/invite` | | Obtener el `emisorId` tras la autorización | ✅ | `POST /{tenantSlug}/tokenizacion/exchange` o el webhook `tokenizacion.completada` | | Actualizar credenciales / renovar certificado | ✅ | Nueva sesión de tokenización sobre el mismo RUT (`emitterOperation: "UPDATE"`) | | Revocar un emisor | ✅ | `POST /{tenantSlug}/tokenizacion/revoke` | | Consultar datos SII de un RUT | ✅ | `POST /{tenantSlug}/herramientas/lookup-rut` — ver [Lookup RUT](/guias/lookup-rut/) | | Listar los emisores del tenant | ❌ | Panel web (**Configuración → Emisores**) | | Consultar o eliminar el certificado almacenado | ❌ | Panel web | | Editar direcciones, sucursales y actividades económicas | ❌ | Panel web | :::caution[Guarda el `emisorId` cuando lo recibas] Hoy **no hay endpoint de listado de emisores con API key**. Los ids te llegan una sola vez, al completarse la tokenización: guárdalos en tu sistema junto al RUT. Una sesión DTE que pide facturación y boletas devuelve **dos** emisores en el campo `emisores`, y los dos hay que guardarlos. Si los pierdes, tienes que consultarlos en el panel web o volver a tokenizar el mismo RUT (que devuelve los mismos emisores con `emitterOperation: "UPDATE"`). ::: --- ## Flujos de Integración Existen **4 flujos** para integrar la tokenización de credenciales, según cómo quieras recibir la confirmación. Estos flujos aplican tanto para **Emisores Tributarios** como para **Emisores DTE**. :::tip[¿Cuál flujo elegir?] - **Redirect**: Cuando necesitas feedback inmediato al usuario en tu app - **Webhook**: Cuando el procesamiento es backend y el usuario puede cerrar la ventana - **Dual**: Cuando necesitas ambos (redundancia) - **Invite**: Cuando prefieres que Redcumbre envíe el email de invitación ::: --- ### Flujo 1: Redirect con Code (OAuth2 clásico) Ideal cuando tu aplicación necesita **feedback inmediato al usuario** después de autorizar. ``` Tu App Redcumbre Usuario │ │ │ │── POST /init-session ────▶│ │ │ (returnUrl) │ │ │◀── authorizationUrl ──────│ │ │ │ │ │── Redirige usuario ───────┼─────────────────────────▶│ │ │ │ │ │◀── Completa wizard ──────│ │ │ (ingresa credenciales)│ │ │ │ │◀── Redirect returnUrl?code=xyz ─────────────────────│ │ │ │ │── POST /exchange ────────▶│ │ │ (code) │ │ │◀── emisorId, rut, etc ────│ │ ``` **Cuándo usarlo:** - Quieres mostrar confirmación inmediata al usuario en tu app - Necesitas el `emisorId` para continuar un flujo en tu frontend - Patrón OAuth2 tradicional **Configuración:** ```json { "returnUrl": "https://tu-app.com/callback", "tipoAutenticacion": "CLAVE_TRIBUTARIA", "tipoEmisor": "tributario" } ``` --- ### Flujo 2: Webhook (Server-to-Server) Ideal para **procesamiento backend** sin necesidad de redirect. ``` Tu App Redcumbre Usuario │ │ │ │── POST /init-session ────▶│ │ │ (webhookUrl) │ │ │◀── authorizationUrl ──────│ │ │ │ │ │── Comparte enlace ────────┼─────────────────────────▶│ │ (email, WhatsApp, etc) │ │ │ │◀── Completa wizard ──────│ │ │ │ │ │── Muestra éxito ────────▶│ │ │ │ │◀── POST webhook ──────────│ │ │ (emisorId, rut, etc) │ │ ``` **Cuándo usarlo:** - No necesitas redirect al usuario (puede cerrar la ventana) - Procesamiento asíncrono en tu backend - Flujos donde compartes el enlace manualmente (WhatsApp, SMS) **Configuración:** ```json { "webhookUrl": "https://tu-app.com/webhooks/tokenizacion", "webhookHeaders": { "Authorization": "Bearer tu-secret" }, "tipoAutenticacion": "CLAVE_TRIBUTARIA", "tipoEmisor": "tributario" } ``` --- ### Flujo 3: Dual (Redirect + Webhook) Ideal cuando necesitas **ambos**: feedback al usuario Y procesamiento backend. ``` Tu App Redcumbre Usuario │ │ │ │── POST /init-session ────▶│ │ │ (returnUrl + webhookUrl)│ │ │◀── authorizationUrl ──────│ │ │ │ │ │── Redirige usuario ───────┼─────────────────────────▶│ │ │◀── Completa wizard ──────│ │ │ │ │◀── Redirect returnUrl?code=xyz ─────────────────────│ │◀── POST webhook ──────────│ (en paralelo) │ ``` **Cuándo usarlo:** - Frontend necesita confirmar al usuario - Backend necesita procesar sin depender del frontend - Redundancia: si el redirect falla, el webhook llega igual **Configuración:** ```json { "returnUrl": "https://tu-app.com/callback", "webhookUrl": "https://tu-app.com/webhooks/tokenizacion", "tipoAutenticacion": "CLAVE_TRIBUTARIA", "tipoEmisor": "tributario" } ``` --- ### Flujo 4: Invitación por Email Ideal para **onboarding masivo** o cuando prefieres que Redcumbre envíe el email. ``` Tu App Redcumbre Usuario │ │ │ │── POST /invite ──────────▶│ │ │ (rut, email) │ │ │◀── sessionId, url ────────│ │ │ │── Email invitación ─────▶│ │ │ │ │ │◀── Completa wizard ──────│ │ │ │ │◀── POST webhook ──────────│ │ │ (usa config del tenant) │ │ ``` **Cuándo usarlo:** - Quieres que Redcumbre envíe el email (con tu branding) - Onboarding de múltiples usuarios - No quieres gestionar el envío de enlaces **Configuración:** ```json { "rut": "77438768-4", "email": "usuario@empresa.cl", "tipoAutenticacion": "CLAVE_TRIBUTARIA", "tipoEmisor": "tributario" } ``` :::note El flujo de invitación usa la configuración de webhook de tu tenant (si existe). No necesitas enviar `webhookUrl` en cada request. ::: --- ### Resumen de Flujos | Flujo | returnUrl | webhookUrl | Usa `/exchange`? | Usuario ve | |-------|-----------|------------|------------------|------------| | **Redirect** | ✅ | Config tenant | ✅ Sí | Redirect a tu app | | **Webhook** | ❌ | ✅ | ❌ No | Pantalla de éxito | | **Dual** | ✅ | ✅ | Opcional | Redirect a tu app | | **Invite** | ❌ | Config tenant | ❌ No | Pantalla de éxito | :::note[De dónde sale la URL del webhook] Si envías `webhookUrl` en `init-session`, el evento va a esa URL y no se filtra por los eventos que hayas habilitado en el panel: pedirla en el body ya es la suscripción. Si no la envías, el evento va a la URL configurada en **Configuración → Webhooks** de tu tenant, siempre que esté activa y `tokenizacion.completada` esté habilitado (la lista vacía habilita todos). Es decir: con la configuración del panel activa recibes el webhook aunque uses el flujo Redirect. ::: --- ## Emisores Tributarios ### ¿Qué son? Los **Emisores Tributarios** permiten que tus usuarios finales autoricen el uso de sus credenciales SII de forma segura, sin que tu plataforma las vea directamente. Funciona como un flujo OAuth2 donde el usuario mantiene control total. Ideal para plataformas SaaS que necesitan realizar operaciones tributarias en nombre de sus usuarios (consultas al SII, emisión de BHE, etc.) sin comprometer la seguridad de sus credenciales. ### Sistema de Invitación (Dual Mode) Invita a tus clientes a autorizar sus credenciales de forma profesional. #### Modo Email Directo - Sistema envía email automático al usuario final - Incluye información de tu empresa (nombre, logo) - Enlace único y seguro al wizard de autorización - Enlace de revocación para control total #### Modo "Copiar Enlace" - Sistema genera enlace único - Tú decides cómo y cuándo compartirlo (WhatsApp, email manual, SMS, etc.) - Ideal para flujos personalizados o comunicación directa - Mismo nivel de seguridad que modo email **Ventajas:** - ✅ **Flexibilidad**: Elige el modo que mejor se adapte a tu flujo de trabajo - ✅ **Branding**: Todos los emails incluyen tu información empresarial - ✅ **Notificaciones automáticas**: Usuario recibe confirmación al autorizar ### Sistema de Revocación El usuario final tiene control **TOTAL** sobre sus credenciales en todo momento. **¿Cómo funciona?** 1. Usuario accede al enlace de revocación (recibido por email) 2. Wizard muestra información de la autorización 3. Usuario confirma revocación validando su identidad con clave tributaria SII 4. Sistema elimina credenciales y notifica a ambas partes **Ventajas:** - ✅ **Control total del usuario**: Puede revocar en cualquier momento sin contactar a tu empresa - ✅ **Tokens de un solo uso**: Cada enlace de revocación es único y expira en 24 horas - ✅ **Validación de identidad**: Requiere clave tributaria SII para confirmar la revocación - ✅ **Notificaciones bidireccionales**: Usuario y empresa reciben confirmación - ✅ **Webhooks**: Tu sistema se entera instantáneamente vía webhook ### Revocación por API (Administrador) Además de la revocación por el usuario final, puedes revocar emisores directamente desde tu backend usando la API. ```bash POST /tu-tenant/tokenizacion/revoke Content-Type: application/json Authorization: Bearer tu-api-key { "emisorId": "uuid-del-emisor", "tipoEmisor": "tributario", "motivoRevocacion": "Cambio de proveedor de servicios" } ``` **Parámetros:** | Campo | Tipo | Requerido | Descripción | |-------|------|-----------|-------------| | `emisorId` | UUID | ✅ | ID del emisor a revocar | | `tipoEmisor` | string | ✅ | `tributario` o `dte` | | `motivoRevocacion` | string | ❌ | Motivo (max 500 chars). Se incluye en el email de notificación | **Response:** ```json { "success": true, "message": "Emisor revocado exitosamente. Se ha enviado una notificación al usuario.", "emisor": { "id": "uuid-del-emisor", "rut": "77438768-4", "razonSocial": "EMPRESA EJEMPLO SPA", "revokedAt": "2025-01-15T10:30:00Z" } } ``` **Comportamiento:** - Marca el emisor como revocado (`revokedBy: 'ADMIN'`) - Envía email de notificación al usuario final del emisor - Encola webhook `emisor.revocado` a tu sistema - **NO** valida credenciales SII (autoridad del API Key/Admin) :::caution[Importante] La revocación por API es una acción administrativa. El usuario final recibirá un email informando que su autorización fue revocada por el administrador. ::: ### Métodos de Autenticación Los emisores tributarios soportan **dos métodos** de autenticación con el SII: | Método | Campo | Descripción | |--------|-------|-------------| | **Clave Tributaria** | `CLAVE_TRIBUTARIA` | Clave web del SII. Más simple, la mayoría de usuarios la tiene | | **Certificado Digital** | `CERTIFICADO_DIGITAL` | Archivo PFX/P12 + contraseña. Más seguro, usado por empresas con firma electrónica avanzada | El método se elige al abrir la sesión de tokenización, con el campo `tipoAutenticacion` de `init-session` o `invite`. Queda registrado en el emisor y determina qué se le pide al titular en el wizard: su clave del SII, o el archivo `.pfx` con su contraseña. ### Integración Rápida #### 1️⃣ Iniciar Sesión de Tokenización **Con clave tributaria:** ```bash POST /tu-tenant/tokenizacion/init-session Content-Type: application/json Authorization: Bearer tu-api-key { "rut": "77438768-4", "email": "usuario@empresa.cl", "tipoAutenticacion": "CLAVE_TRIBUTARIA", "webhookUrl": "https://tu-app.com/webhooks/tokenizacion" } ``` **Con certificado digital:** ```bash POST /tu-tenant/tokenizacion/init-session Content-Type: application/json Authorization: Bearer tu-api-key { "rut": "77438768-4", "email": "usuario@empresa.cl", "tipoAutenticacion": "CERTIFICADO_DIGITAL", "tipoEmisor": "tributario", "webhookUrl": "https://tu-app.com/webhooks/tokenizacion" } ``` :::note Cuando `tipoAutenticacion` es `CERTIFICADO_DIGITAL` y `tipoEmisor` es `tributario`, el certificado se almacena en un vault independiente exclusivo para emisores tributarios (separado del vault de emisores DTE). ::: **Response:** ```json { "success": true, "data": { "sessionId": "a1b2c3d4-5678-90ab-cdef-1234567890ab", "authorizationUrl": "https://app.redcumbre.cl/p/tokenizacion/a1b2c3d4-5678-90ab-cdef-1234567890ab/authorize", "emitterOperation": "CREATE", "lookupData": { "rut": "77438768-4", "razonSocial": "EMPRESA EJEMPLO SPA", "portalMipymeHabilitado": true, "tipoContribuyente": "EMPRESA" } } } ``` `emitterOperation` te dice de antemano si el RUT va a crear un emisor nuevo (`CREATE`) o actualizar uno que ya existe en tu tenant (`UPDATE`). #### 2️⃣ Compartir Enlace con Usuario Recibes `authorizationUrl` para compartir con tu usuario (email, WhatsApp, SMS, etc.) El wizard incluye: - **CAPTCHA proof-of-work** (Altcha) para protección contra bots - **Consentimiento explícito** (Ley 21.719) para tratamiento de credenciales #### 3️⃣ Recibir Confirmación Tu webhook recibe notificación cuando el usuario completa el proceso: ```json { "event": "tokenizacion.completada", "sessionId": "c6c53392-bdb0-4053-8b9d-5b34296d63a3", "code": "83aK0esV0V21u8-krvuMiXx1OqheSVy56E6tBG3OjX8", "emisorId": "cmikf6ei00001sek0mxzcf668", "rut": "77438768-4", "tipoAutenticacion": "CLAVE_TRIBUTARIA", "tipoEmisor": "tributario", "timestamp": "2025-01-15T10:30:00Z" } ``` #### 4️⃣ Canjear el código (flujo Redirect) Si usaste `returnUrl`, el usuario vuelve a tu aplicación con `?code=...`. Ese código se canjea una sola vez: ```bash POST /tu-tenant/tokenizacion/exchange Content-Type: application/json Authorization: Bearer tu-api-key { "code": "83aK0esV0V21u8-krvuMiXx1OqheSVy56E6tBG3OjX8" } ``` **Response:** ```json { "success": true, "data": { "emisorId": "cmikf6ei00001sek0mxzcf668", "rut": "77438768-4", "tipoAutenticacion": "CLAVE_TRIBUTARIA", "tipoEmisor": "tributario" } } ``` ### De dónde sale el `emisorTributarioId` Es la pregunta que traba la primera integración, así que va explícita: :::tip[`emisorId` (tokenización) === `emisorTributarioId` (emisión)] El `emisorTributarioId` que piden `POST /{tenantSlug}/bhe` y `POST /{tenantSlug}/bhet` **es el `emisorId` que devuelve el flujo de tokenización**. Lo obtienes de dos formas, y ambas entregan el mismo valor: - En la respuesta de `POST /{tenantSlug}/tokenizacion/exchange` → `data.emisorId` - En el webhook `tokenizacion.completada` → `emisorId` (*"ID del emisor tributario creado/actualizado"*) También está visible en el panel web, en **Configuración → Emisores**. ::: ``` POST /tu-tenant/tokenizacion/init-session (o /invite) │ ▼ el titular autoriza en el wizard │ ├── webhook tokenizacion.completada → emisorId └── POST /tu-tenant/tokenizacion/exchange → data.emisorId │ ▼ POST /tu-tenant/bhe { "emisorTributarioId": "", ... } ``` Es un identificador opaco: trátalo como texto, no lo construyas ni lo interpretes. Es estable para ese RUT dentro de tu tenant, así que si el contribuyente vuelve a autorizar, el flujo devuelve el **mismo** `emisorId` con `emitterOperation: "UPDATE"`. :::caution[Sólo sirve el id de un emisor tributario] El campo se llama `emisorTributarioId` literalmente: acepta el `emisorId` de una tokenización con `tipoEmisor: "tributario"`. El id de un **emisor DTE** identifica otra entidad y no se puede usar para emitir BHE ni BHET. El webhook te dice cuál es cuál en el campo `tipoEmisor`. ::: ### Certificado Digital del Emisor Tributario Para emisores con `tipoAutenticacion: "CERTIFICADO_DIGITAL"`, el archivo PFX **entra siempre por el wizard de tokenización**: lo sube el propio titular del certificado, en el enlace que tú le compartes. Tu integración nunca manipula el `.pfx` ni su contraseña. :::caution[No hay carga de certificados por API] Redcumbre **no expone endpoints para subir, consultar ni eliminar el certificado con API key**. Es una decisión de diseño: un PFX es la firma electrónica avanzada del contribuyente, y el modelo de la plataforma es que sólo él lo entregue, en el wizard, con consentimiento explícito. ::: #### Alta con certificado digital Una sola llamada — el certificado lo aporta el titular en el wizard: ```bash POST /tu-tenant/tokenizacion/invite Content-Type: application/json Authorization: Bearer tu-api-key { "rut": "77438768-4", "email": "titular@empresa.cl", "tipoAutenticacion": "CERTIFICADO_DIGITAL", "tipoEmisor": "tributario" } ``` Al completar el wizard, el sistema valida el PFX (RUT, emisor, vigencia), lo cifra con AES-256-GCM en el vault de emisores tributarios y te avisa por webhook con el `emisorId`. #### Renovar el certificado No hay operación de "renovación" aparte: **abres una sesión de tokenización nueva sobre el mismo RUT** y el titular sube el PFX vigente. El emisor no se duplica, el certificado anterior se reemplaza (UPSERT, sin histórico) y la respuesta trae `emitterOperation: "UPDATE"` con el mismo `emisorId` de siempre. #### Consultar o eliminar el certificado Son operaciones **del panel web** (**Configuración → Emisores → Certificado**): ahí ves la metadata —emisor de la firma, vigencia, serie— y puedes eliminarlo. No están disponibles por API. :::caution[Vencimiento] Un cron diario (06:00 AM Chile) detecta certificados por vencer (30 días) y desactiva automáticamente los vencidos. Cuando un certificado vence, el emisor queda con `tieneCredenciales: false` hasta que se suba uno nuevo mediante una nueva sesión de tokenización. ::: ### Reautorización ¿Qué pasa si un usuario ya autorizó previamente y quiere actualizar sus credenciales? **El sistema lo maneja automáticamente:** - Detecta si el emisor ya existe - Actualiza las credenciales (no crea duplicado) - Limpia estado de revocación previo - Genera nuevo token de revocación - Envía notificación de reautorización **Ventaja**: Usuario puede actualizar credenciales sin intervención de tu soporte. ### Estados del Emisor Tributario | Estado | Descripción | |--------|-------------| | `PENDIENTE` | Invitación enviada, esperando autorización del usuario | | `ACTIVO` | Credenciales válidas, puede realizar operaciones tributarias | | `REVOCADO` | Usuario revocó sus credenciales | | `EXPIRADO` | Credenciales expiraron o fueron invalidadas | --- ## Emisores DTE ### ¿Qué son? Los **Emisores DTE** son entidades habilitadas para emitir Documentos Tributarios Electrónicos (facturas, boletas, notas de crédito) ante el SII de Chile. La plataforma permite gestionar múltiples emisores de forma centralizada, segura y eficiente. Ideal para empresas con múltiples sucursales, holdings empresariales, o cualquier organización que necesite administrar varios RUTs emisores desde un solo lugar. ### Características Principales #### Gestión Centralizada Administre todos los RUTs emisores de su organización desde un solo lugar: - **Múltiples sucursales**: Registre cada sucursal como un emisor independiente - **Datos completos**: Razón social, giro, direcciones, actividades económicas - **Certificados digitales**: Asocie certificados digitales a cada emisor - **Emisor por defecto**: Marque el emisor principal para operaciones automáticas #### Consulta Automática al SII Simplifique la creación de emisores con integración directa al SII: - **Búsqueda por RUT**: Consulte cualquier contribuyente registrado en el SII - **Prellenado automático**: Los datos se cargan automáticamente desde el SII - **Información actualizada**: Razón social, direcciones, actividades económicas verificadas - **Zona tributaria**: Identificación automática de la unidad SII correspondiente #### Gestión de Direcciones y Sucursales Administre todas las ubicaciones de su empresa: - **Múltiples direcciones**: Registre todas las sucursales y puntos de venta - **Casa matriz**: Identifique claramente su domicilio legal - **Dirección por defecto**: Configure la dirección principal para facturación - **Zona SII automática**: Sistema inteligente de asignación de zona tributaria #### Actividades Económicas Gestione las actividades económicas de cada emisor: - **Códigos ACTECO**: Base de datos completa con 674 actividades económicas - **Búsqueda inteligente**: Encuentre rápidamente la actividad correcta - **Múltiples actividades**: Registre todas las actividades de su empresa - **Actividad por defecto**: Marque la actividad principal ### Creación de Emisor DTE Un emisor DTE **siempre** se da de alta por tokenización con certificado digital: es el único modo de que el certificado del representante legal llegue a la plataforma sin pasar por tu integración. Redcumbre resuelve por ti el lookup al SII, las direcciones, las actividades económicas y el representante legal a partir del certificado. #### Paso 1 (opcional): Verificar el RUT en el SII Antes de invitar puedes consultar los datos del contribuyente —incluido si tiene Portal MiPyme habilitado— con el endpoint de lookup: ```bash POST /tu-tenant/herramientas/lookup-rut Content-Type: application/json Authorization: Bearer tu-api-key { "rut": "77438768-4" } ``` Detalle de la respuesta, caché y modo sandbox en la guía de [Lookup RUT](/guias/lookup-rut/). #### Paso 2: Declarar qué emisores quieres La configuración de emisión tiene **dos dimensiones independientes**, y tienes que pedir al menos una: | Dimensión | Campo | Valores | |-----------|-------|---------| | **Facturación** | `tipoIntegracion` | `PORTAL_MIPYME` (emisión vía Portal MiPyme del SII) o `FULL_DTE` (API oficial del SII) | | **Boletas** | `boletas` | `true` para pedir además el emisor de boletas del mismo RUT | Combinan libremente: | Lo que pides | Emisores que quedan | |--------------|---------------------| | `"tipoIntegracion": "FULL_DTE"` | uno de facturación | | `"boletas": true` | uno de boletas | | `"tipoIntegracion": "FULL_DTE"` + `"boletas": true` | **dos emisores para el mismo RUT** | :::caution[El sistema no elige el carril por ti] Una configuración a medias —`emailEmpresa` sin `tipoIntegracion` ni `boletas: true`, por ejemplo— se rechaza con **400**, y por `/invite` también se rechaza un cuerpo DTE que no declare ninguno de los dos. Es deliberado: el emisor que quedaría creado no sería el que pediste, y lo descubrirías después de que el titular ya subió su certificado. ::: #### Paso 3: Invitar al emisor DTE ```bash POST /tu-tenant/tokenizacion/invite Content-Type: application/json Authorization: Bearer tu-api-key { "rut": "77438768-4", "email": "autorizador@empresa.cl", "emailEmpresa": "facturacion@empresa.cl", "tipoAutenticacion": "CERTIFICADO_DIGITAL", "tipoEmisor": "dte", "tipoIntegracion": "FULL_DTE", "boletas": true, "contactoCertificacionEmail": "certificacion@empresa.cl" } ``` | Campo | Requerido para DTE | Descripción | |-------|:------------------:|-------------| | `tipoAutenticacion` | ✅ | Siempre `CERTIFICADO_DIGITAL`: los emisores DTE no aceptan clave tributaria | | `tipoIntegracion` | ⚠️ | El carril de facturación. Obligatorio salvo que la sesión pida **únicamente** boletas | | `boletas` | ⚠️ | `true` para pedir el emisor de boletas. Obligatorio si no pides facturación | | `email` | ✅ | Email del **autorizador**: recibe la invitación y el enlace de revocación | | `emailEmpresa` | ✅ | Email de la **empresa**, el que aparece en la factura. Es distinto del anterior | | `contactoCertificacionEmail` | — | Contacto para la correspondencia de la certificación ante el SII. Si lo omites, se usa el email de la empresa | | `contactoCertificacionTelefono` | — | Teléfono de contacto para la certificación | :::note[`init-session` acepta exactamente la misma configuración] Si prefieres el flujo Redirect en vez del email de invitación, `init-session` recibe los mismos campos, con las mismas validaciones y los mismos rechazos. Lo único que cambia es cómo llega el titular al formulario y cómo vuelve el resultado: en `init-session` el RUT es opcional y decides entre `returnUrl`, `webhookUrl` o los dos. ::: Si pides `PORTAL_MIPYME` y el RUT no lo tiene habilitado en el SII, la respuesta es **400** antes de enviar nada. Lo mismo si tu tenant no tiene contratado el servicio del carril que pides, o si el RUT ya tiene un emisor incompatible con la configuración. #### Paso 4: Recibir los emisores creados El webhook `tokenizacion.completada` (o el `/exchange` si usaste `returnUrl`) trae el `emisorId` del emisor DTE, con `tipoEmisor: "dte"`, y el arreglo `emisores` con **todos** los que creó la sesión: ```json { "emisorId": "cmikf6ei00001sek0mxzcf668", "emisores": [ { "emisorId": "cmikf6ei00001sek0mxzcf668", "carril": "FULL_DTE", "estado": "ESPERA_CERTIFICACION", "motivoInactivo": "PENDIENTE_CERTIFICACION", "tiposHabilitados": [] }, { "emisorId": "cmr8b52zn000fseodrcbqvr62", "carril": "SOLO_BOLETA", "estado": "ESPERA_CERTIFICACION", "motivoInactivo": "PENDIENTE_CERTIFICACION", "tiposHabilitados": [] } ] } ``` `emisorId` sigue apuntando a **un** emisor: el de facturación si pediste uno; si la sesión fue sólo boletas, el de boletas. `emisores` es la única forma de conocer el id del segundo. #### La certificación tarda días, y cada carril termina por su cuenta `FULL_DTE` y `SOLO_BOLETA` exigen certificación ante el SII. El emisor **nace inactivo** (`estado: "ESPERA_CERTIFICACION"`, `tiposHabilitados: []`) y queda operativo cuando el SII resuelve, lo que se mide en días. `PORTAL_MIPYME` no certifica: nace operativo. :::caution[Dos carriles son dos certificaciones, y no terminan juntas] Si pediste facturación y boletas, son **dos procesos independientes**. Recibes **un webhook `emisor.certificado` por cada uno**, en momentos distintos, y el payload trae el `tipoIntegracion` para que sepas cuál llegó. No esperes un único aviso de cierre, y no bloquees la emisión de un carril esperando al otro. ::: El campo `pronostico` de la respuesta del alta te dice, carril por carril y antes de que el titular haga nada, cuál va a nacer operativo y cuál va a quedar esperando. Las direcciones, sucursales y actividades económicas se cargan automáticamente desde el SII y se administran después desde el panel web — no hay endpoints de edición con API key. ### De dónde sale el `emisorDteId` Es el campo obligatorio de `POST /{tenantSlug}/dte`, así que va explícito — igual que su equivalente tributario. :::tip[`emisorId` (tokenización) === `emisorDteId` (emisión)] El `emisorDteId` que pide `POST /{tenantSlug}/dte` **es el `emisorId` del emisor DTE**. Lo obtienes de dos formas, según cómo se dio de alta el emisor: - **Panel web** — **Configuración → Emisores**: abre el emisor y copia el campo **ID**. Es el camino cuando el emisor ya estaba provisionado en tu tenant. - **Tokenización** — en la respuesta de `POST /{tenantSlug}/tokenizacion/exchange` (`data.emisorId`) o en el webhook `tokenizacion.completada`, en ambos casos con `tipoEmisor: "dte"`. ::: Es un CUID (por ejemplo `clxyz123abc456def`), estable para ese RUT y ese `tipoIntegracion` dentro de tu tenant: si el titular vuelve a autorizar, el flujo devuelve el **mismo** `emisorId` con `emitterOperation: "UPDATE"`. :::caution[Es configuración: guárdalo una vez] No hay endpoint de listado de emisores con API key. El `emisorDteId` se fija al dar de alta el emisor y no cambia, así que guárdalo en tu sistema junto al RUT. Si lo pierdes, tienes que consultarlo en el panel web o volver a tokenizar el mismo RUT. Un mismo RUT puede tener **más de un emisor DTE** en el tenant: uno de facturación (`PORTAL_MIPYME` **o** `FULL_DTE`, nunca los dos) y uno de boletas (`SOLO_BOLETA`). Por eso la emisión pide el id y no el RUT: el id dice exactamente por cuál de ellos sale el documento. ::: :::caution[El emisor de boletas no anula boletas] La nota de crédito que anula una boleta es un documento de **facturación**: la emite el emisor de facturación del mismo RUT, no el de boletas. Si tu cliente sólo tiene el emisor de boletas, la anulación la hace directamente en el Portal MiPyme del SII — no hay forma de hacerla por API. Es la razón práctica para pedir los dos carriles cuando el cliente anula boletas con alguna frecuencia. ::: :::note[No confundir con el `emisorTributarioId`] El id de un emisor DTE identifica otra entidad que el de un emisor tributario: no sirve para emitir BHE ni BHET, y al revés tampoco. Ver [De dónde sale el `emisorTributarioId`](#de-dónde-sale-el-emisortributarioid). ::: ### Estados del Emisor DTE | Estado | Descripción | |--------|-------------| | `PENDIENTE` | Creado, pendiente de configuración completa | | `ACTIVO` | Certificado válido, puede emitir documentos | | `REVOCADO` | Administrador revocó el emisor | | `EXPIRADO` | Certificado expirado o inválido | --- ## Características Compartidas ### 🔒 Seguridad #### Almacenamiento Cifrado - Claves tributarias se almacenan cifradas con **AES-256-GCM** + PBKDF2 - Certificados digitales (PFX) se almacenan cifrados con **AES-256-GCM** en vault independiente - Salt único por credencial, key derivation basada en RUT - Certificados de emisores tributarios y emisores DTE usan vaults separados (cero acoplamiento) - Nunca se almacenan en texto plano #### Validación en Tiempo Real - Todas las credenciales se validan contra el SII **ANTES** de aceptarlas - No se almacenan credenciales inválidas - Validación de vigencia de certificados digitales - Verificación de coincidencia de RUT entre certificado y emisor #### Protección del Wizard Público - **CAPTCHA Altcha** (proof-of-work): protección contra bots en el endpoint de completar sesión - **Consentimiento explícito** (Ley 21.719): checkbox obligatorio con texto específico al tratamiento de credenciales. Se registra timestamp, IP y texto completo del consentimiento para cumplimiento legal #### Tokens de Seguridad | Token | Uso | Expiración | |-------|-----|------------| | **Tokens de revocación** | Un solo uso | 24 horas | | **Authorization codes** | Un solo uso para intercambio | 10 minutos | | **Sesiones de tokenización** | Múltiple hasta completar | 7 días | ### 📧 Transparencia Total - Usuario recibe email al autorizar (con enlace de revocación) - Usuario recibe email al revocar - Empresa recibe webhook en ambos eventos - Trazabilidad completa de todas las acciones ### 🛡️ Control de Acceso y Auditoría - **Roles y permisos**: Control granular de acceso (Administradores, Operadores) - **Registro de actividades**: Auditoría completa de todas las operaciones - **Trazabilidad**: Histórico de cambios con usuario y fecha - **Multi-tenant**: Aislamiento total entre organizaciones --- ## Webhooks ### Eventos Disponibles | Evento | Descripción | Aplica a | |--------|-------------|----------| | `tokenizacion.completada` | Usuario completó la autorización | Ambos | | `emisor.revocado` | Usuario/admin revocó credenciales | Ambos | ### Payload: tokenizacion.completada ```json { "event": "tokenizacion.completada", "tenantId": "8", "sessionId": "c6c53392-bdb0-4053-8b9d-5b34296d63a3", "code": "83aK0esV0V21u8-krvuMiXx1OqheSVy56E6tBG3OjX8", "emisorId": "cmikf6ei00001sek0mxzcf668", "rut": "77438768-4", "tipoAutenticacion": "CLAVE_TRIBUTARIA", "tipoEmisor": "tributario", "correlationId": "proceso-interno-123", "lookupData": { "razonSocial": "EMPRESA EJEMPLO SPA", "giro": "SERVICIOS INFORMATICOS", "direcciones": [ { "ciudad": "SANTIAGO", "comuna": "PROVIDENCIA", "direccion": "AV. PROVIDENCIA 1234", "comunaId": 180 } ], "actividadesEconomicas": [ { "codigo": "620900", "descripcion": "ACTIVIDADES DE TECNOLOGÍA DE LA INFORMACIÓN" } ], "tipoContribuyente": "EMPRESA", "portalMipymeHabilitado": true }, "timestamp": "2025-01-15T10:30:00Z" } ``` | Campo | Descripción | |-------|-------------| | `tipoAutenticacion` | `CLAVE_TRIBUTARIA` o `CERTIFICADO_DIGITAL` | | `tipoEmisor` | `tributario` o `dte` | | `code` | Authorization code (usar con `/exchange` si aplica) | | `correlationId` | ID de correlación enviado en `init-session` (opcional) | | `lookupData` | Datos del contribuyente obtenidos del SII (puede ser `null`) | ### Payload: emisor.revocado ```json { "event": "emisor.revocado", "data": { "emisorId": "cmikf6ei00001sek0mxzcf668", "emisorType": "tributario", "rut": "77438768-4", "razonSocial": "EMPRESA EJEMPLO SPA", "tenantId": "8", "revokedBy": "USER", "motivoRevocacion": null }, "timestamp": "2025-01-15T10:30:00Z" } ``` | Campo | Descripción | |-------|-------------| | `emisorType` | `tributario` o `dte` | | `revokedBy` | `USER` (usuario final) o `ADMIN` (administrador del tenant) | | `motivoRevocacion` | Motivo de revocación (solo cuando `revokedBy: ADMIN`) | ### Configuración de Webhooks Tu sistema puede recibir notificaciones en tiempo real configurando: - **URL del webhook**: Endpoint HTTPS donde recibirás los eventos - **Headers personalizados**: Para autenticación (ej: `Authorization: Bearer token`) - **Eventos habilitados**: Selecciona qué eventos recibir :::note[Reintentos] Los webhooks se reintentan hasta **6 veces** con backoff exponencial si tu endpoint no responde con 2xx. ::: --- ## Casos de Uso ### 📄 Boletas de Honorarios Electrónicas **Tipo:** Emisores Tributarios Permite a profesionales independientes autorizar credenciales para que puedas emitir BHE en su nombre. - Usuario autoriza vía wizard seguro - Validación automática contra SII - Usuario mantiene control total ### 📊 Plataformas de Facturación Electrónica **Tipo:** Ambos Gestiona emisores para emitir DTEs (facturas, boletas, notas de crédito) en nombre de tus clientes. - Emisores Tributarios: para delegación de credenciales - Emisores DTE: para gestión centralizada ### 🏦 Sistemas Financieros **Tipo:** Emisores Tributarios Consulta información tributaria del SII para validación de clientes o scoring crediticio. - Lookup automático de datos SII - Validación de credenciales en tiempo real - Sin almacenamiento de credenciales sensibles ### 🏢 Empresas Multi-Sucursal **Tipo:** Emisores DTE **Ejemplo:** Cadena de retail con 50 tiendas - Registre cada tienda como emisor independiente - Mantenga certificados digitales centralizados - Administre permisos por sucursal - Consulte actividad histórica por ubicación ### 🏛️ Holdings Empresariales **Tipo:** Emisores DTE **Ejemplo:** Grupo empresarial con 10 sociedades - Administre todas las sociedades desde una plataforma - Certificados digitales centralizados - Visión consolidada de operaciones - Control de acceso por empresa ### 📈 Software de Contabilidad **Tipo:** Emisores Tributarios Automatiza la obtención de información tributaria de tus usuarios. - Integración simple vía API - Datos verificados del SII - Webhooks para sincronización --- ## Resumen de Ventajas | Característica | Beneficio | |---------------|-----------| | **Validación contra SII** | Solo se aceptan credenciales válidas | | **Control del usuario** | Usuario puede revocar en cualquier momento | | **Dual mode** | Email automático o compartir enlace manual | | **Tokens de seguridad** | Un solo uso, con expiración | | **Cifrado AES-256-GCM** | Credenciales y certificados protegidos en reposo | | **Certificado digital** | Emisores tributarios pueden autenticarse con PFX además de clave tributaria | | **CAPTCHA + Consentimiento** | Wizard protegido contra bots y conforme a Ley 21.719 | | **Webhooks en tiempo real** | Tu sistema se entera al instante de cambios | | **Pre-carga de datos** | Agiliza proceso para el usuario final | | **Lookup automático SII** | Obtiene datos del contribuyente automáticamente | | **Marca blanca** | Tu logo y nombre en todos los puntos de contacto | | **Múltiples sucursales** | Sin límite de direcciones por emisor DTE | | **Zona SII automática** | Cálculo automático según ubicación | | **Auditoría completa** | Registro de todas las operaciones | --- ## Testing (Sandbox) Utiliza el **modo sandbox** para probar la integración sin costo: - API Keys con `isSandbox: true` retornan datos de prueba - No se realizan llamadas reales al SII - Webhooks funcionan normalmente para validar tu integración **RUTs de prueba:** | RUT | Comportamiento | |-----|---------------| | `78012039-8` | Respuesta exitosa | | `77425402-1` | Múltiples resultados | | `99999999-9` | Simula error | --- ## API Reference Para detalles técnicos de implementación y especificaciones de endpoints: 👉 [Ver endpoints de Tokenización SII en Swagger](https://api.redcumbre.cl/api-docs#/Tokenizaci%C3%B3n%20SII) — alta, canje, invitación y revocación de emisores 👉 [Ver el endpoint de Lookup RUT en Swagger](https://api.redcumbre.cl/api-docs#/Herramientas%20DTE) — consulta de datos del contribuyente en el SII Los emisores tributarios y los emisores DTE **no tienen endpoints propios en la API pública**: se crean y actualizan por tokenización, y el resto de su administración vive en el panel web. --- # Evento de identidad acreditada Fuente: https://docs.redcumbre.cl/guias/identidad-acreditada/ :::tip[TL;DR - Acceso Rápido] - `kyc.identity.level_changed` llega cuando la revisión de identidad de la persona de **tu** transacción termina **aprobada**. - `data.transaction_status` te dice qué hacer: con `pending` puedes mandar a la persona al mismo enlace de autorización, con `authorized` la transacción ya quedó autorizada y con `closed` abres una transacción nueva. - `data.connection_at_approval` dice si **había una conexión viva al aprobar**, no si la persona está mirando. ::: Cuando una persona intenta autorizar con PIN-RUT y todavía no tiene su identidad acreditada, la plataforma la guía por el proceso de acreditación. La mayoría de las veces termina en minutos y la transacción sigue su curso normal, sin que tengas que hacer nada. **Pero no siempre.** Un caso puede quedar en revisión de una persona de nuestro equipo y resolverse horas o días después. Para entonces la persona puede seguir con la pantalla abierta o haberse ido hace rato, y la transacción puede seguir viva o ya no. Este evento te avisa que la revisión **se aprobó** y te dice en qué quedó **tu** transacción, para que decidas qué hacer. --- ## Cuándo se despacha | Situación | ¿Llega el evento? | |-----------|-------------------| | La revisión de identidad de la persona de tu transacción termina **aprobada** | **Sí** | | La acreditación se resuelve sola, con la persona en el flujo | No — la persona sigue en la misma pantalla y autoriza ahí mismo | | La revisión termina sin aprobarse | **No** | | La identidad aprobada no es la del RUT de tu transacción | **No** | :::note[Sólo informa una aprobación] No hay evento para una revisión que no se aprueba, y no lo va a haber: comunicar que una persona *no* quedó acreditada diría por qué, y eso es información de ella, no tuya. En ese caso te enteras cuando la transacción vence. ::: --- ## Quién lo recibe **Sólo la integración de la transacción cuya persona pasó por la revisión.** Si tienes varias integraciones, el aviso llega a la que abrió la transacción, no a todas. El destino se resuelve igual que el resto de tus webhooks de PIN-RUT: el `callback_url` de esa transacción y, si no lo declaraste, la URL de webhooks configurada para tu cuenta. :::caution[La lista de eventos habilitados] Si tu cuenta tiene una lista de eventos habilitados no vacía y usas PIN-RUT con acreditación de identidad —con PIN o con cara—, incluye `kyc.identity.level_changed`: sin él no recibes este aviso. Una lista vacía no filtra nada. ::: --- ## Payload ```json { "event": "kyc.identity.level_changed", "timestamp": "2026-09-14T18:40:12.051Z", "data": { "transaction_id": "8b5f2c1e-4d7a-4c3b-9e21-6f0a1d2b3c4d", "transaction_created_at": "2026-09-14T13:02:47.118Z", "approved_at": "2026-09-14T18:40:11.964Z", "transaction_status": "pending", "connection_at_approval": "none", "state": "orden-4581", "authentication_method": "pin" } } ``` | Campo | Tipo | Descripción | |-------|------|-------------| | `event` | string | Siempre `kyc.identity.level_changed` | | `timestamp` | string (ISO 8601) | Instante en que se despachó el aviso | | `data.transaction_id` | string (UUID) | La transacción que creaste y cuya persona pasó por la revisión | | `data.transaction_created_at` | string (ISO 8601) | Cuándo creaste esa transacción | | `data.approved_at` | string (ISO 8601) | Cuándo se aprobó la revisión | | `data.transaction_status` | string | En qué quedó la transacción al aprobarse: `pending`, `authorized` o `closed` | | `data.connection_at_approval` | string | Si había una conexión viva al aprobar: `open`, `none` o `unknown` | | `data.state` | string o `null` | El valor opaco que enviaste al crear la transacción | | `data.authentication_method` | string | La modalidad que pediste: `pin` o `face` | :::caution[Nada de la persona] El payload no lleva RUT, ni nombres, ni identificadores de la persona, ni qué se revisó, ni por qué había quedado en revisión, ni la fecha de expiración de la transacción. Eso no es una limitación temporal: es el contrato. ::: --- ## Qué hacer al recibirlo ### Según `transaction_status` | Valor | Qué significa | Qué puedes hacer | |-------|---------------|------------------| | `pending` | La transacción **sigue pendiente y no ha vencido** | Mandar a la persona al mismo enlace de autorización **ahora**: el plazo de la transacción sigue corriendo | | `authorized` | La transacción **ya está autorizada** | Nada más: la autorización te llega por su propio evento, `pin.transaction.authorized` | | `closed` | La transacción **ya no se puede autorizar**: venció, se canceló o falló | Crear una transacción nueva sobre el mismo RUT. La persona ya tiene su identidad acreditada | `transaction_status` se informa como estado y nunca como un plazo: el tiempo que le queda a una transacción pendiente no forma parte del aviso. ### Qué significa `connection_at_approval` | Valor | Qué significa | |-------|---------------| | `open` | Al aprobarse, la persona tenía abierta la pantalla de espera, en su celular o en su computador | | `none` | Al aprobarse, no había ninguna conexión viva | | `unknown` | No se pudo saber | :::caution[«Había una conexión viva» no es «la persona está mirando»] Un celular con la pantalla bloqueada o una red cortada terminan la conexión aunque la persona vaya a volver en un minuto. Con `pending` y `none` igual puedes mandarla al enlace de autorización. ::: ### Cuánto tiempo pasó La diferencia entre `data.transaction_created_at` y `data.approved_at` es cuánto tardó la revisión desde que creaste la transacción. Te sirve para decidir si todavía tiene sentido contactar a la persona. :::danger[Cómo saber a qué persona corresponde] El evento no trae el RUT. Guarda el `transaction_id` de cada transacción junto con el RUT de tu lado: cuando llegue el aviso, la correlación la haces con tus propios datos. **No existe ningún endpoint para consultar el estado de acreditación de un RUT**, y su ausencia es deliberada — sería un padrón de quién está verificado y quién no. ::: --- ## Seguridad y reintentos Se entrega con la misma firma HMAC y la misma política de reintentos que el resto de los webhooks de la plataforma. Si una entrega se reintenta, el aviso trae el mismo `transaction_id`: procésalo de forma idempotente. Ver [Webhooks](/guias/webhooks/). --- ## Relacionado - [PIN-RUT](/guias/pin-rut/) — el flujo completo, las dos modalidades y el cargo de la acreditación - [Webhooks](/guias/webhooks/) — configuración, firma HMAC y reintentos --- # Guías de integración Fuente: https://docs.redcumbre.cl/guias/ Esta sección reúne todas las guías de integración disponibles. Cada guía cubre un servicio o funcionalidad específica de la API de Redcumbre con ejemplos prácticos y documentación detallada. Si es tu primera integración, el punto de partida habitual es [Documentos Tributarios Electrónicos (DTE)](/guias/dte/) — facturas, notas de crédito y débito, guías de despacho y liquidaciones ante el SII. ¿Vas a integrar con la ayuda de un asistente de IA? Empieza por [Integrar con tu agente de IA](/primeros-pasos/integrar-con-ia/): una sola URL le da todo el contexto que necesita. :::tip[Navegación] En dispositivos móviles, usa el **menú de 3 líneas** (☰) en la parte superior para ver el índice completo de la documentación, o el **ícono de búsqueda** (🔍) para encontrar temas específicos. ::: ## Guías Disponibles --- # Lookup RUT: consulta de contribuyentes Fuente: https://docs.redcumbre.cl/guias/lookup-rut/ :::tip[TL;DR - Acceso Rápido] **Endpoint:** `POST /{tenantSlug}/herramientas/lookup-rut` 👉 [Ver endpoint en Swagger](https://api.redcumbre.cl/api-docs#/Herramientas%20DTE) ::: El endpoint de Lookup RUT permite consultar información de cualquier RUT chileno directamente desde el SII. Los datos incluyen razón social, direcciones, actividades económicas, fecha de autorización y más. Para una **persona natural** consulta además el carril de personas del SII, que resuelve su **nombre** y si está **fallecida** — incluso cuando no tiene inicio de actividades, caso en el que el resto del SII no sabe nada de ella. El sistema implementa un **caché inteligente** que evita consultas innecesarias al SII, optimizando costos y tiempos de respuesta. --- ## Requisitos Previos Antes de usar el endpoint necesitas: 1. Una **API Key** con uno de estos roles: `ADMIN`, `SUPER-ADMIN`, o `FULL-API` 2. El tenant debe tener **DTE habilitado** (`dteEnabled: true`) :::note[Importante] El lookup usa internamente un emisor MASTER para autenticarse con el SII. Esto es transparente para el usuario y no requiere configuración adicional. ::: --- ## Sistema de Caché y Billing El sistema mantiene un repositorio maestro de contribuyentes (`ContribuyenteMaestro`) que actúa como caché. Esto significa que **no todas las consultas generan cobro**. ### Cuándo se Cobra | Escenario | ¿Cobra? | Campo `source` | |-----------|:-------:|----------------| | Datos frescos en caché (< 180 días) | No | `LOCAL` | | Datos vencidos → consulta al SII | **Sí** | `SII` | | Modo Sandbox activo | No | N/A | | El SII responde sin datos, pero hay datos locales | **Sí** | `LOCAL_FALLBACK` | | No se pudo alcanzar al SII (red, timeout, 5xx) | No | `LOCAL_FALLBACK` | :::note[Un lookup que no llegó al SII no se cobra] `LOCAL_FALLBACK` cubre dos situaciones distintas: que el SII haya respondido y no tenga datos del RUT, o que no hayamos podido llegar al SII. Solo la primera se cobra — si no hubo consulta, no hay servicio prestado que facturar. ::: :::tip[Optimización de Costos] Si consultas el mismo RUT múltiples veces dentro de 180 días, solo la primera consulta genera cobro. Las siguientes usan el caché local. ::: ### Flujo de Decisión ``` POST /herramientas/lookup-rut │ ▼ ¿Modo Sandbox? ───SÍ───▶ Retorna fixture (NO billing) │ NO ▼ ¿DTE habilitado? ───NO───▶ Error 400 │ SÍ ▼ ¿Caché fresco (< 180 días)? ─SÍ─▶ Retorna del caché │ NO (NO billing) ▼ Consulta al SII (BILLABLE) │ ▼ Sincroniza con repositorio maestro │ ▼ Retorna datos + registra evento billing ``` ### Campos de Respuesta Relacionados La respuesta incluye campos que indican el origen de los datos: - **`source`**: `"SII"` | `"LOCAL"` | `"LOCAL_FALLBACK"` - De dónde provienen los datos - **`cacheHit`**: `true` | `false` - Si se usó el caché (no hubo consulta al SII) ### Forzar consulta al SII Si necesitas datos frescos sí o sí (por ejemplo, el contribuyente acaba de cambiar su casilla de intercambio), envía `forceRefresh: true` en el body. Salta el caché, consulta al SII y **siempre se cobra**. ```json { "rut": "78012039-8", "forceRefresh": true } ``` --- ## Personas naturales y empresas El **RUT decide a qué fuentes se pregunta**. El corte está en el cuerpo del RUT (la parte antes del guión): | Cuerpo del RUT | Qué se consulta | Bloques de la respuesta | |---|---|---| | Desde `50.000.000` — persona jurídica | Sólo el carril tributario | `data` | | Bajo `50.000.000` — persona natural | El carril tributario **y** el de personas | `data` + `persona` | En una empresa el carril de personas no aporta nada, así que no se ejercita y `persona` llega `null`. Una persona natural puede tener giro comercial o no tenerlo, y eso cambia lo que llega: | Caso | `data` | `persona` | |---|---|---| | Persona natural **con** inicio de actividades | Poblado | Poblado | | Persona natural **sin** inicio de actividades | `null` | Poblado | | Ningún registro del RUT en el SII | — | — (responde `404`) | :::caution[`data` puede llegar en `null`] Para una persona natural sin inicio de actividades el Portal MiPyme no tiene absolutamente nada que entregar: no hay razón social, ni dirección, ni actividades. Todo lo que se sabe de ella viaja en `persona`. Si tu integración asume que `data` siempre viene, agrégale la comprobación. **Antes este caso respondía `404`.** El `404` quedó reservado para cuando ninguna de las dos fuentes conoce el RUT. ::: ### El bloque `persona` | Campo | Tipo | Descripción | |-------|------|-------------| | `nombre` | string \| null | Nombre completo de la persona natural | | `fallecido` | boolean \| null | Si el SII la registra como fallecida | | `estadoSii` | string \| null | `PERSONA_NATURAL`, `PERSONA_FALLECIDA`, `PERSONA_JURIDICA`, `RUT_NO_ENCONTRADO` o `DESCONOCIDO` | ```json { "success": true, "data": null, "persona": { "nombre": "SOTO RAMIREZ CAMILA ANDREA", "fallecido": false, "estadoSii": "PERSONA_NATURAL" }, "consultaContribuyente": null, "source": "SII", "cacheHit": false } ``` :::note[`fallecido` es una marca, no una fecha] El SII entrega la condición pero **no la fecha de defunción**, así que no hay forma de saber desde cuándo. Y un `null` en ese campo significa *no se pudo consultar* — nunca *no está fallecida*. ::: :::note[La clasificación por rango decide a quién preguntar, no qué responder] El corte en `50.000.000` es una convención de asignación de RUT, no una regla publicada del SII. Cuando la fuente contradice al rango —responde `PERSONA_JURIDICA` para un RUT bajo el umbral— **manda la fuente**, y eso se lee en `persona.estadoSii`. ::: :::tip[No cambia lo que pagas] El bloque `persona` **no agrega ningún cargo**. Las métricas del lookup son las mismas de antes de que existiera: si la consulta salió del caché, sigue sin costar nada. ::: --- ## Modo Sandbox (Testing) El modo sandbox permite probar la integración sin realizar consultas reales al SII y sin generar cobros. ### Activación El modo sandbox se activa automáticamente cuando tu API Key tiene el flag `isSandbox: true` configurado en sus metadata. ### RUTs de Prueba Disponibles | RUT | Descripción | Resultado | |-----|-------------|-----------| | `78012039-8` | FIRERAISE SPA - Empresa completa con todos los datos | Éxito | | `77425402-1` | LA GRANJERA LIMITADA - Múltiples direcciones y sucursales | Éxito | | `13830230-k` | Persona natural con inicio de actividades | Éxito — `data` + `persona` | | `19673431-7` | Persona natural con datos tributarios, sin actividades económicas | Éxito — `data` + `persona` | | `20111222-0` | Persona natural **sin inicio de actividades** | Éxito — `data: null`, sólo `persona` | | `9876543-5` | Persona natural **fallecida**, sin inicio de actividades | Éxito — `data: null`, `persona.fallecido: true` | | `22222222-2` | Empresa con datos parciales (sin giro) | Éxito | | `11111111-1` | Empresa sin Portal Mipyme habilitado | Éxito | | `99999999-9` | RUT no encontrado | Error 404 | :::caution[Solo RUTs de Prueba] En modo sandbox, solo los RUTs listados arriba retornan datos. Cualquier otro RUT retorna un error indicando los RUTs disponibles. ::: :::caution[La respuesta sandbox no es idéntica a la de producción] En sandbox la respuesta trae `sandbox: true` y **no incluye** `consultaContribuyente`, `source` ni `cacheHit`: los fixtures cubren los bloques `data` y `persona`, y dentro de `data` tampoco traen `emailIntercambio`. Si tu integración depende de la casilla de intercambio o de los documentos autorizados, esa parte debe probarse contra producción. ::: --- ## Request ### Endpoint ``` POST /{tenantSlug}/herramientas/lookup-rut ``` ### Headers ``` Authorization: Bearer {api_key} Content-Type: application/json ``` ### Body ```json { "rut": "78012039-8" } ``` | Campo | Tipo | Requerido | Descripción | |-------|------|:---------:|-------------| | `rut` | string | Sí | RUT a consultar en formato `12345678-9` | | `forceRefresh` | boolean | No | `true` ignora el caché y consulta al SII (siempre cobrable). Por defecto `false` | :::note[Formato del RUT] El RUT debe enviarse con guión y sin puntos: `12345678-9` o `12345678-K` ::: ### Ejemplo con curl ```bash curl -X POST "https://api.redcumbre.cl/{tenantSlug}/herramientas/lookup-rut" \ -H "Authorization: Bearer {api_key}" \ -H "Content-Type: application/json" \ -d '{ "rut": "78012039-8" }' ``` --- ## Response La respuesta trae cuatro bloques independientes: - **`data`** — datos del contribuyente obtenidos del Portal MiPyme del SII. `null` para una persona natural sin inicio de actividades. - **`persona`** — nombre y condición de fallecido de una persona natural. `null` para una empresa. Ver [Personas naturales y empresas](#personas-naturales-y-empresas). - **`consultaContribuyente`** — datos del registro de contribuyentes electrónicos del SII: casilla de intercambio, resolución que autoriza a emitir DTE y documentos autorizados. - **`source` / `cacheHit`** — trazabilidad del origen de los datos. Una consulta exitosa responde **`200 OK`**, nunca `201`: el lookup consulta datos, no crea ningún recurso. Si tu integración valida el código de estado, compáralo contra `200`. ### Respuesta Exitosa (200) ```json { "success": true, "data": { "rut": "78012039-8", "razonSocial": "FIRERAISE SPA", "giroGlosa": null, "tipoContribuyente": "EMPRESA", "fechaAutorizacion": "2021-09-27", "portalMipymeHabilitado": true, "emailIntercambio": "intercambio@firemail.cl", "direcciones": [ { "direccion": "LOS PEUMOS ST. 3 LT B", "comuna": "BULNES", "codigoComuna": "16108", "ciudad": "", "codigoSucursal": "90866365", "unidadSii": { "direccionRegional": "Dirección Regional de Ñuble", "nombreCorto": "DR Ñuble", "codigoRegion": "16", "unidad": "Unidad de Chillán" }, "comunaId": 101, "comunaBheId": 8402, "comunaSiiId": "16108", "regionId": 16, "regionNombre": "Ñuble", "origen": "SII" } ], "actividadesEconomicas": [ { "codigo": "620900", "descripcion": "OTRAS ACTIVIDADES DE TECNOLOGÍA DE LA INFORMACIÓN Y DE SERVICIOS INFORMÁTICOS" } ], "emails": [], "telefonos": [], "contactos": [] }, "consultaContribuyente": { "rut": "78012039-8", "razonSocial": "FIRERAISE SPA", "nroResolucion": "80", "fechaResolucion": "22-08-2014", "emailContacto": "intercambio@firemail.cl", "documentos": [ { "codigo": 33, "descripcion": "FACTURA ELECTRONICA", "autorizado": "28-12-2024", "desautorizado": null }, { "codigo": 61, "descripcion": "NOTA CREDITO ELECTRONICA", "autorizado": "28-12-2024", "desautorizado": null }, { "codigo": 890, "descripcion": "SIST. SII DE EMISIÓN DE BOLETAS ELECTRÓNICAS", "autorizado": "05-09-2022", "desautorizado": "19-12-2022" } ] }, "source": "SII", "cacheHit": false } ``` ### Campos de la Respuesta | Campo | Tipo | Descripción | |-------|------|-------------| | `success` | boolean | Indica si la operación fue exitosa | | `data.rut` | string | RUT consultado | | `data.razonSocial` | string | Nombre o razón social del contribuyente | | `data.giroGlosa` | string \| null | Giro comercial. El SII solo lo entrega en lookup auto-referencial, por eso normalmente llega `null` | | `data.tipoContribuyente` | string | Estado ante el Portal MiPyme, **no** naturaleza jurídica. `"PERSONA"` cubre dos condiciones que el SII reporta por separado: sin inicio de actividades, **o** no habilitado para recibir documentos electrónicos. Para saber si el RUT es de una persona natural, usa `persona.estadoSii` | | `data.fechaAutorizacion` | string \| null | Fecha de autorización en SII (YYYY-MM-DD) | | `data.portalMipymeHabilitado` | boolean | Si tiene acceso al Portal Mipyme del SII | | `data.emailIntercambio` | string \| null | Casilla de intercambio DTE (ver sección siguiente) | | `data.direcciones` | array | Lista de direcciones registradas | | `data.actividadesEconomicas` | array | Lista de actividades económicas (ACTECO) | | `data.emails` | array | Emails cargados en la plataforma (el SII no entrega emails) | | `data.telefonos` | array | Teléfonos cargados en la plataforma | | `data.contactos` | array | Contactos unificados (nombre + email + teléfono) | | `persona` | object \| null | Nombre y condición de fallecido de una persona natural. `null` en una empresa | | `persona.nombre` | string \| null | Nombre completo. Es el único lugar donde llega el de quien no tiene inicio de actividades | | `persona.fallecido` | boolean \| null | Marca, no fecha. `null` = no se pudo consultar | | `persona.estadoSii` | string \| null | Veredicto del SII: `PERSONA_NATURAL`, `PERSONA_FALLECIDA`, `PERSONA_JURIDICA`, `RUT_NO_ENCONTRADO`, `DESCONOCIDO` | | `consultaContribuyente` | object \| null | Registro de contribuyentes electrónicos del SII (ver sección siguiente) | | `source` | string | Origen de los datos: `SII`, `LOCAL`, `LOCAL_FALLBACK` | | `cacheHit` | boolean | `true` si los datos vinieron del caché local | :::caution[`giroGlosa` normalmente llega `null`] El SII solo entrega el giro cuando el RUT consultado es el mismo del certificado que hace la consulta. Para cualquier otro contribuyente —que es el caso al facturarle a un cliente— llega `null`: en el repositorio maestro, dos de cada tres RUT consultados no tienen giro. Como `receptor.giro` es obligatorio para emitir una factura, la receta para resolverlo está en [Emitir un DTE — el giro del receptor](/guias/dte/#el-giro-del-receptor). ::: ### Estructura de Dirección | Campo | Tipo | Descripción | |-------|------|-------------| | `direccion` | string | Dirección completa | | `comuna` | string | Nombre de la comuna | | `codigoComuna` | string | Código SII de la comuna | | `ciudad` | string | Ciudad (puede estar vacío) | | `codigoSucursal` | string | Código de sucursal SII | | `unidadSii` | object | Información de la unidad SII correspondiente | | `comunaId` | number | ID interno de comuna | | `comunaBheId` | number | ID de comuna para BHE | | `comunaSiiId` | string | Código SII de la comuna | | `regionId` | number | ID de la región | | `regionNombre` | string | Nombre de la región | | `origen` | string | `"SII"` (obtenida del SII) o `"PERSONALIZADA"` (agregada manualmente, no la pisa el SII) | ### Estructura de Actividad Económica | Campo | Tipo | Descripción | |-------|------|-------------| | `codigo` | string | Código ACTECO (6 dígitos) | | `descripcion` | string | Descripción de la actividad económica | --- ## Casilla de Intercambio y Documentos Autorizados Además de los datos del Portal MiPyme, el lookup consulta el **registro de contribuyentes electrónicos del SII** y devuelve el resultado en el bloque `consultaContribuyente`. :::note[Enriquecimiento best-effort] Si el SII no responde a esta segunda consulta, `consultaContribuyente` llega en `null` y el resto de la respuesta se entrega igual. Nunca hace fallar el lookup. ::: ### Campos de `consultaContribuyente` | Campo | Tipo | Descripción | |-------|------|-------------| | `rut` | string \| null | RUT del contribuyente | | `razonSocial` | string \| null | Razón social según el registro de contribuyentes electrónicos | | `nroResolucion` | string \| null | Número de la resolución del SII que lo autoriza a emitir documentos electrónicos | | `fechaResolucion` | string \| null | Fecha de la resolución, formato `DD-MM-AAAA` | | `emailContacto` | string \| null | Casilla de intercambio publicada en el SII | | `documentos` | array | Tipos de documento autorizados (ver abajo) | ### Casilla de intercambio `consultaContribuyente.emailContacto` es la casilla que el contribuyente declaró ante el SII para recibir DTE y devolver los acuses de recibo. El mismo valor se replica en `data.emailIntercambio` para tenerlo a mano junto al resto de los datos del contribuyente. Si el contribuyente no publicó casilla en el SII, ambos campos llegan en `null`. ### Documentos autorizados Cada entrada de `consultaContribuyente.documentos` indica desde cuándo el contribuyente puede emitir ese tipo de documento y, si corresponde, desde cuándo dejó de poder hacerlo. | Campo | Tipo | Descripción | |-------|------|-------------| | `codigo` | number | Código del documento según el SII (33 = factura electrónica, 61 = nota de crédito, etc.) | | `descripcion` | string | Nombre del documento según el SII | | `autorizado` | string \| null | Fecha desde la que está autorizado, formato `DD-MM-AAAA` | | `desautorizado` | string \| null | Fecha desde la que dejó de estarlo, formato `DD-MM-AAAA`. `null` si sigue vigente | :::caution[Ambas fechas son fechas, no banderas] `autorizado` y `desautorizado` son fechas en formato `DD-MM-AAAA` tal como las publica el SII, no booleanos. Para saber si un documento está vigente hoy, la regla es `desautorizado === null`. ::: :::note[El código 890 no es un DTE] Los códigos sobre 800 no son tipos de documento tributario sino sistemas del SII. En particular, el 890 (`SIST. SII DE EMISIÓN DE BOLETAS ELECTRÓNICAS`) aparece desautorizado en muchos contribuyentes que migraron del portal del SII a un facturador externo. Eso no significa que el contribuyente esté inhabilitado. ::: ### Validar antes de emitir El uso típico es verificar que el receptor puede recibir el tipo de DTE que vas a emitirle: ```javascript const { consultaContribuyente } = await lookupRut('78012039-8'); const puedeRecibirFactura = consultaContribuyente?.documentos.some( (doc) => doc.codigo === 33 && doc.desautorizado === null ); if (!puedeRecibirFactura) { throw new Error('El receptor no está autorizado para factura electrónica'); } ``` --- ## Códigos de Error La consulta exitosa responde `200 OK`. Estos códigos indican que algo falló: | Código | Descripción | Causa | |:------:|-------------|-------| | 400 | DTE no habilitado | El tenant no tiene `dteEnabled: true` | | 404 | RUT no encontrado | **Ninguna** de las dos fuentes del SII conoce el RUT. Una persona natural sin inicio de actividades ya no cae acá: responde `200` con `data: null` y el bloque `persona` | | 500 | Error interno | Error en servicios internos | ### Ejemplo de Error 404 ```json { "statusCode": 404, "message": "No se encontraron datos para el RUT 99999999-9", "error": "Not Found" } ``` --- ## Casos de Uso ### Validar Destinatario antes de Emitir BHE Antes de emitir una Boleta de Honorarios, puedes validar que el destinatario existe en el SII: ```bash # 1. Consultar datos del destinatario curl -X POST "https://api.redcumbre.cl/{tenant}/herramientas/lookup-rut" \ -H "Authorization: Bearer {api_key}" \ -d '{"rut": "76123456-7"}' # 2. Si existe, usar los datos para emitir la BHE con modo siiLookup curl -X POST "https://api.redcumbre.cl/{tenant}/bhe" \ -H "Authorization: Bearer {api_key}" \ -H "Content-Type: application/json" \ -d '{ "emisorTributarioId": "tu-emisor-tributario-id", "sinDestinatario": false, "siiLookup": { "rut": "76123456-7", "razonSocial": "EMPRESA CLIENTE SA", "direcciones": [ { "direccion": "AV PROVIDENCIA 1234 OF 501", "codigoComuna": "13101", "comuna": "SANTIAGO" } ] }, "prestaciones": [ { "descripcion": "Servicios profesionales", "valor": "500000" } ], "tipoRetencion": "RETRECEPTOR" }' ``` :::caution[El monto va en `prestaciones`, y `valor` es un string] No existen `detalle` ni `montoTotal`. El detalle y el monto viajan juntos en cada ítem de `prestaciones` (`descripcion` + `valor`), y `valor` se envía como **string**, no como número. El bruto es la suma de las prestaciones; la retención la calcula la plataforma. ::: ### Obtener Datos Fiscales de un Cliente Para mostrar información fiscal de un cliente en tu aplicación: ```javascript const response = await fetch(`https://api.redcumbre.cl/${tenant}/herramientas/lookup-rut`, { method: 'POST', headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ rut: '78012039-8' }) }); const { data, cacheHit } = await response.json(); console.log(`Razón Social: ${data.razonSocial}`); console.log(`Actividad: ${data.actividadesEconomicas[0]?.descripcion}`); console.log(`Dirección: ${data.direcciones[0]?.direccion}, ${data.direcciones[0]?.comuna}`); console.log(`Datos desde caché: ${cacheHit ? 'Sí' : 'No'}`); ``` --- ## Integración con BHE y BHET El lookup de RUT está integrado con la emisión de BHE y BHET: el bloque `data` de la respuesta se pasa como `siiLookup` y evita una segunda consulta al SII durante la emisión. **Los dos endpoints no aceptan el mismo objeto.** BHET declara el lookup completo; BHE declara sólo el núcleo. Es una asimetría real del contrato, y conviene conocerla antes de reutilizar el mismo código para ambos. ### En BHE: sólo el núcleo del lookup `POST /{tenantSlug}/bhe` usa cuatro campos —`rut`, `razonSocial`, `email` y `direcciones`— y de cada dirección sólo `direccion`, `codigoComuna` y `comuna`. ```json { "emisorTributarioId": "tu-emisor-tributario-id", "sinDestinatario": false, "siiLookup": { "rut": "78012039-8", "razonSocial": "FIRERAISE SPA", "direcciones": [ { "direccion": "LOS PEUMOS ST. 3 LT B", "codigoComuna": "16108", "comuna": "BULNES" } ] }, "prestaciones": [ { "descripcion": "Servicios de consultoría", "valor": "1000000" } ], "tipoRetencion": "RETRECEPTOR" } ``` Obligatorios: `rut`, `razonSocial` y al menos una entrada en `direcciones` con `direccion`, `codigoComuna` y `comuna`. `email` es opcional. :::caution[En BHE los campos extra del lookup se descartan en silencio] Si le pasas a BHE el `data` completo del lookup, la emisión **funciona y responde `2xx`**: los campos que el contrato de BHE no declara se descartan antes de llegar al handler. No se guardan, no viajan al SII y **no** obtienes un `400` que te avise. Sobre el objeto de más abajo son doce los que se pierden: `tipoContribuyente`, `fechaAutorizacion`, `portalMipymeHabilitado`, `actividadesEconomicas`, y dentro de cada dirección `ciudad`, `codigoSucursal`, `comunaId`, `comunaBheId`, `comunaSiiId`, `regionId`, `regionNombre` y `origen`. Manda a BHE sólo los campos del contrato. Si necesitas el resto del lookup, guárdalo de tu lado. ::: ### En BHET: el objeto completo del lookup `POST /{tenantSlug}/bhet` declara el lookup entero, así que ahí sí puedes pasar el `data` de la respuesta tal cual, sin transformarlo ni podarlo. Lo que acepta BHET es un superconjunto de lo que acepta BHE. ```json { "emisorTributarioId": "tu-emisor-tributario-id", "siiLookup": { "rut": "78012039-8", "razonSocial": "FIRERAISE SPA", "tipoContribuyente": "EMPRESA", "fechaAutorizacion": "2021-09-27", "portalMipymeHabilitado": true, "direcciones": [ { "direccion": "LOS PEUMOS ST. 3 LT B", "comuna": "BULNES", "codigoComuna": "16108", "ciudad": "", "codigoSucursal": "90866365", "comunaId": 101, "comunaBheId": 8402, "comunaSiiId": "16108", "regionId": 16, "regionNombre": "Ñuble", "origen": "SII" } ], "actividadesEconomicas": [ { "codigo": "620900", "descripcion": "OTRAS ACTIVIDADES DE TECNOLOGÍA DE LA INFORMACIÓN Y DE SERVICIOS INFORMÁTICOS" } ] }, "prestaciones": [ { "descripcion": "Servicios de consultoría", "valor": "1000000" } ] } ``` Una BHET **no** lleva `sinDestinatario` ni `tipoRetencion`: siempre tiene tercero. Ver [Boletas de Terceros](/guias/boletas-terceros/). El listado de campos con sus tipos, para ambos endpoints, está en [Swagger](https://api.redcumbre.cl/api-docs). 👉 Ver más detalles en [Boletas de Honorarios](/guias/boletas-honorarios#modo-3-siilookup-objeto-lookup-completo) --- # PIN-RUT Verificación de Identidad Fuente: https://docs.redcumbre.cl/guias/pin-rut/ :::tip[TL;DR - Acceso Rápido] **Endpoint:** `POST /{tenantSlug}/pin/transactions` 👉 [Ver endpoint en Swagger](https://api.redcumbre.cl/api-docs#/PIN-RUT%20Tenant%20API) ::: El servicio PIN-RUT permite verificar la identidad de personas mediante un PIN numérico personal vinculado a su RUT chileno. La persona crea su PIN una sola vez y lo usa para autorizar operaciones solicitadas por tu sistema. **Casos de uso típicos:** - Firma de contratos o documentos - Autorización de transacciones financieras - Confirmación de identidad en procesos de onboarding - Validación de operaciones sensibles --- ## Requisitos Previos Antes de usar la API necesitas: 1. Una **API Key** con rol `FULL-API` 2. El tenant debe tener el **servicio PIN_RUT habilitado** 3. Una **integración PIN-RUT** configurada con las URLs permitidas :::note[Servicio PIN_RUT] Si al llamar al endpoint recibes un error 403 indicando que el servicio PIN_RUT no está habilitado, contacta al administrador de la plataforma para activarlo en tu tenant. ::: --- ## Configuración de Integraciones Antes de crear transacciones, debes configurar una integración desde el **panel de administración**: **Configuración → PIN-RUT → Integraciones** ### Campos de la Integración | Campo | Descripción | |-------|-------------| | **Slug** | Identificador único inmutable (ej: `mi_app_prod`). Se usa en cada request. | | **Nombre** | Nombre descriptivo que la persona verá en la pantalla de verificación | | **URIs de redirect** | URLs HTTPS donde el browser redirige tras completar la verificación | | **URLs de callback** | URLs HTTPS donde se envían webhooks con el resultado | | **Orígenes iframe** | URLs HTTPS autorizadas para embeber la pantalla PIN en un iframe | | **URI de redirect default** | Redirect por defecto si no se especifica en cada transacción | :::caution[Solo HTTPS] Todas las URLs deben usar HTTPS. El sistema rechaza URLs HTTP por seguridad. ::: ### Ejemplo de Integración | Campo | Valor | |-------|-------| | Slug | `mi_app_prod` | | Nombre | Mi Aplicación | | Redirect URIs | `https://miapp.cl/pin-callback` | | Callback URLs | `https://miapp.cl/webhooks/pin` | | Redirect default | `https://miapp.cl/pin-callback` | --- ## Flujo de Verificación ``` Tu Sistema Redcumbre Persona │ │ │ │── POST /pin/transactions ──▶│ │ │ (Bearer token + body) │ │ │ │ │ │◀── authorize_url ──────────│ │ │ (+ transaction_id) │ │ │ │ │ │── Redirige browser ────────▶│ │ │ a authorize_url │ │ │ │── Pantalla de autorización ▶│ │ │ │ │ │◀── Se autentica: PIN o cara│ │ │ │ │◀── Webhook (callback_url) ─│ │ │ pin.transaction.authorized │ │ │ │ │ │── Redirect a redirect_uri ▶│ │ │ ?transaction_id=X │ │ │ &status=authorized │ │ │ &state=Y │ │ │ │ │── GET /result ─────────────▶│ │ │ (verificar estado final) │ │ │◀── { status: authorized } ─│ │ ``` **Pasos:** 1. Tu sistema crea una transacción con los datos de la operación y la **modalidad** con que la persona autoriza 2. Rediriges al usuario a la `authorize_url` retornada 3. La persona ve los datos de la operación y se autentica con la modalidad que pediste 4. Tu sistema recibe el resultado vía webhook y/o redirect 5. Opcionalmente, consultas el resultado vía API ### Las dos modalidades | `authentication_method` | La persona autoriza con | Cuándo usarla | |---|---|---| | `pin` | Su PIN de 6 dígitos. Si todavía no tiene PIN, lo crea después de autenticarse con su cara, y crearlo autoriza la operación | Operaciones frecuentes, donde el PIN basta | | `face` | Una prueba de vida contra el reconocimiento facial de su identidad acreditada. El PIN no interviene: no se pide, y un PIN bloqueado no la impide | Operaciones donde necesitas presencia de la persona, no sólo conocimiento | En las dos, **si la persona no tiene su identidad acreditada**, la plataforma la guía primero a acreditarla —cédula por ambos lados y prueba de vida— y después sigue con la modalidad que pediste. No haces nada distinto: es la misma URL. --- ## Crear Transacción ### Endpoint ``` POST /{tenantSlug}/pin/transactions ``` ### Headers ``` Authorization: Bearer {api_key} Content-Type: application/json ``` ### Body ```json { "integration_slug": "mi_app_prod", "rut": "12.345.678-9", "authentication_method": "pin", "operation_type": "firma_contrato", "operation_label": "Firmar Contrato de Servicios N° 2026-042", "operation_detail": "Contrato por 12 meses", "redirect_uri": "https://miapp.cl/pin-callback", "callback_url": "https://miapp.cl/webhooks/pin", "state": "session-abc-123" } ``` | Campo | Tipo | Requerido | Descripción | |-------|------|:---------:|-------------| | `integration_slug` | string | Sí | Slug de la integración configurada | | `rut` | string | Sí | RUT chileno de la persona (ej: `12.345.678-9` o `12345678-9`) | | `authentication_method` | string | Sí | Con qué autoriza la persona: `pin` o `face`. No tiene valor por defecto: sin él, `400` | | `operation_type` | string | Sí | Tipo de operación en snake_case (máx. 50 chars) | | `operation_label` | string | Sí | Descripción visible para la persona (máx. 200 chars) | | `operation_detail` | string | No | Describe la operación que la persona va a autorizar. No incluyas datos personales de otras personas ni datos sensibles, como información de salud. Se conserva como evidencia de la autorización y se anonimiza junto con ella. | | `redirect_uri` | string | No | URL HTTPS de redirect. Debe estar en las URIs permitidas de la integración | | `callback_url` | string | No | URL HTTPS para webhook. Debe estar en las URLs permitidas de la integración | | `state` | string | No | Valor opaco devuelto sin modificar en redirect y webhook (máx. 2048 chars) | :::note[Sin `fullName`] El cuerpo **no acepta `fullName`**: el nombre de la persona es el que se lee de la cédula que presentó al acreditar su identidad, nunca uno declarado. Si lo envías, la transacción se crea igual y el campo se descarta. ::: :::tip[Campo `state`] Usa `state` para correlacionar la respuesta con tu sesión interna. Por ejemplo, puedes pasar un ID de sesión o token CSRF. El valor se devuelve exactamente como lo enviaste. ::: ### Ejemplo con curl ```bash curl -X POST "https://api.redcumbre.cl/{tenantSlug}/pin/transactions" \ -H "Authorization: Bearer {api_key}" \ -H "Content-Type: application/json" \ -d '{ "integration_slug": "mi_app_prod", "rut": "12.345.678-9", "authentication_method": "face", "operation_type": "firma_contrato", "operation_label": "Firmar Contrato N° 2026-042", "redirect_uri": "https://miapp.cl/pin-callback", "callback_url": "https://miapp.cl/webhooks/pin", "state": "session-abc-123" }' ``` ### Respuesta Exitosa (201) ```json { "success": true, "data": { "transaction_id": "cedd740a-e5a3-43a2-9896-5ac45dfdb4dc", "authorize_url": "/p/pin/authorize/7f3b9a61-2c4e-4d8f-b1a0-5e6d7c8b9a0f", "expires_at": "2026-05-18T23:30:04.594Z", "operation_hash": "06860b21052299ad..." } } ``` | Campo | Tipo | Descripción | |-------|------|-------------| | `transaction_id` | string (UUID) | Identificador único de la transacción | | `authorize_url` | string | Ruta relativa para redirigir a la persona. Prefijar con la URL base de la plataforma. La ruta contiene un identificador propio del enlace, distinto de `transaction_id`: úsala tal cual, sin extraer ni construir ese identificador | | `expires_at` | string (ISO 8601) | Límite absoluto de la transacción: **la creación más 7 días y 48 horas, igual para toda transacción**. Puede vencer antes (ver `expiration_reason`) | | `operation_hash` | string | Hash SHA-256 de la operación (para verificación de integridad) | :::note[La respuesta es la misma para toda persona] La creación **no informa el estado de la persona**: no te dice si tiene PIN, si le falta crearlo ni si tiene su identidad acreditada, y `expires_at` es el mismo para todas. Un plazo o una respuesta distintos por estado serían una consulta de quién está verificado y quién no. La plataforma resuelve por dentro qué le falta a esa persona cuando abre el enlace. ::: ### Redirect de la Persona Construye la URL completa y redirige al usuario: ``` https://app.redcumbre.cl{authorize_url} ``` Por ejemplo: ``` https://app.redcumbre.cl/p/pin/authorize/7f3b9a61-2c4e-4d8f-b1a0-5e6d7c8b9a0f ``` ### Qué pasa cuando la persona abre el enlace La pantalla le muestra primero la operación y quién la pide, y después la lleva por lo que le falte: - **Con `pin` y PIN:** ingresa su PIN. - **Con `face`, o con `pin` y sin PIN:** se autentica con su cara. Con `pin`, después crea su PIN, y crearlo autoriza. - **Sin identidad acreditada, en las dos modalidades:** la acredita primero —consentimiento, cédula por ambos lados y prueba de vida— y vuelve al mismo punto. **Los plazos cortos corren desde que la persona abre el enlace**, no desde que creas la transacción: 5 minutos para ingresar el PIN y 30 minutos para la prueba de vida. La acreditación de identidad tiene el plazo de su sesión: hasta la creación más 7 días y 48 horas. **Si nadie abre el enlace en 7 días**, la transacción vence con `not_started`. :::note[Qué NO te informa] Ni la creación ni el resultado te dicen **por qué** una persona no está acreditada, si un proceso anterior le falló o en qué paso va. Tampoco existe un endpoint para consultarlo: recibes el desenlace de **tu** transacción y nada más. ::: --- ## Consultar Resultado ### Endpoint ``` GET /{tenantSlug}/pin/transactions/{transaction_id}/result ``` ### Headers ``` Authorization: Bearer {api_key} ``` ### Respuesta ```json { "success": true, "data": { "transaction_id": "cedd740a-e5a3-43a2-9896-5ac45dfdb4dc", "status": "authorized", "integration_slug": "mi_app_prod", "rut": "12.345.678-9", "operation_type": "firma_contrato", "operation_label": "Firmar Contrato N° 2026-042", "authentication_method": "face", "created_at": "2026-05-09T23:30:04.594Z", "authorized_at": "2026-05-09T23:33:01.538Z", "expires_at": "2026-05-18T23:30:04.594Z", "state": "session-abc-123", "expiration_reason": null, "receipt": "eyJhbGciOiJFUzI1NiIsImtpZCI6Ii4uLiJ9.eyJ0cmFuc2FjY2lvbklkIjoiLi4uIn0.MEUCIQ...", "enrollment_charge": { "available": true, "price": 350, "currency": "CLP", "includesIva": false, "note": "Valores netos + IVA. Precio de lista del plan vigente, no una liquidación: la factura manda.", "desglose": [ { "metrica": "kyc_enrolamiento_iniciado", "price": 150 }, { "metrica": "kyc_enrolamiento_logrado", "price": 200 } ] }, "face_authentication_charge": { "available": false, "includesIva": false, "message": "El plan actual no tiene una tarifa unitaria resoluble para la autenticación facial. El cargo se aplica igual y el monto lo determina la factura." } } } ``` #### El comprobante `receipt` es el **comprobante de la autorización**: un JWS compacto que firma la transacción, el hash de la operación, la modalidad, el instante de la autorización y la verificación que la respalda. Llega **sólo con la transacción `authorized`**; en cualquier otro estado la clave no existe. Se verifica sin relación con nosotros, contra las claves públicas de [`/.well-known/kyc-jwks.json`](https://api.redcumbre.cl/.well-known/kyc-jwks.json): toma el `kid` del encabezado del JWS, busca esa clave en el JWKS y valida la firma con cualquier biblioteca JOSE. Guárdalo junto a tu operación: es la prueba de que la persona la autorizó. #### El cargo de la acreditación de identidad `enrollment_charge` informa el cargo de **la acreditación de identidad** que esta transacción gatilló. - Es **`null`** cuando esa persona ya estaba acreditada y no hubo acreditación. `null` significa *no hubo cargo*, no *el cargo fue cero*. - Cuando la tarifa del plan no se puede resolver, llega con `"available": false` y su `message`, **sin monto**. Nunca vas a recibir un cero por un cargo que sí ocurre. - **El cargo de la transacción no se informa acá**: está asumido en tu contrato y no cambia. El de la acreditación aparece a veces sí y a veces no —depende de si esa persona ya estaba acreditada—, y por eso es el único que te informamos por transacción. :::caution[Dos advertencias sobre el monto] **Es precio de lista del plan vigente, no una liquidación.** Descuentos, prorrateos, impuestos y cierre de período los resuelve la facturación después: **la factura manda**. **Supone una tarifa unitaria plana.** Si tu plan tarifa la acreditación **por tramos de volumen**, el precio unitario deja de estar determinado hasta cerrar el período, y el campo llega con `"available": false`. ::: #### El cargo de la autenticación facial `face_authentication_charge` informa el cargo de **la autenticación facial** de esta transacción, con la misma forma que `enrollment_charge`: las métricas `pin_validacion_facial_iniciada` y `pin_validacion_facial_lograda` que devengó, con su precio de lista. - Es **`null`** cuando la transacción no tuvo autenticación facial: la autorizó el PIN, o la acreditación de identidad. - Sin tarifa unitaria resoluble en tu plan llega con `"available": false` y su `message`, **sin monto**. Nunca un cero. Las **dos advertencias del monto** son las mismas que para la acreditación: es **precio de lista del plan vigente, no una liquidación** —la factura manda—, y **supone una tarifa unitaria plana**. #### El motivo del vencimiento `expiration_reason` dice por qué venció una transacción: | Valor | Cuándo | |-------|--------| | `"not_started"` | La persona no empezó: nadie abrió el enlace en 7 días, o abrió una acreditación de identidad y no aceptó el consentimiento | | `"not_completed"` | La persona empezó y no terminó a tiempo: abrió el enlace y no ingresó el PIN, no completó la prueba de vida o no terminó la acreditación | | `null` | La transacción todavía no venció | No existe un tercer valor: `expiration_reason` **no te dice cómo terminó la acreditación ni la prueba de vida** —aprobada, rechazada o en revisión—, sólo si la persona empezó. #### Cuando los datos de la transacción ya se anonimizaron La plataforma no conserva los datos personales de una transacción para siempre. Pasado su plazo, los anonimiza y conserva el hecho: la transacción, su estado, sus fechas y `operation_hash` siguen respondiendo. | Transacción | Cuándo | Qué cambia en la consulta | |---|---|---| | No autorizada (`expired`, `failed` o `cancelled`) | 90 días después de `expires_at` | `rut` y `state` llegan en `null` | | Autorizada | 6 años después de `authorized_at` | `receipt` llega en `null` | | De una persona cuyo registro venció | Al anonimizar el registro de la persona | `rut`, `state` y `receipt` llegan en `null` | **Guarda tu copia del comprobante cuando lo recibes.** Tu copia sigue siendo verificable con el JWKS por su `kid`, y la plataforma conserva la prueba de que lo emitió y cuándo aunque ya no lo entregue. :::caution[Aislamiento Cross-Tenant] Solo puedes consultar transacciones de tu propio tenant. Intentar consultar una transacción de otro tenant retorna 404. ::: --- ## Cancelar Transacción Cancela una transacción pendiente antes de que la persona la complete: ### Endpoint ``` POST /{tenantSlug}/pin/transactions/{transaction_id}/cancel ``` ### Headers ``` Authorization: Bearer {api_key} ``` ### Respuesta ```json { "success": true, "data": { "success": true } } ``` Solo transacciones en estado `pending` pueden cancelarse. Si la transacción ya está en un estado terminal, recibirás un error 409. :::note[Regenerar un enlace es cancelar el anterior] Hay un tope de 3 transacciones pendientes por RUT e integración, y una transacción con acreditación de identidad puede vivir hasta 9 días. Si necesitas enviarle a la persona un enlace nuevo, **cancela la transacción anterior** con este endpoint y crea otra: sin cancelar, quien regenera tres veces el enlace queda bloqueado por días. ::: --- ## Estados de la Transacción | Estado | Terminal | Descripción | |--------|:--------:|-------------| | `pending` | No | Persona aún no completó la verificación | | `authorized` | Sí | La persona se autenticó con la modalidad pedida — trae `receipt` | | `failed` | Sí | PIN incorrecto reiterado, PIN bloqueado, o la verificación facial agotó sus intentos (`face_not_verified`) | | `expired` | Sí | La persona no completó a tiempo; `expiration_reason` dice si empezó o no | | `cancelled` | Sí | Cancelada por el integrador o por la persona | --- ## Webhooks Recibirás webhooks cuando la transacción cambie a un estado terminal, en el `callback_url` de la transacción o, si no lo enviaste, en la URL del webhook de tu cuenta. Sólo recibes los eventos que tu [configuración de webhooks](/guias/webhooks#opciones-de-configuración) tenga en **Eventos Habilitados** (vacía = todos). ### Eventos | Evento | Cuándo | |--------|--------| | `pin.transaction.authorized` | La persona se autenticó con la modalidad pedida | | `pin.transaction.failed` | PIN incorrecto 5 veces, PIN bloqueado o verificación facial no completada | | `pin.transaction.expired` | La transacción venció sin completarse; `expiration_reason` dice si la persona empezó o no | | `pin.transaction.cancelled` | Cancelada por integrador o persona | ### Payload del Webhook ```json { "event": "pin.transaction.authorized", "timestamp": "2026-05-09T23:38:01.577Z", "data": { "transaction_id": "cedd740a-e5a3-43a2-9896-5ac45dfdb4dc", "integration_id": "702191e3-e415-41dc-ac1b-0b66d2edccf9", "rut": "12.345.678-9", "authentication_method": "pin", "operation_type": "firma_contrato", "operation_label": "Firmar Contrato N° 2026-042", "operation_hash": "06860b21052299ad...", "authorized_at": "2026-05-09T23:38:01.538Z", "status": "authorized", "state": "session-abc-123", "failure_reason": null, "expiration_reason": null, "receipt": "eyJhbGciOiJFUzI1NiIsImtpZCI6Ii4uLiJ9.eyJ0cmFuc2FjY2lvbklkIjoiLi4uIn0.MEUCIQ..." } } ``` Cuando vence una transacción cuya persona empezó y no terminó a tiempo: ```json { "event": "pin.transaction.expired", "timestamp": "2026-05-12T10:14:02.101Z", "data": { "transaction_id": "cedd740a-e5a3-43a2-9896-5ac45dfdb4dc", "integration_id": "702191e3-e415-41dc-ac1b-0b66d2edccf9", "rut": "12.345.678-9", "authentication_method": "face", "operation_type": "firma_contrato", "operation_label": "Firmar Contrato N° 2026-042", "operation_hash": "06860b21052299ad...", "authorized_at": null, "status": "expired", "state": "session-abc-123", "failure_reason": "transaction_expired", "expiration_reason": "not_completed" } } ``` **Ningún evento lleva el identificador interno de la persona**: es propio de la plataforma y la correlacionaría entre integradores. Todos llevan `authentication_method`, y **`receipt` va sólo en `pin.transaction.authorized`**: en los demás eventos la clave no existe. `pin.transaction.expired` llega **a lo sumo una vez** por transacción. ### Campos Específicos por Evento | Evento | Campos adicionales en `data` | |--------|------------------------------| | `pin.transaction.authorized` | `authorized_at`, `receipt` | | `pin.transaction.failed` | `failure_reason` (`"max_attempts_reached"`, `"rate_limit_exceeded"` o `"face_not_verified"`) | | `pin.transaction.expired` | `failure_reason` (`"transaction_expired"`), `expiration_reason` (`"not_started"` o `"not_completed"`) | | `pin.transaction.cancelled` | — | ### Resolución del Webhook URL El sistema determina dónde enviar el webhook con esta prioridad: 1. `callback_url` enviado en la transacción (per-request) 2. URL global del webhook configurado en el tenant (fallback) 3. Si ninguno existe → no se envía webhook (solo redirect) Los **headers personalizados** configurados en el webhook global del tenant se incluyen siempre, independientemente de si el URL es per-request o global. Dos reglas deciden si el webhook sale: - **Eventos Habilitados** filtra por tipo de evento **con cualquiera de los dos destinos**. Si la lista no está vacía y no incluye el evento, no se envía, tampoco al `callback_url`. Vacía = todos. - **Activo** gobierna sólo la URL global. Con el interruptor apagado, una transacción con `callback_url` sigue recibiendo sus webhooks; una sin `callback_url`, no. Para dejar de recibirlos en el `callback_url`, deja de enviarlo o quita los eventos `pin.transaction.*` de la lista. :::tip[Verificar Webhooks] Consulta la guía de [Webhooks](/guias/webhooks) para detalles sobre firma HMAC, reintentos y mejores prácticas de implementación. ::: --- ## Redirect Cuando la persona completa la verificación (o cancela), el browser redirige a la `redirect_uri` con query parameters: ``` https://miapp.cl/pin-callback?transaction_id=cedd740a-...&status=authorized&state=session-abc-123 ``` | Parámetro | Descripción | |-----------|-------------| | `transaction_id` | UUID de la transacción | | `status` | Estado final: `authorized`, `failed` o `cancelled` | | `state` | Valor opaco que enviaste al crear la transacción | :::caution[No confiar solo en el redirect] El redirect ocurre en el browser del usuario y puede ser manipulado. **Siempre verifica el resultado vía API** (`GET /transactions/{id}/result`) o webhook antes de tomar acciones en tu sistema. ::: --- ## Códigos de Error | Código | Descripción | Causa | |:------:|-------------|-------| | 400 | Validación fallida | Falta `authentication_method` o no es `pin` ni `face`, RUT inválido, `redirect_uri` no en lista permitida, `callback_url` no permitida, máximo de pendientes alcanzado | | 401 | No autorizado | API key inválida, expirada o revocada | | 402 | Pago requerido | Tu plan no incluye la métrica de PIN-RUT (`code: METRIC_NOT_IN_PLAN`) | | 403 | Prohibido | Servicio PIN_RUT no habilitado o rol insuficiente | | 404 | No encontrado | Integración no existe o está inactiva, transacción no encontrada | | 409 | Conflicto | Transacción no está en estado `pending` (al cancelar); o se envió un PIN a una transacción `face` (`code: face_authentication_required`) | | 422 | No procesable | No hay `redirect_uri` y la integración no tiene default | | 423 | Bloqueado | Sólo con `authentication_method: "pin"`: el PIN del RUT está bloqueado por demasiados intentos fallidos. Con `face` el PIN no interviene y no hay `423` | ### Ejemplo de Error 404 (Integración) ```json { "message": "Integración activa con slug \"mi_app_prod\" no encontrada", "error": "Not Found", "statusCode": 404 } ``` ### Ejemplo de Error 423 (RUT Bloqueado) ```json { "code": "pin_blocked", "message": "RUT is blocked due to too many failed attempts", "statusCode": 423 } ``` --- ## Protección Anti-Brute-Force El sistema implementa un rate limit global de intentos fallidos **del PIN**: - **Máximo 10 intentos fallidos de PIN** por RUT en 1 hora (cross-tenant). Las verificaciones faciales fallidas no cuentan: no bloquean el PIN - Al superar el límite, el PIN del RUT se **bloquea inmediatamente** - Transacciones nuevas para un RUT bloqueado retornan **423 Locked** - El bloqueo es **global**: protege contra ataques distribuidos desde múltiples integraciones :::note[Desbloqueo] Un RUT bloqueado debe ser desbloqueado manualmente por un administrador MASTER desde el panel de gestión PIN-RUT. ::: --- ## Límites | Concepto | Límite | |----------|--------| | Transacciones pendientes por RUT × integración | 3 | | Intentos de PIN por transacción | 5 | | Intentos de verificación facial por transacción | 3 | | Intentos fallidos globales de PIN por RUT/hora | 10 | | Plazo para abrir el enlace | 7 días desde la creación | | Plazo para ingresar el PIN, desde que se abre el enlace | 5 minutos | | Plazo para la prueba de vida, desde que se abre el enlace | 30 minutos | | Plazo de una acreditación de identidad | Hasta la creación más 7 días y 48 horas (`expires_at`) | | Tamaño máximo de `state` | 2048 caracteres | | Tamaño máximo de `operation_label` | 200 caracteres | --- ## Integración JavaScript ```javascript // 1. Crear transacción const response = await fetch( `https://api.redcumbre.cl/${tenantSlug}/pin/transactions`, { method: 'POST', headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ integration_slug: 'mi_app_prod', rut: '12.345.678-9', operation_type: 'firma_contrato', operation_label: 'Firmar Contrato N° 2026-042', redirect_uri: 'https://miapp.cl/pin-callback', callback_url: 'https://miapp.cl/webhooks/pin', state: sessionId, }), } ); const { data } = await response.json(); // 2. Redirigir al usuario window.location.href = `https://app.redcumbre.cl${data.authorize_url}`; // 3. En la página de callback, verificar resultado const params = new URLSearchParams(window.location.search); const txId = params.get('transaction_id'); const status = params.get('status'); if (status === 'authorized') { // Verificar server-side antes de tomar acción const result = await fetch( `https://api.redcumbre.cl/${tenantSlug}/pin/transactions/${txId}/result`, { headers: { 'Authorization': `Bearer ${apiKey}` } } ); const { data: tx } = await result.json(); if (tx.status === 'authorized') { // Identidad verificada — proceder con la operación } } ``` ### Receptor de Webhook (Node.js) ```javascript app.post('/webhooks/pin', (req, res) => { const { event, data } = req.body; switch (event) { case 'pin.transaction.authorized': console.log(`✓ Identidad verificada: RUT=${data.rut}, tx=${data.transaction_id}`); // Procesar autorización break; case 'pin.transaction.failed': console.log(`✗ Verificación fallida: ${data.failure_reason}`); break; case 'pin.transaction.expired': console.log(`⏰ Transacción expirada: ${data.transaction_id}`); break; case 'pin.transaction.cancelled': console.log(`↩ Transacción cancelada: ${data.transaction_id}`); break; } res.json({ received: true }); }); ``` --- # Procesos Batch: operaciones masivas Fuente: https://docs.redcumbre.cl/guias/procesos-batch/ :::tip[TL;DR - Acceso Rápido] **Ubicación:** Herramientas → Procesos Batch Sube un archivo Excel con hasta **5,000 registros** y el sistema procesa cada uno de forma automática, con reintentos y notificaciones. ::: Los **Procesos Batch** permiten ejecutar operaciones masivas sin intervención manual. En lugar de emitir documentos uno por uno, subes un archivo Excel con todos los datos y el sistema se encarga del resto: valida, procesa, reintenta si hay errores, y te notifica cuando termina. --- ## Beneficios para tu Empresa | Beneficio | Descripción | | ------------------ | ------------------------------------------------------------- | | **Automatización** | Procesa miles de registros mientras te dedicas a otras tareas | | **Confiabilidad** | Reintentos automáticos si hay problemas temporales con el SII | | **Trazabilidad** | Cada operación queda registrada con su resultado y detalles | | **Escalabilidad** | Desde 1 hasta 5,000 registros por archivo | | **Independencia** | Si un registro falla, los demás continúan procesándose | --- ## Casos de Uso ### Disponibles ahora | Proceso | Descripción | | -------------------------- | -------------------------------------------------- | | **Emisión masiva de BHE** | Boletas de Honorarios para múltiples destinatarios | | **Emisión masiva de BHET** | Boletas de Terceros para múltiples personas | ## Cómo Funciona ``` ┌─────────────────────────────────────────────────────────────────┐ │ FLUJO BATCH │ └─────────────────────────────────────────────────────────────────┘ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ Descarga │ │ Completa │ │ Sube y │ │ Inicia │ │ Plantilla│────▶│ datos │────▶│ Valida │────▶│ Proceso │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ ▼ ┌──────────┐ ┌──────────┐ ┌──────────────────────────┐ │ Descarga │ │ Recibe │ │ Seguimiento en tiempo │ │Resultados│◀────│ Email │◀────│ real desde plataforma │ └──────────┘ └──────────┘ └──────────────────────────┘ ``` **El sistema se encarga de:** - Validar cada registro antes de procesar - Ejecutar las operaciones de forma paralela y controlada - Reintentar automáticamente si hay errores temporales - Notificarte por email cuando inicia y cuando termina - Generar un archivo de resultados con el detalle de cada operación --- ## Confiabilidad del Sistema ### Reintentos Automáticos Si una operación falla por un problema temporal (ej: el SII no responde), el sistema **reintenta automáticamente** varias veces con pausas crecientes entre cada intento. No pierdes datos ni tienes que hacer nada manual. ### Procesamiento Independiente Cada registro se procesa de forma independiente. Si uno falla (ej: RUT inválido), **los demás continúan normalmente**. Al final recibes un reporte indicando cuáles fueron exitosos y cuáles fallaron. ### Notificaciones Recibirás notificaciones: - **Al iniciar**: Confirmación de que el proceso comenzó - **Al finalizar**: Resumen con cantidad de exitosos y fallidos - **Por email**: A tu correo y a los destinatarios adicionales que configures #### Webhook `batch.completado` Si tienes [webhooks configurados](/guias/webhooks), el fin del proceso también viaja a tu endpoint con el resumen completo (`total`, `exitosos`, `fallidos`, `porcentaje`) y la URL de resultados. El payload está documentado en la [guía de Webhooks](/guias/webhooks#batchcompletado). :::caution[Hay que habilitarlo explícitamente] `batch.completado` es **opt-in estricto**: a diferencia del resto del catálogo, dejar "Eventos Habilitados" vacío **no** lo activa. Hay que seleccionarlo. ::: `estado` llega como `COMPLETADO` tanto si todos los registros salieron bien como si algunos fallaron — para distinguirlo, mira `resumen.fallidos`. ### Seguimiento en Tiempo Real Mientras el proceso se ejecuta, puedes ver el progreso en tiempo real desde la plataforma: - Barra de progreso con porcentaje - Contadores de procesados, exitosos y fallidos - Timeline de eventos --- ## Guía Paso a Paso ### 1. Acceder a Procesos Batch Navega a **Herramientas → Procesos Batch** en el menú lateral. ![Lista de Procesos Batch](../../../assets/guias/procesos-batch/lista-procesos.png) Aquí verás todos tus procesos anteriores con su estado y progreso. También encontrarás botones para **descargar las plantillas** Excel. --- ### 2. Descargar Plantilla Antes de crear un proceso, descarga la plantilla correspondiente: - **Boleta de Honorarios**: Para emisión masiva de BHE - **Boleta de Terceros**: Para emisión masiva de BHET Las plantillas incluyen: - **Hoja "Datos"**: Donde agregas tus registros - **Hoja "Ejemplos"**: Filas de ejemplo con datos válidos - **Hoja "Comunas"**: Códigos de comunas y regiones del SII - **Hoja "Instrucciones"**: Guía de uso ![Plantilla Excel](../../../assets/guias/procesos-batch/plantilla-excel.png) --- ### 3. Completar los Datos Abre la plantilla en Excel y completa los datos en la hoja "Datos". **Columnas típicas para BHET:** | Columna | Descripción | Requerido | | -------------------------- | ---------------------------------- | :-------: | | `rut_emisor` | RUT del emisor tributario | Sí | | `tercero_rut` | RUT del tercero | Sí | | `tercero_nombre` | Nombre completo | Sí | | `tercero_domicilio` | Dirección | Sí | | `tercero_codigo_comuna` | Código comuna SII | Sí | | `tercero_email` | Email (opcional) | No | | `prestacion_1_descripcion` | Descripción del servicio | Sí | | `prestacion_1_valor` | Monto en CLP | Sí | :::note[RUT del Emisor] El `rut_emisor` es el RUT del emisor tributario en formato `12345678-9` (con guión y dígito verificador). El emisor debe estar previamente configurado en **Administración → Emisores Tributarios** con sus credenciales del SII. Para más información sobre emisores, consulta la [Guía de Emisores](/guias/emisores). ::: :::tip[Validaciones] La segunda fila de la plantilla indica si cada columna es **REQUERIDO** u **Opcional**. ::: --- ### 4. Crear Nuevo Proceso Haz clic en **"+ Nuevo Proceso Batch"** y sigue el asistente de 4 pasos: #### Paso 1: Tipo de Proceso Selecciona el tipo de proceso que deseas ejecutar. ![Wizard Paso 1](../../../assets/guias/procesos-batch/wizard-paso1-tipo.png) :::note[Múltiples Emisores] El emisor se especifica en cada fila del Excel (columna `rut_emisor`), lo que permite emitir boletas desde diferentes emisores en un solo proceso. ::: --- #### Paso 2: Notificaciones Configura quién recibirá las notificaciones del proceso. ![Wizard Paso 2](../../../assets/guias/procesos-batch/wizard-paso2-notificaciones.png) - Tu email siempre recibe notificaciones (no se puede quitar) - Puedes agregar hasta **5 emails adicionales** - Todos recibirán: notificación de inicio, notificación de fin, y link para descargar resultados --- #### Paso 3: Cargar Archivo Sube tu archivo Excel completado. ![Wizard Paso 3](../../../assets/guias/procesos-batch/wizard-paso3-archivo.png) **Límites:** - Formato: `.xlsx` o `.xls` - Máximo: **5,000 filas** por archivo El sistema valida el archivo **inmediatamente** al subirlo: - Verifica que las columnas requeridas existan - Valida formato de RUTs - Verifica códigos de comunas - Valida que los montos sean números válidos Si hay errores, verás un detalle de cada fila con problemas y podrás descargar un reporte para corregirlos. --- #### Paso 4: Confirmar e Iniciar Revisa el resumen y haz clic en **"Iniciar Proceso"**. :::caution[Sin vuelta atrás] Una vez iniciado, el proceso no puede cancelarse. Asegúrate de que los datos son correctos antes de iniciar. ::: --- ### 5. Seguimiento y Resultados Una vez iniciado, puedes ver el progreso en tiempo real. ![Detalle del Proceso](../../../assets/guias/procesos-batch/detalle-completado.png) La vista de detalle muestra: - **Estado**: En cola, En ejecución, Completado, etc. - **Contadores**: Total, Exitosos, Fallidos - **Timeline**: Eventos del proceso (creado, validado, iniciado, completado) - **Información**: Emisor, fechas, emails notificados --- ### 6. Descargar Resultados Al finalizar, descarga el archivo de resultados. Es tu mismo archivo Excel con columnas adicionales: | Columna | Descripción | | ---------------- | ----------------------------------- | | `_resultado` | `EXITO` o `ERROR` | | `_folio` | Número de folio asignado por el SII | | `_monto_bruto` | Monto bruto calculado | | `_monto_liquido` | Monto líquido a recibir | | `_error_codigo` | Código de error (si falló) | | `_error_mensaje` | Descripción del error (si falló) | | `_url_pdf` | Link al PDF generado | --- ## Reintentar Fallidos Si algunos registros fallaron, puedes crear un **nuevo proceso solo con los fallidos**: 1. Abre el detalle del proceso completado 2. Haz clic en **"Reintentar Fallidos"** 3. Se crea un nuevo proceso con solo los registros que fallaron 4. El proceso original mantiene su historial intacto Esto es útil cuando: - Hubo problemas temporales con el SII - Corregiste datos en el sistema (ej: credenciales del emisor) - Quieres reintentar después de un tiempo --- ## Estados del Proceso | Estado | Descripción | | -------------------------- | ------------------------------------ | | **Preparado** | Archivo validado, listo para iniciar | | **En Cola** | Esperando turno para ejecutarse | | **En Ejecución** | Procesando registros | | **Completado** | Todos los registros fueron exitosos | | **Completado con Errores** | Algunos registros fallaron | | **Cancelado** | Proceso detenido manualmente | --- ## Requisitos ### Roles Requeridos Para usar Procesos Batch necesitas uno de estos roles: - `ADMIN` - `SUPER-ADMIN` - `OPERADOR` - `FULL-API` ### Servicios Habilitados El tenant debe tener habilitado el servicio correspondiente: - **SII_BHE**: Para emisión masiva de Boletas de Honorarios - **SII_BHET**: Para emisión masiva de Boletas de Terceros Si no ves un tipo de proceso disponible, contacta al administrador para verificar los servicios habilitados. --- ## Preguntas Frecuentes ### ¿Cuánto tarda un proceso? Depende de la cantidad de registros y la disponibilidad del SII. Un proceso de 100 registros típicamente tarda unos minutos. Procesos grandes (miles de registros) pueden tardar más tiempo. ### ¿Qué pasa si cierro el navegador? El proceso continúa ejecutándose en el servidor. Recibirás un email cuando termine y podrás ver los resultados cuando vuelvas a entrar. ### ¿Puedo cancelar un proceso en ejecución? Sí, puedes cancelar un proceso mientras está en ejecución. Los registros ya procesados mantienen su resultado, y los pendientes se marcan como cancelados. ### ¿Los errores afectan a otros registros? No. Cada registro se procesa de forma independiente. Si uno falla, los demás continúan normalmente. ### ¿Cuántos procesos puedo ejecutar a la vez? Puedes tener múltiples procesos en cola. El sistema los ejecuta de forma ordenada para garantizar la estabilidad. --- # Reportar un problema Fuente: https://docs.redcumbre.cl/guias/reportar-issue/ Si estás integrando y algo no calza —la guía dice una cosa y el API hace otra, una ruta documentada te rechaza, un `500` sin explicación—, tienes un canal para decirlo: `redcumbre:issue`. Un reporte por esta vía llega estructurado y con la traza técnica ya adentro, así que no hay ida y vuelta para reconstruir qué pasó. Eso es todo el punto: quien más lee esta documentación es una máquina que la ejecuta línea por línea contra la API real, y hasta ahora esa lectura no volvía. Necesitas una **API key válida** (de sandbox o de producción, da igual). Si todavía no la tienes, esto no te sirve: escribe por el [formulario de contacto](https://redcumbre.cl/contacto). ## Antes de reportar: ¿es un defecto nuestro o de tu petición? Esta es la parte que más rinde. El modo de falla más común de un agente es armar mal el payload, recibir un `400` y reportar «bug en la API» — un reporte que no describe ningún defecto y que igual cuesta el tiempo de alguien. **NO reportes:** | Qué te pasó | Por qué no | |---|---| | `400` por un campo que el spec declara obligatorio y no mandaste | Es tu petición. Compara contra [`/api-docs-json`](https://api.redcumbre.cl/api-docs-json) y corrígela | | `400` por un valor fuera del enum, un tipo incorrecto o un techo excedido | Ídem. El mensaje del validador te dice qué campo es | | `401` sin credencial, o con una key mal copiada | Falta o está mal la credencial | | `403` con `code: API_KEY_TENANT_MISMATCH` | El `tenantSlug` de la URL no es el de tu credencial. Pregúntaselo a `GET /global/whoami` | | «No tengo API key» / «el servicio no está habilitado» / «no hay emisor DTE configurado» | Nada de eso se resuelve por API — está en «Lo que no se resuelve por API». Es una conversación con REDCUMBRE, no un defecto | **SÍ reporta:** | Qué te pasó | Cómo clasificarlo | |---|---| | Un `5xx` — la respuesta trae el link a esta guía en el campo `reportar` | `tipo: bug`, `area: api` | | Una ruta que una guía publicada documenta y que **rechaza a toda credencial de máquina** | `tipo: bug`, `area: ambas` | | Una respuesta que **contradice el contrato publicado**: falta un campo que el spec declara, o el tipo no es el declarado | `tipo: bug`, `area: api` | | Una guía que **contradice el comportamiento real**: el ejemplo no funciona, el orden de pasos está mal, el campo cambió de nombre | `tipo: bug`, `area: documentacion` | | Un mensaje de error que **no permite saber qué estaba mal** | `tipo: mejora`, `area: documentacion` | Si dudas entre las dos columnas, mira si puedes describir **qué debería haber pasado según algo publicado**. Si no puedes citar ninguna fuente que diga otra cosa, probablemente no es un defecto. ## Guarda el `x-trace-id` de TODA respuesta, también de las exitosas Cada respuesta del API devuelve una cabecera `x-trace-id`. Es lo que permite abrir la traza completa de esa llamada sin pedirte nada más. **Guárdala siempre, no sólo cuando algo falla.** Cuando adviertes el problema ya pasaron varios turnos, y la traza de la llamada que lo originó —que suele ser la *anterior* a la que falló— ya se perdió. Reconstruirla después cuesta una sesión entera de ida y vuelta. Es el campo con mejor relación costo/beneficio del reporte entero, y no te cuesta una línea de código extra: ya viene en la respuesta. ## Regla de redacción: manda la FORMA del payload, no los valores de tus clientes La evidencia viaja a nuestro repositorio de trabajo. Reemplaza por marcadores los datos identificatorios de terceros —RUT, razón social, direcciones, correos de los clientes finales de la empresa— antes de enviarla: ```json { "receptor": { "rut": "", "razonSocial": "" }, "detalle": [{ "nombreItem": "", "montoItem": 119000 }] } ``` Los montos, los códigos y la estructura sí sirven — son lo que muestra dónde falla. Los nombres y los RUT, no. ## Qué envías `POST https://api.redcumbre.cl/global/issues`, con tu API key como Bearer token. **No lleva `tenantSlug` en la URL**: es a propósito, porque uno de los defectos que este canal existe para recibir es «mi `tenantSlug` no resuelve». **No mandes tu identidad.** La empresa, el nombre de la credencial, sus roles y el flag `sandbox` los toma el servidor de tu propia petición. Si los envías en el cuerpo se descartan sin aviso. Es deliberado: un reporte que dice «producción» con una credencial de sandbox manda la investigación al ambiente equivocado. ### Campos Los marcados con `*` son obligatorios. | Campo | Techo | Qué es | |---|---|---| | `tipo` * | `bug` · `mejora` · `requerimiento` | Qué clase de reporte es | | `area` * | `api` · `documentacion` · `ambas` | Dónde está el problema | | `dominio` * | ver la lista de abajo | Dominio de la API al que pertenece | | `severidad` * | `bloqueante` · `degradado` · `menor` | Cuánto te bloquea | | `titulo` * | 120 caracteres | Una línea. Es el título del issue | | `esperado` * | 2.000 caracteres | Qué decía la documentación o el contrato que debía pasar | | `observado` * | 2.000 caracteres | Qué pasó realmente | | `pasos[]` | 20 elementos, 300 caracteres cada uno | Cómo reproducirlo, en orden | | `evidencia[]` | 10 elementos | Las llamadas involucradas — ver abajo | | `docs` | — | Qué documentación leías — ver abajo | | `entorno` | — | Con qué estás integrando — ver abajo | | `contacto` * | — | A quién responderle — ver abajo | **`evidencia[]`** — cada elemento: | Campo | Techo | Qué es | |---|---|---| | `metodo` * | `GET` `POST` `PUT` `PATCH` `DELETE` `HEAD` `OPTIONS` | Método de la llamada | | `ruta` * | 500 caracteres | La ruta, sin el host | | `statusCode` * | entero 100–599 | Lo que devolvió | | `traceId` | 128 caracteres | El `x-trace-id` de esa respuesta | | `extracto` | 2.000 caracteres | El fragmento que muestra el problema, redactado | **`docs`, `entorno` y `contacto`:** | Campo | Techo | Qué es | |---|---|---| | `docs.urls[]` | 10 elementos, 500 caracteres cada uno | Qué páginas estabas siguiendo | | `docs.llmsHash` | 128 caracteres | El `hash` del bloque «Versión de este documento» de `llms.txt` | | `entorno.agente` | 120 caracteres | Qué asistente eres | | `entorno.lenguaje` | 120 caracteres | Lenguaje y versión | | `entorno.sdk` | 120 caracteres | Cliente HTTP o SDK | | `contacto.email` * | 254 caracteres | Correo de la persona que autorizó el reporte | | `contacto.nombre` | 120 caracteres | Su nombre, si lo tienes | **`dominio`** admite: `dte`, `bhe`, `bhet`, `procesos-batch`, `autorizaciones-v2`, `tokenizacion`, `sms`, `pin`, `herramientas`, `validacion-rut`, `clientes`, `proveedores`, `global`, `whoami`, `hola`, `emisores`, `webhooks`, `dte-xml-format1` y `desconocido`. Usa `desconocido` si no logras clasificarlo — es preferible a inventar un valor, que da `400`. **`docs.llmsHash` vale mucho más de lo que parece.** Un reporte sobre la documentación sin saber qué versión leíste no es resoluble. El paso 0 del protocolo ya te obliga a tenerlo a mano. **No se aceptan archivos adjuntos.** Si el XML que el SII rechazó es la evidencia, manda el fragmento relevante en `extracto` y el `traceId` de esa llamada: con eso se recupera el resto. ### Ejemplo completo ```bash curl -s -X POST https://api.redcumbre.cl/global/issues \ -H "Authorization: Bearer $REDCUMBRE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "tipo": "bug", "area": "ambas", "dominio": "dte", "severidad": "degradado", "titulo": "POST /dte responde 500 al emitir nota de crédito con referencia", "esperado": "Según guias/dte, una nota de crédito (tipo 61) con una referencia al DTE original debería emitirse igual que una factura y devolver 202 con el trackId.", "observado": "Responde 500 con { statusCode: 500, message: \"Internal server error\" }. Sin referencias funciona. Reproducido 4 veces seguidas.", "pasos": [ "Emitir una factura 33 y anotar su folio", "Emitir un DTE 61 con referencias[] apuntando a ese folio", "La respuesta es 500" ], "evidencia": [ { "metodo": "POST", "ruta": "/mi-empresa/dte", "statusCode": 500, "traceId": "7f3c9a1e42b8", "extracto": "{\"tipoDte\":61,\"receptor\":{\"rut\":\"\"},\"referencias\":[{\"tipoDocumento\":33,\"folio\":1042}]}" }, { "metodo": "POST", "ruta": "/mi-empresa/dte", "statusCode": 202, "traceId": "1a0d5b7c93ef", "extracto": "misma petición sin referencias[] — responde 202" } ], "docs": { "urls": ["https://docs.redcumbre.cl/guias/dte/"], "llmsHash": "9c1f2b7d4e6a8035" }, "entorno": { "agente": "Claude Code", "lenguaje": "TypeScript 5.7", "sdk": "fetch nativo" }, "contacto": { "email": "persona@empresa.cl", "nombre": "Persona" } }' ``` ## Qué te responde ```json { "caso": "RC-20260819-A3F1", "recibido": "2026-08-19T14:22:05.113Z", "trace_id": "b41e7f09c2d5", "duplicado": false } ``` - **`202`** — el reporte se aceptó. **Dile el `caso` a la persona**: es su identificador para citarlo si escribe a soporte. - **`200`** — ya habías reportado esto. Devuelve el **mismo caso** y no genera nada nuevo. No insistas: reenviarlo no cambia el resultado. - **`400`** — el cuerpo no cumple el contrato. El mensaje del validador dice qué campo es. - **`401`** — falta la credencial. - **`429`** — alcanzaste el límite de reportes de tu credencial. El mensaje indica en cuántos segundos puedes volver. Son **5 por hora** y **20 por día**, contados por credencial. ## Después de enviarlo **Dale el número de caso a la persona.** Es su único handle: el repositorio donde queda el reporte es interno, así que no va a poder consultarlo por su cuenta — si escribe a soporte, cita el caso y con eso se ubica. Y anótalo en `.redcumbre/estado.json`, junto al contacto, para no volver a pedir el correo ni reportar dos veces lo mismo entre sesiones: ```json { "contacto": { "email": "persona@empresa.cl", "nombre": "Persona" }, "reportes": [ { "caso": "RC-20260819-A3F1", "fecha": "2026-08-19", "titulo": "POST /dte responde 500 …" } ] } ``` **El correo del operador SÍ se guarda como valor** en ese archivo, a diferencia de la API key, de la que sólo se guarda el nombre de su variable de entorno. Es dato personal de esa persona, en un archivo local que ya está en `.gitignore`, y sirve justamente para no volver a preguntárselo. La regla del paso 4 aplica a **secretos**, no al contacto. Si no tienes acceso al sistema de archivos, pregunta el correo cada vez. No lo expliques ni te disculpes por ello. ## Pide autorización antes de enviar, siempre El reporte se escribe en un sistema externo a nombre de quien te contrató, y ese registro es permanente. **Pregunta antes de cada envío** — por acción, no por sesión: si encuentras un segundo defecto en la misma conversación, vuelve a preguntar. Una línea alcanza: > Parece que encontramos un problema. ¿Lo reporto a Redcumbre? > Va con el detalle técnico y tu correo, para que puedan responderte. > Te doy el número de caso para que lo puedas citar. **No prometas lo que no puedes cumplir.** No ofrezcas «seguir el estado del reporte»: el repositorio es interno y la persona no tiene acceso. Y no afirmes que todo reporte se procesa — REDCUMBRE **revisa** los reportes para estabilizar la plataforma, y ése es el compromiso que se puede decir en voz alta. ## Lo que NO se resuelve por esta vía Reportar un defecto no es lo mismo que pedir algo. Estas tres cosas siguen requiriendo contacto con REDCUMBRE y no hay endpoint que las resuelva: - **Emisión de una API key.** [Solicitar una cuenta](/solicitar-cuenta/). - **Habilitación de un servicio** en la empresa. - **Configuración de un emisor DTE** con su certificado digital y sus folios CAF. Y si **no tienes credencial**, este canal no está disponible: usa el [formulario de contacto](https://redcumbre.cl/contacto) del sitio. No existe una vía anónima — el valor de un reporte por acá es que viene de una integración real y verificada. --- # Envío de SMS a teléfonos chilenos Fuente: https://docs.redcumbre.cl/guias/sms/ :::tip[TL;DR - Acceso Rápido] **Endpoint:** `POST /{tenantSlug}/sms/send` 👉 [Ver endpoint en Swagger](https://api.redcumbre.cl/api-docs#/SMS%20Tenant) ::: El servicio de SMS permite enviar mensajes de texto a números chilenos desde tu sistema. Los mensajes se procesan de forma asíncrona con routing inteligente, reintentos automáticos y tracking de estado de entrega. --- ## Requisitos Previos Antes de usar el endpoint necesitas: 1. Una **API Key** con uno de estos roles: `ADMIN`, `SUPER-ADMIN`, `SMS_OPERADOR`, o `FULL-API` 2. El tenant debe tener el **servicio SMS habilitado** :::note[Servicio SMS] Si al llamar al endpoint recibes un error 403 indicando que el servicio SMS no está habilitado, contacta al administrador de la plataforma para activarlo en tu tenant. ::: --- ## Flujo del Mensaje ``` POST /{tenantSlug}/sms/send │ ▼ Validar teléfono y body │ ▼ Calcular segmentos (GSM-7 / Unicode) │ ▼ Crear registro en BD (status: queued) │ ▼ Encolar en BullMQ (prioridad según routeType) │ ▼ Respuesta inmediata: { messageId, status: "queued" } │ ▼ [Async] Worker procesa con circuit breaker │ ▼ [Async] Proveedor entrega SMS → status: delivered/failed ``` La respuesta es **inmediata** — el mensaje se encola y se procesa en background. Usa el `messageId` para consultar el estado posteriormente. --- ## Request ### Endpoint ``` POST /{tenantSlug}/sms/send ``` ### Headers ``` Authorization: Bearer {api_key} Content-Type: application/json ``` ### Body ```json { "to": "+56912345678", "body": "Tu código de verificación es 123456", "routeType": "transactional", "metadata": { "orderId": "ORD-001", "source": "mi-sistema" } } ``` | Campo | Tipo | Requerido | Descripción | |-------|------|:---------:|-------------| | `to` | string | Sí | Teléfono chileno destino | | `body` | string | Sí | Texto del mensaje (máx. 1600 caracteres) | | `routeType` | string | Sí | Tipo de ruta para priorización | | `metadata` | object | No | Objeto JSON libre para trazabilidad | ### Formatos de Teléfono Aceptados | Formato | Ejemplo | Descripción | |---------|---------|-------------| | `+56XXXXXXXXX` | `+56912345678` | Con prefijo internacional | | `56XXXXXXXXX` | `56912345678` | Sin signo + | | `9XXXXXXXX` | `912345678` | Solo número móvil | Todos se normalizan internamente a `+56XXXXXXXXX`. ### Tipos de Ruta (`routeType`) El `routeType` determina la prioridad de procesamiento y el proveedor de envío: | Tipo | Descripción | Prioridad | |------|-------------|-----------| | `otp` | Códigos de verificación y autenticación | Máxima | | `premium` | Mensajes prioritarios de negocio | Alta | | `transactional` | Notificaciones de transacciones y eventos | Media | | `marketing` | Campañas promocionales (requiere opt-in del destinatario) | Baja | | `wholesale` | Envíos masivos de alto volumen | Mínima | :::caution[Rate Limit OTP] Los mensajes con `routeType: "otp"` tienen rate limiting por número de destino: 1 mensaje cada 5 minutos. Si envías otro OTP al mismo número antes de ese periodo, recibirás error 429. ::: ### Ejemplo con curl ```bash curl -X POST "https://api.redcumbre.cl/{tenantSlug}/sms/send" \ -H "Authorization: Bearer {api_key}" \ -H "Content-Type: application/json" \ -d '{ "to": "+56912345678", "body": "Hola, tu pedido #1234 fue despachado.", "routeType": "transactional" }' ``` --- ## Response ### Respuesta Exitosa (201) ```json { "messageId": "cmovnx2op0001sezxd0srby0g", "status": "queued", "segmentCount": 1, "encoding": "gsm7" } ``` | Campo | Tipo | Descripción | |-------|------|-------------| | `messageId` | string | Identificador único del mensaje (CUID) | | `status` | string | Siempre `"queued"` en respuesta inicial | | `segmentCount` | number | Cantidad de segmentos SMS | | `encoding` | string | `"gsm7"` o `"ucs2"` según caracteres usados | ### Segmentos y Encoding Los SMS se dividen en segmentos según la codificación del contenido: | Encoding | Caracteres por segmento | Cuándo se usa | |----------|:-----------------------:|---------------| | GSM-7 (`gsm7`) | 160 | Texto ASCII estándar (letras, números, puntuación básica) | | UCS-2 (`ucs2`) | 70 | Texto con caracteres especiales, emojis, acentos no-GSM | :::tip[Optimización de Costos] Evita emojis y caracteres especiales si no son necesarios. Un mensaje de 140 caracteres en GSM-7 es 1 segmento, pero con un solo emoji pasa a UCS-2 y se convierte en 2 segmentos. ::: --- ## Códigos de Error | Código | Descripción | Causa | |:------:|-------------|-------| | 400 | Validación fallida | Teléfono mal formado, body vacío, routeType inválido | | 401 | No autorizado | API key inválida, expirada o revocada | | 403 | Prohibido | Servicio SMS no habilitado o rol insuficiente | | 429 | Rate limited | OTP rate limit (1 por número cada 5 min) | ### Ejemplo de Error 400 ```json { "statusCode": 400, "message": [ "to must be a valid Chilean phone number (+56XXXXXXXXX, 56XXXXXXXXX, or 9XXXXXXXX)" ], "error": "Bad Request" } ``` ### Ejemplo de Error 403 ```json { "statusCode": 403, "message": "El servicio 'SMS' no está habilitado para este tenant. Contacte al administrador de la plataforma.", "error": "Forbidden" } ``` --- ## Consultar Estado del Mensaje Después de enviar un SMS, puedes consultar su estado de entrega: ### Listar Mensajes ``` GET /{tenantSlug}/sms/messages ``` ```bash curl "https://api.redcumbre.cl/{tenantSlug}/sms/messages?status=delivered" \ -H "Authorization: Bearer {api_key}" ``` ### Detalle de un Mensaje ``` GET /{tenantSlug}/sms/messages/{messageId} ``` ```bash curl "https://api.redcumbre.cl/{tenantSlug}/sms/messages/cmovnx2op0001sezxd0srby0g" \ -H "Authorization: Bearer {api_key}" ``` ### Estados del Mensaje | Estado | Terminal | Descripción | |--------|:--------:|-------------| | `queued` | No | En cola, pendiente de procesamiento | | `submitted` | No | Enviado al proveedor SMS | | `sent` | No | Confirmado enviado por el proveedor | | `delivered` | Sí | Entregado al destinatario | | `undelivered` | Sí | No se pudo entregar (número inválido, apagado, etc.) | | `failed` | Sí | Error en el envío (proveedor falló) | | `expired` | Sí | Sin confirmación de entrega después de 1 hora | | `rejected` | Sí | Rechazado por política de contenido | --- ## Estadísticas Consulta estadísticas de envío para un rango de fechas: ``` GET /{tenantSlug}/sms/stats?dateFrom=2026-01-01&dateTo=2026-01-31 ``` ```bash curl "https://api.redcumbre.cl/{tenantSlug}/sms/stats?dateFrom=2026-01-01&dateTo=2026-01-31" \ -H "Authorization: Bearer {api_key}" ``` --- ## Casos de Uso ### Notificación de Despacho ```bash curl -X POST "https://api.redcumbre.cl/{tenantSlug}/sms/send" \ -H "Authorization: Bearer {api_key}" \ -H "Content-Type: application/json" \ -d '{ "to": "+56987654321", "body": "Tu pedido #5678 fue despachado. Seguimiento: https://track.example.com/5678", "routeType": "transactional", "metadata": { "orderId": "5678", "type": "dispatch" } }' ``` ### Código de Verificación (OTP) ```bash curl -X POST "https://api.redcumbre.cl/{tenantSlug}/sms/send" \ -H "Authorization: Bearer {api_key}" \ -H "Content-Type: application/json" \ -d '{ "to": "+56912345678", "body": "Tu código de verificación es 482913. Expira en 5 minutos.", "routeType": "otp" }' ``` ### Integración JavaScript ```javascript const response = await fetch( `https://api.redcumbre.cl/${tenantSlug}/sms/send`, { method: 'POST', headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ to: '+56912345678', body: 'Recordatorio: tu cita es mañana a las 10:00.', routeType: 'transactional', metadata: { appointmentId: 'APT-100' }, }), } ); const { messageId, segmentCount } = await response.json(); console.log(`SMS encolado: ${messageId} (${segmentCount} segmento/s)`); ``` --- # Webhooks de eventos en tiempo real Fuente: https://docs.redcumbre.cl/guias/webhooks/ Los webhooks permiten que tu sistema reciba notificaciones HTTP automáticas cuando ocurren eventos importantes en Redcumbre. En lugar de consultar periódicamente nuestra API (polling), configurás una URL y nosotros te avisamos cuando algo sucede. --- ## Flujo de Webhooks ``` Tu Sistema Redcumbre Evento │ │ │ │── Configura webhook URL ─────▶│ │ │ (POST /config/webhooks) │ │ │◀── Secret HMAC ──────────────│ │ │ │ │ │ │◀─────── Ocurre evento ─────│ │ │ (BHE emitida, etc) │ │ │ │ │◀── POST webhook ─────────────│ │ │ (JSON + firma HMAC) │ │ │ │ │ │── 200 OK ────────────────────▶│ │ │ │── Log: WEBHOOK_SENT │ ``` **Características:** - Procesamiento asíncrono (no bloquea la operación principal) - Reintentos automáticos con backoff exponencial - Firma HMAC-SHA256 para validar autenticidad - Headers personalizados para autenticación --- ## Configuración de Webhooks La configuración de webhooks se realiza desde el **panel de administración de Redcumbre**: **Administración → Configuración → Webhooks** ### Opciones de Configuración | Campo | Descripción | |-------|-------------| | **URL del Webhook** | URL HTTPS donde recibirás las notificaciones | | **Headers Personalizados** | Headers HTTP adicionales para autenticación (ej: `Authorization: Bearer token`) | | **Eventos Habilitados** | Selecciona qué eventos quieres recibir. **Dejarlo vacío significa "todos"**, no "ninguno" — y eso incluye los eventos que se agreguen en el futuro. El filtro aplica cualquiera sea el destino, también al `callback_url` de PIN-RUT | | **Activo** | Habilitar/deshabilitar el envío a la **URL del Webhook**. No alcanza a los destinos que una operación declara por sí misma, como el `callback_url` de una transacción [PIN-RUT](/guias/pin-rut#webhooks): esos se siguen enviando, con los headers personalizados | ### Secret HMAC Al crear la configuración, el sistema genera automáticamente un **secret de 64 caracteres hexadecimales**. Este secret se usa para firmar los webhooks con HMAC-SHA256. :::note[Importante] Guarda el secret de forma segura. Lo necesitarás para validar la autenticidad de los webhooks que recibas. ::: :::caution[Si dejas "Eventos Habilitados" vacío, recibes todo — salvo dos familias] Un array vacío habilita **todos** los eventos, presentes y futuros. Cuando la plataforma agrega uno nuevo, tu endpoint empieza a recibirlo sin que hagas nada. Por eso tu receptor debe **ignorar los `event` que no reconozca** y responder `200` igual — si devuelve error ante un evento desconocido, entra en el ciclo de reintentos de entrega para nada. Si prefieres control estricto, selecciona explícitamente los eventos que te interesan. **La excepción son los eventos de alto volumen: `dte.emitido`, `dte.emision_terminada` y `batch.completado` son opt-in estricto.** Con el array vacío **no** los recibes; hay que listarlos uno por uno. Se emite uno por documento, y hay integradores con decenas de miles al mes: nadie los recibe sin haberlos pedido. Si emites documentos tributarios y no ves llegar nada, es lo primero que hay que revisar. ::: --- ## Catálogo de Eventos ### Eventos DTE (Documentos Tributarios) | Evento | Descripción | |--------|-------------| | `tokenizacion.completada` | Emisor autorizado exitosamente vía SII | | `emisor.certificado` | Un emisor DTE quedó productivo ante el SII. **Uno por carril** | | `emisor.revocado` | Emisor revocado (DTE o Tributario) | | `bhe.emitida` | Boleta de Honorarios emitida al SII | | `bhe.emision_terminada` | PDF SII disponible (modos SINC_PARCIAL/ASINCRONO) | | `bhe.pdf_sii_fallido` | PDF SII falló después de 7 días de reintentos | | `bhe.emision_reintentando` | Un intento de emisión asíncrona falló y va a reintentarse — informativo | | `bhe.emision_fallida` | Emisión asíncrona falló **definitivamente**. Se envía una sola vez | | `bhe.anulada` | Anulación de la BHE aceptada por el SII | | `bhe.anulacion_fallida` | El SII rechazó la anulación de la BHE | | `bhet.emitida` | Boleta de Terceros emitida al SII | | `bhet.emision_terminada` | PDF SII de la BHET disponible | | `bhet.pdf_fallido` | Generación del PDF SII de la BHET falló | | `bhet.emision_reintentando` | Un intento de emisión asíncrona de BHET falló y va a reintentarse | | `bhet.emision_fallida` | Emisión asíncrona de BHET falló **definitivamente**. Se envía una sola vez | | `bhet.anulada` | Anulación de la BHET aceptada por el SII | | `bhet.anulacion_fallida` | El SII rechazó la anulación de la BHET | | `dte.emitido` | El documento existe: folio, timbre y XML firmado. **Opt-in estricto** | | `dte.emision_terminada` | El SII resolvió el documento. **Opt-in estricto** | | `dte.emision_reintentando` | Un intento de emisión asíncrona de DTE falló y va a reintentarse | | `dte.emision_fallida` | Emisión asíncrona de DTE falló **definitivamente**. Se envía una sola vez | ### Los dos hitos de un documento tributario Emitir un documento y que el SII lo dé por bueno son **dos cosas distintas**, y cada una tiene su evento. Sirven para cualquier tipo de documento —facturas, boletas, notas, guías— y para cualquier forma de emisión. | Hito | Evento | Qué afirma | |---|---|---| | El documento existe | `dte.emitido` | Tiene folio del CAF, timbre y XML firmado. Ya puedes entregárselo a tu receptor | | El SII lo resolvió | `dte.emision_terminada` | El SII lo aceptó, lo aceptó con reparos o lo rechazó | Cuánto se separan en el tiempo depende de cómo se emite: | Forma de emisión | Qué pasa entre un hito y el otro | |---|---| | Portal MiPyme (facturas, notas, guías) | El portal devuelve el documento timbrado en el acto; el veredicto del registro de reclamos llega después | | Boletas electrónicas (39 y 41) | El envío al SII va en lotes, cada un minuto, y su respuesta dispara el segundo hito | | XML propio (Full DTE) | Un proceso envía el documento al SII y consulta el resultado por su track id | | Integración externa | Si delegaste el seguimiento ante el SII a tu propio sistema, **el segundo hito no se emite**: la plataforma no tiene veredicto que comunicarte | **`dte.emision_terminada` se emite una sola vez por documento**, en el momento en que el SII lo resuelve. Si más adelante ese documento cambia —por ejemplo, porque le emites una nota de crédito— no vuelve a dispararse. **Cuánto tardan las boletas.** La emisión en sí es sincrónica: la respuesta HTTP ya te devuelve el folio del CAF y el documento firmado. Lo que ocurre después en background es el envío al SII —en lotes, cada un minuto— y el registro de su respuesta, que es lo que dispara el webhook. Medido sobre 10.687 boletas reales de producción (30 días, agosto de 2026): **mediana 1 min 24 s, y el 99 % bajo 2 minutos** desde que se emite la boleta hasta que sale el evento. ### Eventos de procesos batch | Evento | Descripción | |--------|-------------| | `batch.completado` | Proceso batch terminado (exitoso o con errores). **Opt-in estricto**, igual que los de boleta | --- ### Eventos PIN-RUT | Evento | Descripción | |--------|-------------| | `kyc.identity.level_changed` | Se aprobó la revisión de identidad de la persona de una transacción de tu integración. Trae el `transaction_id` y en qué quedó la transacción (`pending`, `authorized` o `closed`). Alcanza **sólo** a la integración de esa transacción — ver [Identidad Acreditada](/guias/identidad-acreditada/) | --- ## Payloads de Eventos :::tip[`correlationId` viaja en todos los eventos de boleta de honorarios] Si enviaste `correlationId` al emitir una BHE o BHET, lo recibes de vuelta en **todos** los webhooks de esa boleta — no solo en `bhe.emitida` / `bhet.emitida`, sino también en los de PDF (`*.emision_terminada`, `bhe.pdf_sii_fallido`, `bhet.pdf_fallido`) y en los de anulación. Es la forma de amarrar la notificación con el registro de tu sistema sin guardar el id de Redcumbre. Si no lo enviaste, el campo llega en `null`. **Los eventos de documentos tributarios (`dte.*`) no lo incluyen**: amárralos por `dteId` o por `folio`. ::: ### tokenizacion.completada Se envía cuando un usuario completa el flujo de autorización de credenciales SII. ```json { "event": "tokenizacion.completada", "tenantId": "8", "sessionId": "c6c53392-bdb0-4053-8b9d-5b34296d63a3", "code": "83aK0esV0V21u8-krvuMiXx1OqheSVy56E6tBG3OjX8", "emisorId": "cmikf6ei00001sek0mxzcf668", "rut": "77438768-4", "tipoAutenticacion": "CLAVE_TRIBUTARIA", "tipoEmisor": "tributario", "correlationId": "mi-referencia-123", "lookupData": { "razonSocial": "EMPRESA EJEMPLO SPA", "giro": "SERVICIOS INFORMATICOS", "direcciones": [ { "ciudad": "SANTIAGO", "comuna": "PROVIDENCIA", "direccion": "AV. PROVIDENCIA 1234" } ] }, "timestamp": "2025-12-10T15:30:00Z" } ``` #### El campo `emisores`, en sesiones DTE Cuando la sesión es `tipoEmisor: "dte"`, el payload trae además `emisores` con **todos** los emisores que creó la sesión: ```json { "event": "tokenizacion.completada", "emisorId": "cmikf6ei00001sek0mxzcf668", "tipoEmisor": "dte", "emisores": [ { "emisorId": "cmikf6ei00001sek0mxzcf668", "carril": "FULL_DTE", "estado": "ESPERA_CERTIFICACION", "motivoInactivo": "PENDIENTE_CERTIFICACION", "tiposHabilitados": [] }, { "emisorId": "cmr8b52zn000fseodrcbqvr62", "carril": "SOLO_BOLETA", "estado": "ESPERA_CERTIFICACION", "motivoInactivo": "PENDIENTE_CERTIFICACION", "tiposHabilitados": [] } ] } ``` | Campo | Descripción | |-------|-------------| | `emisorId` (raíz) | Apunta a **un** emisor: el de facturación si la sesión pidió uno; si fue sólo boletas, el de boletas | | `emisores[].carril` | `PORTAL_MIPYME` o `FULL_DTE` (facturación) · `SOLO_BOLETA` (boletas) | | `emisores[].estado` | `OPERATIVO` (puede emitir ya) o `ESPERA_CERTIFICACION` (nació inactivo, espera al SII) | | `emisores[].motivoInactivo` | `PENDIENTE_CERTIFICACION`, o `null` cuando el emisor está operativo | | `emisores[].tiposHabilitados` | Códigos de tipo DTE que ese emisor puede emitir hoy. Vacío mientras espera la certificación | :::note[El estado no es definitivo] Un emisor en `ESPERA_CERTIFICACION` queda operativo cuando el SII resuelve, lo que puede tardar días. Ese desenlace llega en un webhook aparte: [`emisor.certificado`](#emisorcertificado), **uno por cada carril**. ::: --- ### emisor.certificado Se envía cuando un emisor DTE queda **productivo ante el SII**: su certificación llegó al desenlace y el emisor puede emitir. ```json { "event": "emisor.certificado", "data": { "emisorId": "cmikf6ei00001sek0mxzcf668", "emisorType": "dte", "rut": "77438768-4", "razonSocial": "EMPRESA EJEMPLO SPA", "tenantId": "8", "solicitudId": "cmr9x2k1p0001se7ab3cd4efg", "tipoIntegracion": "FULL_DTE", "tiposHabilitados": [33, 34, 52, 56, 61] }, "timestamp": "2026-01-15T11:02:44.318Z" } ``` | Campo | Descripción | |-------|-------------| | `tipoIntegracion` | El carril que quedó certificado: `FULL_DTE` o `SOLO_BOLETA`. **Es el discriminante**: no lo deduzcas de `tiposHabilitados` | | `tiposHabilitados` | Códigos de tipo DTE que el emisor puede emitir a partir de ahora | | `solicitudId` | La solicitud de certificación que llegó al desenlace | :::caution[Es un aviso por emisor, no por RUT] Un RUT con los dos carriles produce **dos avisos, en momentos distintos**: son dos certificaciones independientes. Ramifica por `tipoIntegracion` y no asumas que el segundo llega junto al primero. `PORTAL_MIPYME` no certifica —nace operativo—, así que ese carril nunca emite este evento. ::: --- ### emisor.revocado Se envía cuando un emisor es revocado (por el usuario o administrador). ```json { "event": "emisor.revocado", "data": { "emisorId": "cmikf6ei00001sek0mxzcf668", "emisorType": "tributario", "rut": "77438768-4", "razonSocial": "EMPRESA EJEMPLO SPA", "tenantId": "8", "revokedBy": "USER", "motivoRevocacion": null }, "timestamp": "2025-12-10T15:30:00Z" } ``` | Campo | Descripción | |-------|-------------| | `emisorType` | `tributario` o `dte` | | `revokedBy` | `USER` (usuario final) o `ADMIN` (administrador) | | `motivoRevocacion` | Motivo de revocación (solo cuando `revokedBy: ADMIN`) | --- ### bhe.emitida Se envía inmediatamente después de emitir una BHE exitosamente. ```json { "event": "bhe.emitida", "tenantId": "8", "bheId": "cm5abc123", "folioSii": "12345678", "emisor": { "rut": "78012039-8", "razonSocial": "JUAN PÉREZ GONZÁLEZ" }, "destinatario": { "rut": "12345678-9", "nombre": "EMPRESA CLIENTE SA", "sinDestinatario": false }, "montos": { "bruto": 210000, "ppm": 32025, "liquido": 177975 }, "tipoRetencion": "RETRECEPTOR", "canalEmision": "API_SYNC", "correlationId": "mi-referencia-123", "sandbox": false, "estadoPdfSii": "PENDIENTE", "descargas": { "pdfInterno": "https://app.redcumbre.cl/acme/tributario/boletas-honorarios/cm5abc123/descargar?tipo=interno", "pdfSii": "https://app.redcumbre.cl/acme/tributario/boletas-honorarios/cm5abc123/descargar?tipo=sii" }, "timestamp": "2025-12-10T15:30:00Z" } ``` `descargas` son los enlaces de descarga para usuarios con sesión en Redcumbre — los mismos que devuelve la respuesta de emisión. Ver [Enlaces de descarga para tus usuarios](/guias/boletas-honorarios/#enlaces-de-descarga-para-tus-usuarios). Los `montos` del ejemplo están calculados con la tasa de PPM vigente en **2026** (15,25%). La tasa cambia por año tributario: no la deduzcas de este ejemplo ni la fijes en tu código — léela de `GET /global/ppm`, o toma `ppm` del propio evento, que es el que se retuvo en **esa** boleta. --- ### bhe.emision_terminada Se envía cuando el PDF SII está disponible (modos SINC_PARCIAL y ASINCRONO). ```json { "event": "bhe.emision_terminada", "tenantId": "8", "bheId": "cm5abc123", "folioSii": "12345678", "correlationId": "mi-referencia-123", "pdfInternoUrl": "https://app.redcumbre.cl/p/boleta/eyJhbGciOiJIUzI1NiJ9...", "pdfSiiUrl": "https://app.redcumbre.cl/p/boleta/eyJhbGciOiJIUzI1NiJ9...", "estadoPdfSii": "DISPONIBLE", "descargas": { "pdfInterno": "https://app.redcumbre.cl/acme/tributario/boletas-honorarios/cm5abc123/descargar?tipo=interno", "pdfSii": "https://app.redcumbre.cl/acme/tributario/boletas-honorarios/cm5abc123/descargar?tipo=sii" }, "timestamp": "2025-12-10T15:35:00Z" } ``` --- ### bhe.pdf_sii_fallido Se envía después de 7 días de reintentos fallidos para descargar el PDF SII. ```json { "event": "bhe.pdf_sii_fallido", "tenantId": "8", "bheId": "cm5abc123", "folioSii": "12345678", "correlationId": "mi-referencia-123", "intentosRealizados": 30, "primerIntento": "2025-12-01T15:30:00Z", "ultimoIntento": "2025-12-08T15:30:00Z", "ultimoError": "SII_PDF_NO_DISPONIBLE", "descargas": { "pdfInterno": "https://app.redcumbre.cl/acme/tributario/boletas-honorarios/cm5abc123/descargar?tipo=interno" }, "timestamp": "2025-12-08T15:30:00Z" } ``` `descargas` trae **solo** `pdfInterno`: el respaldo SII no llegó a existir, así que no hay enlace que entregar para él. --- ### bhe.emision_reintentando Un intento de emisión asíncrona falló y el sistema **va a volver a intentarlo**. Es informativo: no tienes que hacer nada. **No reemitas al recibirlo.** Si lo haces, terminas con dos boletas ante el SII: la tuya y la que el reintento original emite después. ```json { "event": "bhe.emision_reintentando", "tenantId": "8", "correlationId": "mi-referencia-123", "intentoActual": 3, "intentosMaximos": 10, "proximoIntentoEn": "2025-12-08T10:35:00Z", "primerIntento": "2025-12-08T10:00:00Z", "ultimoError": "SII_SERVICE_UNAVAILABLE", "timestamp": "2025-12-08T10:15:00Z" } ``` `proximoIntentoEn` es el instante real del próximo intento, no una estimación: puedes agendar contra ese timestamp. `intentosMaximos` sale de la configuración del job que está corriendo, no de una constante global. Un job encolado antes de un cambio de política reporta el techo con el que fue encolado, así que el contador siempre cierra contra la realidad de *ese* intento. --- ### bhe.emision_fallida La emisión asíncrona falló **definitivamente**. Este evento llega **una sola vez** por emisión, y sólo en dos casos: - se agotaron los 10 intentos de la [ventana de reintentos](#reintentos-de-la-emisión-asíncrona) (18–22,6 h), o - el SII devolvió un error que no se puede recuperar reintentando (contribuyente no habilitado, RUT inválido, certificado vencido), en cuyo caso llega en el primer intento. Es la señal ante la cual **sí** corresponde actuar. Mientras la emisión sigue reintentándose recibes `bhe.emision_reintentando`. ```json { "event": "bhe.emision_fallida", "tenantId": "8", "correlationId": "mi-referencia-123", "intentosRealizados": 10, "primerIntento": "2025-12-08T10:00:00Z", "ultimoIntento": "2025-12-09T08:36:00Z", "ultimoError": "SII_SERVICE_UNAVAILABLE", "timestamp": "2025-12-09T08:36:00Z" } ``` :::note[Cambió en agosto de 2026] Antes este evento se emitía en **cada** intento fallido: una emisión que terminaba bien podía haberte mandado varios. Si tu integración lo trata como "llegó uno, ya falló", ahora eso es correcto — antes no lo era. ::: --- ### bhet.emitida Se envía inmediatamente después de emitir una Boleta de Terceros exitosamente. ```json { "event": "bhet.emitida", "tenantId": "8", "bhetId": "cm5xyz789", "folioSii": "462", "emisor": { "rut": "76123456-7", "razonSocial": "EMPRESA CONTRATANTE SPA" }, "tercero": { "rut": "78012039-8", "nombre": "FIRERAISE SPA", "domicilio": "AV PROVIDENCIA 1234 OF 501", "comuna": "SANTIAGO" }, "montos": { "bruto": 150000, "impuesto": 22875, "neto": 127125 }, "canalEmision": "API_SYNC", "correlationId": "mi-referencia-123", "sandbox": false, "estadoPdfSii": "PENDIENTE", "descargas": { "pdfInterno": "https://app.redcumbre.cl/acme/tributario/boletas-terceros/cm5xyz789/descargar?tipo=interno", "pdfSii": "https://app.redcumbre.cl/acme/tributario/boletas-terceros/cm5xyz789/descargar?tipo=sii" }, "timestamp": "2025-12-10T15:30:00Z" } ``` En BHET el respaldo SII se genera en background, así que este evento siempre llega con `estadoPdfSii: PENDIENTE`. Ver [Enlaces de descarga para tus usuarios](/guias/boletas-terceros/#enlaces-de-descarga-para-tus-usuarios). Los `montos` del ejemplo están calculados con la tasa de retención vigente en **2026** (15,25%). La tasa cambia por año tributario: no la deduzcas de este ejemplo ni la fijes en tu código — léela de `GET /global/bhet-retencion`, o toma `impuesto` del propio evento, que es el que se retuvo en **esa** boleta. --- ### bhet.emision_terminada Se envía cuando el PDF SII de la BHET quedó disponible. ```json { "event": "bhet.emision_terminada", "tenantId": "8", "bhetId": "cm5xyz789", "folioSii": "462", "correlationId": "mi-referencia-123", "pdfInternoUrl": "https://app.redcumbre.cl/p/boleta/eyJhbGciOiJIUzI1NiJ9...", "pdfSiiUrl": "https://app.redcumbre.cl/p/boleta/eyJhbGciOiJIUzI1NiJ9...", "estadoPdfSii": "DISPONIBLE", "descargas": { "pdfInterno": "https://app.redcumbre.cl/acme/tributario/boletas-terceros/cm5xyz789/descargar?tipo=interno", "pdfSii": "https://app.redcumbre.cl/acme/tributario/boletas-terceros/cm5xyz789/descargar?tipo=sii" }, "timestamp": "2025-12-10T15:35:00Z" } ``` --- ### bhet.pdf_fallido Se envía cuando falla la generación del PDF SII de la BHET. ```json { "event": "bhet.pdf_fallido", "tenantId": "8", "bhetId": "cm5xyz789", "folioSii": "462", "correlationId": "mi-referencia-123", "error": "Timeout al convertir el HTML del SII a PDF", "descargas": { "pdfInterno": "https://app.redcumbre.cl/acme/tributario/boletas-terceros/cm5xyz789/descargar?tipo=interno" }, "timestamp": "2025-12-10T15:40:00Z" } ``` La boleta **está emitida** ante el SII: lo que falló es el respaldo en PDF. Por eso `descargas` trae solo `pdfInterno`. --- ### bhet.emision_reintentando Igual que [`bhe.emision_reintentando`](#bheemision_reintentando), para boletas de terceros: un intento falló y el sistema va a volver a intentarlo. Informativo, no reemitas. ```json { "event": "bhet.emision_reintentando", "tenantId": "8", "correlationId": "mi-referencia-123", "intentoActual": 3, "intentosMaximos": 10, "proximoIntentoEn": "2025-12-08T10:35:00Z", "primerIntento": "2025-12-08T10:00:00Z", "ultimoError": "SII_SERVICE_UNAVAILABLE", "timestamp": "2025-12-08T10:15:00Z" } ``` Mismos campos que [`bhe.emision_reintentando`](#bheemision_reintentando): `proximoIntentoEn` es el instante real del próximo intento, e `intentosMaximos` sale del job en curso. --- ### bhet.emision_fallida La emisión asíncrona de la BHET falló **definitivamente**: se agotaron los 10 intentos, o el error del SII no se puede recuperar reintentando. Llega **una sola vez** por emisión. ```json { "event": "bhet.emision_fallida", "tenantId": "8", "correlationId": "mi-referencia-123", "intentosRealizados": 10, "primerIntento": "2025-12-08T10:00:00Z", "ultimoIntento": "2025-12-09T08:36:00Z", "ultimoError": "SII_SERVICE_UNAVAILABLE", "timestamp": "2025-12-09T08:36:00Z" } ``` No incluye `descargas` ni `bhetId`: no hay boleta emitida. --- ### dte.emision_reintentando Un intento de emisión asíncrona de un DTE —factura, boleta electrónica, nota de crédito— falló y el sistema **va a volver a intentarlo**. Informativo. **No reemitas al recibirlo.** Acá cuesta más caro que en boletas de honorarios: una reemisión consume un folio de tu CAF, que es un rango finito autorizado por el SII y que después hay que volver a pedir. ```json { "event": "dte.emision_reintentando", "tenantId": "8", "correlationId": "mi-referencia-123", "intentoActual": 3, "intentosMaximos": 10, "proximoIntentoEn": "2025-12-08T10:35:00Z", "primerIntento": "2025-12-08T10:00:00Z", "ultimoError": "SII_SERVICE_UNAVAILABLE", "timestamp": "2025-12-08T10:15:00Z" } ``` Mismos campos que [`bhe.emision_reintentando`](#bheemision_reintentando): `proximoIntentoEn` es el instante real del próximo intento, e `intentosMaximos` sale del job en curso. --- ### dte.emision_fallida La emisión asíncrona del DTE falló **definitivamente**: se agotaron los 10 intentos de la [ventana de reintentos](#reintentos-de-la-emisión-asíncrona) (18–22,6 h), o el error del SII no se puede recuperar reintentando (CAF vencido, folio duplicado, verificación de actividades pendiente). Llega **una sola vez** por emisión. Ningún folio quedó consumido: el documento nunca llegó a emitirse. ```json { "event": "dte.emision_fallida", "tenantId": "8", "correlationId": "mi-referencia-123", "intentosRealizados": 10, "primerIntento": "2025-12-08T10:00:00Z", "ultimoIntento": "2025-12-09T08:36:00Z", "ultimoError": "SII_SERVICE_UNAVAILABLE", "timestamp": "2025-12-09T08:36:00Z" } ``` --- ### dte.emitido El documento existe: tiene folio del CAF, timbre y XML firmado. Llega para cualquier tipo de documento y cualquier forma de emisión. **No afirma nada sobre el SII.** Si tu documento se somete a revisión —que es casi siempre—, el veredicto llega después en `dte.emision_terminada`. ```json { "event": "dte.emitido", "tenantId": "8", "timestamp": "2026-08-20T12:30:00.000Z", "data": { "dteId": "dte_a1b2c3", "tipoDte": "FACTURA_AFECTA", "folio": "4321", "rutEmisor": "76123456-7", "rutReceptor": "77999888-1", "razonSocialReceptor": "Cliente SpA", "fechaEmision": "2026-08-20", "montoTotal": "119000", "moneda": "CLP" } } ``` --- ### dte.emision_terminada El SII resolvió el documento. Es el evento que cierra el ciclo, y llega **una sola vez** por documento. `resultado` es lo que tienes que mirar: son tres valores y no cambian nunca. | `resultado` | Qué significa | |---|---| | `ACEPTADO` | El documento tiene validez tributaria | | `ACEPTADO_CON_REPAROS` | Válido igual, pero el SII acusó observaciones. `reparos` trae el detalle | | `RECHAZADO` | El SII no lo aceptó. El documento no tiene validez tributaria | `estadoSii` viaja al lado con la sigla cruda que el SII devolvió —`DOK`, `ACEPTADO`, `RPR`, `RCH`…—. **No la uses para decidir:** el SII usa vocabularios distintos según el tipo de documento y por dónde se envió, así que la misma situación llega con siglas diferentes. Está para que puedas registrarla y para soporte. ```json { "event": "dte.emision_terminada", "tenantId": "8", "timestamp": "2026-08-20T12:31:24.000Z", "data": { "dteId": "dte_a1b2c3", "tipoDte": 33, "folio": 4321, "resultado": "ACEPTADO", "estadoSii": "ACEPTADO", "rutEmisor": "76123456-7", "rutReceptor": "77999888-1", "razonSocialReceptor": "Cliente SpA", "montoTotal": 119000, "reparos": null } } ``` Con reparos, `reparos` trae lo que observó el SII: ```json { "event": "dte.emision_terminada", "tenantId": "8", "timestamp": "2026-08-20T12:31:24.000Z", "data": { "dteId": "dte_a1b2c3", "tipoDte": 39, "folio": 861310, "resultado": "ACEPTADO_CON_REPAROS", "estadoSii": "RLV", "reparos": [ { "codigo": "2", "descripcion": "Giro del emisor no corresponde" } ] } } ``` :::note[Notas de crédito y débito] Para una NC (61) o una ND (56), el primer `ACEPTADO` del SII todavía no cierra el ciclo: el documento sigue en seguimiento hasta que el documento al que hace referencia refleje la nota. El evento llega recién ahí, con el desenlace definitivo. ::: Cuando el SII rechaza una boleta electrónica, además del webhook la plataforma avisa por email a los usuarios con rol SUPER-ADMIN y ADMIN del tenant. --- ### bhe.anulada / bhet.anulada El SII aceptó la anulación de la boleta. Mismo payload para ambos eventos; cambia cuál de `bheId` / `bhetId` viene poblado. ```json { "event": "bhe.anulada", "tenantId": "8", "anulacionId": "cm5anul123", "tipoBoleta": "BHE", "folioSii": "12345678", "bheId": "cm5abc123", "causaSii": "3", "codigoResultadoSii": "0", "mensajeSii": "Anulación realizada con éxito", "fechaAnulacionSii": "2026-08-13T12:30:00.000Z", "correlationId": "mi-referencia-123" } ``` --- ### bhe.anulacion_fallida / bhet.anulacion_fallida El SII rechazó la anulación. Mismos campos que el evento de éxito; `mensajeSii` y `codigoResultadoSii` traen el motivo del rechazo y `fechaAnulacionSii` no viene. ```json { "event": "bhe.anulacion_fallida", "tenantId": "8", "anulacionId": "cm5anul123", "tipoBoleta": "BHE", "folioSii": "12345678", "bheId": "cm5abc123", "causaSii": "3", "codigoResultadoSii": "-1", "mensajeSii": "La boleta no puede anularse: período ya declarado", "correlationId": "mi-referencia-123" } ``` Una boleta que **ya estaba anulada** no genera evento: desde tu lado no cambió nada. --- ### batch.completado Un proceso batch terminó. Recuerda que es **opt-in estricto**: hay que listarlo explícitamente en "Eventos Habilitados". ```json { "event": "batch.completado", "tenantId": "8", "timestamp": "2026-08-13T12:30:00.000Z", "data": { "procesoBatchId": "cm5batch123", "tipo": "BHE_EMISION", "estado": "COMPLETADO", "nombre": "Carga honorarios agosto", "resumen": { "total": 120, "exitosos": 118, "fallidos": 2, "porcentaje": 98 }, "emisor": null, "fechas": { "creado": "2026-08-13T12:00:00.000Z", "iniciado": "2026-08-13T12:00:05.000Z", "finalizado": "2026-08-13T12:29:41.000Z" }, "urlResultados": "/acme/procesos-batch/cm5batch123/resultados" } } ``` `emisor` es siempre `null`: cada item del proceso puede tener un emisor distinto, así que no hay uno global. `estado` es `COMPLETADO` tanto si todos los items salieron bien como si algunos fallaron — mira `resumen.fallidos` para distinguirlo. --- ## Eventos que aparecen en la configuración pero todavía no se despachan La pantalla de configuración permite seleccionar algunos eventos que hoy **no llegan a ningún endpoint**. Están declarados en el catálogo, pero no hay nada que los emita: | Evento | Alternativa | |--------|-------------| | `dte.boleta.aceptada`, `dte.boleta.rechazada`, `dte.boleta.reparo` | Se retiraron: describían para boletas el mismo hito que `dte.emision_terminada` cubre para todo tipo de documento. Migra a ese evento y ramifica por `resultado` | | `dte.emision_terminada` | Consulta `estadoPdfSii` del DTE | | `onexo.postulacion-completada` | — | | `onexo.postulacion-aprobada` | — | | `onexo.postulacion-rechazada` | — | | `onexo.postulacion.mensaje-enviado` | — | | `onexo.aclaracion-solicitada` | Deprecado desde abril de 2026 | | `onexo.curso-aprobado` | — | | `validafirma.documento-completado` | — | :::danger[No diseñes tu integración contra estos eventos] Habilitarlos no produce ningún error: la configuración los acepta y simplemente no llega nada. Si tu flujo espera uno de ellos, se queda esperando para siempre. Cuando alguno empiece a despacharse, se documenta acá con su payload. ::: --- ## Seguridad: Validación HMAC Cada webhook incluye una firma HMAC-SHA256 en el header `X-Webhook-Signature` que debes validar para asegurar que el webhook es auténtico. La firma solo se envía si tu configuración de webhooks está **activa** y tiene un secret asignado (Administración → Configuración → Webhooks). ### Formato del header El header **no** es el digest a secas: usa el esquema con timestamp, igual que Stripe. ``` X-Webhook-Signature: t=,v1= ``` | Parte | Contenido | |---|---| | `t` | Timestamp Unix en segundos, el mismo que entra en el cálculo de la firma | | `v1` | HMAC-SHA256 en hexadecimal minúscula (64 caracteres) | El header completo mide **80 caracteres** mientras el timestamp tenga 10 dígitos. ### Qué se firma No se firma el payload solo, sino la concatenación `.`: ``` firma = HMAC_SHA256(secret, `${t}.${rawBody}`) → hex ``` :::caution[Usa el raw body, no el JSON re-serializado] `rawBody` es el cuerpo **tal cual llegó por la red**. Si tu framework parsea el JSON a objeto y después haces `JSON.stringify()` encima, el orden de claves o el espaciado pueden diferir del original y la firma no va a coincidir nunca. En Express usa `express.raw()` para esa ruta, o `express.json({ verify: (req, res, buf) => { req.rawBody = buf.toString() } })`. ::: ### Ejemplo en Node.js ```javascript const crypto = require('crypto'); function validarFirma(rawBody, header, secret) { if (!header) return false; const partes = Object.fromEntries( header.split(',').map((p) => { const i = p.indexOf('='); return [p.slice(0, i), p.slice(i + 1)]; }) ); const { t, v1 } = partes; if (!t || !v1) return false; // Rechazar firmas viejas (protección contra replay). Tolerancia sugerida: 5 minutos. const edad = Math.abs(Math.floor(Date.now() / 1000) - Number(t)); if (edad > 300) return false; const esperada = crypto .createHmac('sha256', secret) .update(`${t}.${rawBody}`) .digest('hex'); // timingSafeEqual lanza si los buffers tienen distinto largo: comparar el largo primero. const recibida = Buffer.from(v1, 'hex'); const calculada = Buffer.from(esperada, 'hex'); return ( recibida.length === calculada.length && crypto.timingSafeEqual(recibida, calculada) ); } // Uso en endpoint receptor app.post( '/webhooks/redcumbre', express.raw({ type: 'application/json' }), (req, res) => { const rawBody = req.body.toString('utf8'); const secret = process.env.REDCUMBRE_WEBHOOK_SECRET; if (!validarFirma(rawBody, req.headers['x-webhook-signature'], secret)) { return res.status(401).json({ error: 'Invalid signature' }); } const { event, ...data } = JSON.parse(rawBody); console.log(`Received ${event}:`, data); res.status(200).json({ success: true }); } ); ``` ### Ejemplo en Python ```python import hmac, hashlib, time def validar_firma(raw_body: bytes, header: str, secret: str) -> bool: if not header: return False partes = dict(p.split("=", 1) for p in header.split(",")) t, v1 = partes.get("t"), partes.get("v1") if not t or not v1: return False if abs(int(time.time()) - int(t)) > 300: return False esperada = hmac.new( secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256, ).hexdigest() return hmac.compare_digest(v1, esperada) ``` ### Regenerar el secret Desde Administración → Configuración → Webhooks puedes regenerar el secret. La acción **invalida el anterior de inmediato**: todo webhook despachado después viaja firmado con el secret nuevo, sin período de gracia ni doble firma. Actualiza tu receptor antes de regenerar, o vas a rechazar como inválidos los eventos de esa ventana. Es una acción del panel, no de la API pública: no hay endpoint con API key para regenerarlo. --- ## Reintentos y Manejo de Errores :::note[Son dos políticas distintas, no las confundas] **Reintentos de la emisión** — cuando el SII no responde, la plataforma vuelve a intentar emitir tu documento durante horas. Es la que produce los eventos `*_reintentando` y `*_fallida`. **Reintentos de la entrega del webhook** — cuando *tu endpoint* no responde, reintentamos el POST durante ~6 minutos. Nada que ver con la anterior. Una emisión puede reintentarse 10 veces en 22 horas y, aun así, perderse el aviso si tu endpoint estuvo caído los ~6 minutos en que se intentó entregar. ::: ### Reintentos de la emisión asíncrona Aplica a la emisión asíncrona de BHE, BHET y DTE. Los tres carriles comparten la misma curva: **10 intentos**, con la espera duplicándose desde 5 minutos y un techo de 6 horas por hueco. | Intento que falló | Espera hasta el siguiente | Acumulado | |---|---|---| | 1 | 5 min | 5 min | | 2 | 10 min | 15 min | | 3 | 20 min | 35 min | | 4 | 40 min | 1 h 15 min | | 5 | 1 h 20 min | 2 h 35 min | | 6 | 2 h 40 min | 5 h 15 min | | 7 | 5 h 20 min | 10 h 35 min | | 8 | 6 h (techo) | 16 h 35 min | | 9 | 6 h (techo) | **22 h 35 min** | A cada espera se le aplica un jitter que sólo puede **restar** hasta un 20 %, para que dos emisiones distintas no golpeen el SII al mismo tiempo cuando vuelve de una caída. Por eso la ventana real va de **18 h a 22,6 h**, no es un número fijo. El jitter es determinista, no aleatorio: el `proximoIntentoEn` que viaja en `*_emision_reintentando` es el instante exacto en que el reintento va a ocurrir, y puedes agendar contra él. **Durante todo ese tiempo recibes `*_emision_reintentando` (informativo) y no debes reemitir.** Sólo al final, si se agotaron los 10 intentos, llega `*_emision_fallida` — una sola vez. Un error del SII que no se puede recuperar reintentando (contribuyente no habilitado, RUT inválido, certificado vencido, CAF vencido) corta la curva de inmediato y salta directo a `*_emision_fallida` en el primer intento. ### Reintentos de la entrega del webhook Cuando tu endpoint no responde 2xx, reintentamos el POST **12 veces**, con la espera duplicándose desde 1 segundo y un techo de 1 minuto por hueco: | Intento | Espera hasta el siguiente | Acumulado | |---------|---------------------------|-----------| | 1 | +1 s | 1 s | | 2 | +2 s | 3 s | | 3 | +4 s | 7 s | | 4 | +8 s | 15 s | | 5 | +16 s | 31 s | | 6 | +32 s | 1 min 3 s | | 7 | +1 min (techo) | 2 min 3 s | | 8 | +1 min | 3 min 3 s | | 9 | +1 min | 4 min 3 s | | 10 | +1 min | 5 min 3 s | | 11 | +1 min | **6 min 3 s** | | 12 (final) | — | — | **Total:** ~6 minutos desde el primer intento hasta el último. La ventana es exacta, sin variación aleatoria: puedes dimensionar tu ventana de despliegue contra ella. El techo de 1 minuto es deliberado. Los primeros intentos son rápidos porque un `502` puntual se resuelve en segundos; a partir del séptimo la espera se estabiliza en un minuto, así que un endpoint que vuelve a los tres minutos recibe el evento pendiente dentro del minuto siguiente, en vez de esperar a que una curva exponencial cierre su último hueco. :::note[Cambió en agosto de 2026] Antes eran 6 intentos en ~31 segundos. Era menos de lo que tarda un deploy en levantar un proceso: cualquier reinicio de tu endpoint perdía el evento. Si dimensionaste tu integración contra la ventana vieja, ahora tienes ~6 minutos de margen — pero la recomendación de reconciliar sigue igual de vigente. ::: --- ### Respuestas Esperadas | Código HTTP | Resultado | |-------------|-----------| | **200-299** | Webhook recibido correctamente | | **4xx** | Error cliente - Se reintentará | | **5xx** | Error servidor - Se reintentará | | **Timeout** | Sin respuesta en 30s - Se reintentará | :::danger[Al agotarse los 12 intentos, el evento se pierde] No hay reenvío ni replay: pasados los ~6 minutos, esa notificación no vuelve a intentarse y no existe forma de solicitarla de nuevo. La ventana cubre un deploy o un reinicio normal, pero no una caída larga: un endpoint que estuvo abajo diez minutos pierde de forma permanente todo lo que se le intentó entregar en ese lapso. **Trata el webhook como un aviso, no como la fuente de verdad.** El estado real siempre está en la API: - Emisión asíncrona → `GET /{tenant}/bhe/intento/{intentoId}` (y sus equivalentes de BHET y DTE) - Documento emitido → `GET /{tenant}/dte/{id}` Para integraciones críticas, agrega una reconciliación periódica que consulte los recursos en estado no terminal en vez de depender sólo del webhook. ::: :::caution[Quién recibe el aviso de fallo] Cuando una entrega agota sus 12 intentos, los usuarios con rol **SUPER-ADMIN** del tenant reciben una notificación por email y en la plataforma. Es sólo ese rol: **un tenant sin ningún SUPER-ADMIN no recibe el aviso**. Si administras la integración con un usuario `ADMIN`, asegúrate de que exista al menos un SUPER-ADMIN o no te vas a enterar de que tu endpoint dejó de responder. ::: --- ## Mejores Prácticas ### Para tu Sistema Receptor 1. **Responde rápido (< 5 segundos)** - Timeout máximo: 30 segundos - Procesa en background si necesitas más tiempo 2. **Retorna HTTP 200-299 siempre que recibas el webhook** ```json { "success": true, "receivedAt": "2025-12-10T15:30:00Z" } ``` 3. **Implementa idempotencia** - Los reintentos pueden enviar el mismo webhook múltiples veces - Usa `timestamp` o un campo único para detectar duplicados 4. **Valida la firma HMAC** - Nunca proceses webhooks sin validar la firma - Usa comparación timing-safe 5. **Logea todos los webhooks recibidos** - Ayuda al debugging - Permite detectar problemas de entrega --- ## Testing ### Usando webhook.site [webhook.site](https://webhook.site) es una herramienta gratuita para testear webhooks: 1. Ve a https://webhook.site 2. Copia tu URL única (ej: `https://webhook.site/abc-123`) 3. Configura esa URL en tu tenant 4. Realiza una operación que genere webhook (ej: emitir BHE) 5. Verifica en webhook.site que recibiste el POST ### Probando Reintentos Para testear la lógica de reintentos, puedes usar URLs que retornan errores: ```bash # Retorna siempre HTTP 500 https://httpstat.us/500 # Retorna HTTP 503 https://httpstat.us/503 # Delay de 35 segundos (causa timeout) https://httpbin.org/delay/35 ``` --- ## Testing (Sandbox) En modo sandbox, los webhooks funcionan normalmente pero los datos son de prueba: - API Keys con `isSandbox: true` envían webhooks con datos simulados - No hay llamadas reales al SII - Útil para validar tu integración antes de producción **RUTs de prueba:** | RUT | Resultado | |-----|-----------| | `78012039-8` | Empresa completa (FIRERAISE SPA) | | `77425402-1` | Empresa con múltiples direcciones | | `99999999-9` | Simula error | --- ## API Reference Los webhooks son notificaciones **salientes**: tu sistema las recibe, no las llama. Por eso no tienen una sección propia en Swagger. El detalle del payload de cada evento está en el tag del dominio que lo emite —por ejemplo, `tokenizacion.completada` se documenta dentro de [Tokenización SII](https://api.redcumbre.cl/api-docs#/Tokenizaci%C3%B3n%20SII)— y los ejemplos completos están más arriba en esta misma guía. --- # Mensajes de WhatsApp por plantilla Fuente: https://docs.redcumbre.cl/guias/whatsapp/ :::tip[TL;DR - Acceso Rápido] **Endpoint:** `POST /{tenantSlug}/canales/whatsapp/{channelId}/enviar` 👉 [Ver endpoint en Swagger](https://api.redcumbre.cl/api-docs#/Agentes) ::: El canal de mensajería de WhatsApp permite despachar **mensajes de plantilla ya aprobados por Meta** por el número de WhatsApp de tu tenant, desde tu propio sistema y con una API key. Cada despacho queda registrado y se puede consultar después por su identificador. --- ## Hasta dónde llega este servicio, hoy Un despacho avanza hasta **«entregado»**, y también se registra **cuándo el destinatario lo leyó**. Ese avance lo puedes **consultar** o **recibir en tu webhook**. Cuando el endpoint responde `201`, lo que la plataforma afirma es que **Meta aceptó** el mensaje y devolvió su identificador — todavía no que haya llegado al teléfono. Después llegan los acuses de WhatsApp y el estado avanza solo: `submitted` → `sent` → `delivered`. El instante de lectura se guarda aparte: leer no es un estado. Pero **una lectura implica la entrega**: si WhatsApp informa que el mensaje se leyó y el acuse de entrega no había llegado, el mensaje avanza a `delivered`. Si enciendes la notificación de estados en el webhook del canal, cada avance te llega como el evento `whatsapp.mensaje.estado`: ver [Recibir el estado de tus despachos](#recibir-el-estado-de-tus-despachos). Y por el mismo camino llegan **los mensajes que te escriben**: se registran, se te cobran, y se entregan al webhook de recepción que configures para el canal. :::caution[Un mensaje que NO es texto llega sin su contenido] De los tipos que WhatsApp maneja, **sólo el texto trae el contenido en el aviso**. Un audio, una imagen, un documento o una ubicación llegan con **el tipo, quién escribió y cuándo**, y el campo del cuerpo **vacío**: el archivo queda guardado en WhatsApp y hay que descargarlo con el testigo del canal, que no está expuesto todavía. 🔴 **Ese mensaje se cobra igual**, porque lo recibiste y quedó registrado. El desenlace de la entrega lo dice con un valor propio —`entregado_sin_contenido`— para que no haya que deducirlo cruzando el tipo con un cuerpo vacío. La descarga del archivo es **el paso siguiente** de este canal, no una limitación permanente. ::: --- ## Requisitos Previos Antes de despachar tu primer mensaje necesitas cuatro cosas: ### 1. El servicio `WHATSAPP_MENSAJERIA` habilitado El tenant tiene que tener contratado el servicio de mensajería de WhatsApp. Si no lo tiene, la llamada responde `403` diciéndolo. ### 2. El número de WhatsApp conectado El canal se conecta desde el panel, en **Comunicación → Canales**. Un canal que no quedó conectado —o cuyo token de Meta expiró— responde `422` al despachar. ### 3. Una API key con el rol `WHATSAPP_MENSAJERIA_DESPACHO` **No sirve `FULL-API`, y es a propósito.** El rol es angosto: habilita **despachar** y **consultar un despacho**, y nada más. No habilita configurar el canal, conectar un número, consultar su salud ni administrar plantillas. La llave la crea un administrador del tenant desde el panel, en **Configuración → API Keys**. En el desplegable de rol, el que corresponde es **Despacho Mensajería WhatsApp** (`WHATSAPP_MENSAJERIA_DESPACHO`). :::note[Una API key no crea API keys] La creación de credenciales es superficie de sesión humana con rol `ADMIN` o `SUPER-ADMIN`, no de API key. No hay forma de emitir la llave desde tu sistema. ::: :::note[Qué pasa si la usas fuera de su alcance] Con esta llave, `POST /{tenantSlug}/sms/send` responde `403`. Igual que cualquier otro endpoint fuera de las dos rutas de esta guía. El cuerpo del `403` es siempre el mismo, así que no lo uses para averiguar qué rutas existen. ::: Ver [Autenticación](/primeros-pasos/autenticacion/) para el manejo general de API keys. ### 4. Una plantilla aprobada por Meta **Las plantillas las administra el tenant desde el panel**, en el canal de WhatsApp → *Plantillas*. Esta guía no cubre cómo crearlas; lo que sí importa para integrar es: - La plantilla tiene que estar **aprobada** por Meta antes de despacharla. Una plantilla que no existe o no está aprobada produce un `422` al enviar. - Se identifica por el par **nombre + idioma** (`templateName` + `languageCode`), no por un id. - 🔴 **Meta puede reclasificar la categoría de una plantilla.** Está medido: una plantilla enviada como `UTILITY` volvió `MARKETING`. El tenant no controla la categoría, y la categoría es lo que Meta cobra y limita distinto. Si un despacho se te empieza a rechazar por límites, ésa es la primera hipótesis. ### Y el `channelId` El identificador del canal va en la ruta. Lo obtienes del panel: es el segmento de la URL cuando abres el canal en **Comunicación → Canales** (`/{tenantSlug}/comunicacion/canales/{channelId}`). **La API key no lo lleva adentro**: la llave no está atada a ningún canal, el canal lo determina la ruta y su pertenencia al tenant se comprueba en cada llamada. --- ## Flujo del Despacho ``` POST /{tenantSlug}/canales/whatsapp/{channelId}/enviar │ ▼ El canal es de este tenant, o no existe (404) │ ▼ Se escribe la fila del registro (estado: queued) │ ▼ POST a Meta con los componentes declarados │ ▼ Meta acusa → fila con wamid, estado: submitted │ ▼ Se emite el evento de cobro │ ▼ Respuesta: { id, wamid, estado, ... } │ ▼ Después, por su cuenta: los acuses de WhatsApp avanzan el estado a sent → delivered, y llenan la marca de lectura sin cambiar el estado ``` La respuesta es **síncrona**: cuando la recibes, el mensaje ya salió (o ya falló). La fila del registro se escribe **antes** de llamar a Meta, a propósito: el envío es irreversible, así que un despacho que falla **igual queda registrado**, con la causa exacta que devolvió Meta, en vez de desaparecer. :::tip[El cuerpo de un error trae `mensajeId`: es la clave del registro] Cuando el despacho falla, el cuerpo del error incluye el campo **`mensajeId`** con el identificador de la fila que quedó escrita. Es el mismo valor que el `id` de la respuesta `201`, y es lo que le pasas a `GET /{tenantSlug}/canales/whatsapp/{channelId}/mensajes/{id}` para leer la causa guardada sin abrir el panel. Vale para el `422`, el `429` y el `400` con `error: "WHATSAPP_SIN_IDENTIFICADOR_DE_MENSAJE"`. El `404` del canal y el `401`/`403` de la credencial **no** lo traen: ahí no llegó a escribirse ninguna fila, porque el registro se escribe después de resolver el canal. ::: --- ## Request ### Endpoint ``` POST /{tenantSlug}/canales/whatsapp/{channelId}/enviar ``` ### Headers ``` Authorization: Bearer {api_key} Content-Type: application/json ``` ### Body ```json { "destinatario": "+56912345678", "templateName": "documento_firmado_v1", "languageCode": "es", "componentes": { "cuerpo": { "variables": [ { "posicion": 1, "valor": "María" }, { "posicion": 2, "valor": "Contrato 4821" } ] }, "boton": { "indice": "0", "variables": [ { "posicion": 1, "valor": "f/abc123XYZ" } ] } }, "referenciaExterna": "contrato-4821" } ``` | Campo | Tipo | Requerido | Descripción | |-------|------|:---------:|-------------| | `destinatario` | string | Sí | Número del destinatario, con código de país | | `templateName` | string | Sí | Nombre exacto de la plantilla aprobada | | `languageCode` | string | Sí | Idioma exacto de la plantilla (`es`, `es_CL`, …) | | `componentes` | object | No | Los componentes de la plantilla. Ver abajo | | `variables` | array | No | Forma anterior: equivale a `componentes.cuerpo.variables` | | `referenciaExterna` | string | No | Tu referencia para reconocer el despacho. Ver abajo | **`referenciaExterna`** vuelve en la respuesta, en la [consulta del despacho](#consultar-el-estado-de-un-despacho) y en cada evento [`whatsapp.mensaje.estado`](#recibir-el-estado-de-tus-despachos). Hasta 128 caracteres: letras, dígitos, punto (`.`), guion bajo (`_`), dos puntos (`:`) y guion (`-`). Fuera de esa forma el despacho responde `400` y no se envía nada. - **No pongas datos personales** —RUT, teléfono, correo, nombre—: la referencia se registra en el log y se guarda junto al mensaje. Usa el identificador de tu sistema. - **No se envía a Meta**: el destinatario no la ve. - **No es clave de idempotencia**: dos despachos con la misma referencia son dos mensajes, y se cobran los dos. ### El destinatario El número se normaliza a **sólo dígitos** —se le quitan `+`, espacios y guiones— y **no se le agrega código de país**. Manda el número internacional completo: | Lo que mandas | Lo que sale a Meta | |---|---| | `+56912345678` | `56912345678` | | `56 9 1234 5678` | `56912345678` | | `912345678` | `912345678` ❌ — sin código de país, Meta no lo va a resolver | La respuesta trae `destinatarioNormalizado` con el número **tal como Meta lo normalizó**, que puede diferir del que enviaste. --- ## 🔴 El contrato de variables: posicional POR COMPONENTE Es la parte que más confunde, y conviene leerla dos veces. Una plantilla de WhatsApp puede tener variables en **tres lugares distintos**: el encabezado, el cuerpo y el botón de URL. **Cada uno numera sus variables desde `{{1}}` por separado** — así lo define la API de Meta, no es una decisión de esta plataforma. Toma esta plantilla: ``` Cuerpo: Hola {{1}}, tu documento {{2}} está listo para firmar. Botón: https://validafirma.cl/{{1}} ``` Hay **tres** valores que llenar, pero la variable del botón **es su propio `{{1}}`**, no el `{{3}}` del cuerpo. Un arreglo plano de tres variables no puede expresar esto. ```json { "componentes": { "cuerpo": { "variables": [ { "posicion": 1, "valor": "María" }, { "posicion": 2, "valor": "Contrato 4821" } ] }, "boton": { "indice": "0", "variables": [ { "posicion": 1, "valor": "f/abc123XYZ" } ] } } } ``` ### Las tres reglas que rompen la intuición **1. Cada componente numera desde `{{1}}`.** El `posicion` es 1-based y corresponde al `{{n}}` **de ese componente**, no del mensaje completo. **2. El `indice` del botón viaja como cadena.** `"0"`, nunca `0`. Es lo que exige Meta; declararlo como número produce un cuerpo que Meta rechaza, y el rechazo llega recién al despachar. El índice es la posición del botón en la plantilla: el primero es `"0"`. **3. 🔴 Un componente que la plantilla no tiene se OMITE, no se manda vacío.** Una plantilla sin botón **rechaza** el envío que lo incluya. No mandes `"boton": { "indice": "0", "variables": [] }` «por las dudas»: no es inofensivo, es un envío fallido. Una plantilla **sin botón**, entonces, simplemente no declara ese componente: ```json { "destinatario": "+56912345678", "templateName": "aviso_simple_v1", "languageCode": "es", "componentes": { "cuerpo": { "variables": [{ "posicion": 1, "valor": "María" }] } } } ``` Una plantilla **sin ninguna variable** se despacha sin `componentes`: ```json { "destinatario": "+56912345678", "templateName": "aviso_fijo_v1", "languageCode": "es" } ``` ### Encabezado de imagen Si la plantilla lleva un encabezado de imagen, el componente acepta el **identificador de material de Meta** que tú ya subiste: ```json { "componentes": { "encabezado": { "mediaId": "1639350477750383" }, "cuerpo": { "variables": [{ "posicion": 1, "valor": "María" }] } } } ``` La plataforma **acepta** ese identificador; **no lo produce**. La subida del material a Meta la haces tú, contra la API de Meta. Y es el `id` del material —lo que Meta devuelve al subirlo—, nunca un `header_handle` (que es de la creación de la plantilla) ni una URL de descarga. ### La forma plana `variables` El campo `variables` sigue aceptado y es **exactamente** `componentes.cuerpo.variables`: ```json { "destinatario": "+56912345678", "templateName": "aviso_simple_v1", "languageCode": "es", "variables": [{ "posicion": 1, "valor": "María" }] } ``` :::danger[Los dos juntos se rechazan, no se elige uno] Mandar `variables` **y** `componentes` en la misma petición responde `400` con el código `WHATSAPP_VARIABLES_Y_COMPONENTES_EXCLUYENTES` al principio del mensaje. No se elige uno en silencio a propósito: adivinar cuál gana significaría que te enteras del error cuando el mensaje ya salió con las variables equivocadas, y eso no se puede deshacer. ::: ### Ejemplo con curl ```bash curl -X POST "https://api.redcumbre.cl/{tenantSlug}/canales/whatsapp/{channelId}/enviar" \ -H "Authorization: Bearer {api_key}" \ -H "Content-Type: application/json" \ -d '{ "destinatario": "+56912345678", "templateName": "documento_firmado_v1", "languageCode": "es", "componentes": { "cuerpo": { "variables": [ { "posicion": 1, "valor": "María" }, { "posicion": 2, "valor": "Contrato 4821" } ] }, "boton": { "indice": "0", "variables": [{ "posicion": 1, "valor": "f/abc123XYZ" }] } } }' ``` --- ## Response ### Respuesta Exitosa (201) ```json { "success": true, "data": { "id": "cmtugt4ln0003se8nqas72beu", "wamid": "wamid.HBgLNTY5ODQzMDYyNDQVAgARGBI0QTMxRjNBREJBNDEyMjMxQUYA", "estado": "submitted", "destinatarioNormalizado": "56912345678", "sandbox": false, "cobrado": true, "noCobradoMotivo": null, "referenciaExterna": "contrato-4821" } } ``` | Campo | Tipo | Descripción | |-------|------|-------------| | `id` | string | Identificador de la fila del registro | | `wamid` | string | Identificador del mensaje en Meta | | `estado` | string | `submitted` cuando Meta aceptó. Avanza a `sent` y `delivered` con los acuses. Ver [estados](#estados-del-mensaje) | | `destinatarioNormalizado` | string | El número tal como Meta lo normalizó | | `sandbox` | boolean | `true` si el despacho fue simulado | | `cobrado` | boolean | Si se emitió el evento de facturación | | `noCobradoMotivo` | string \| null | `sandbox` o `sin_suscripcion` cuando `cobrado` es `false` | | `referenciaExterna` | string \| null | La que mandaste en el body, tal cual. `null` si no la mandaste | :::tip[Guarda el `id`, no sólo el `wamid`] El `id` es con lo que consultas el despacho después. El endpoint de consulta recibe el `id` de la fila, **no** el `wamid`: si sólo guardas el `wamid` no vas a poder preguntar por ese mensaje. ::: ### Estados del Mensaje | Estado | Terminal | Descripción | |--------|:--------:|-------------| | `queued` | No | La fila se escribió, el despacho está en curso | | `submitted` | No | **Aceptado por Meta.** Todavía sin acuse | | `sent` | No | WhatsApp acusó que el mensaje salió | | `delivered` | Sí | WhatsApp acusó la entrega al destinatario, o informó que lo leyó. **Es hasta donde llega un saliente** | | `rejected` | Sí | Meta rechazó el envío por una causa de negocio | | `failed` | Sí, salvo lectura | La llamada falló sin respuesta de negocio de Meta, o WhatsApp acusó que no pudo entregarlo. Si después WhatsApp informa que se leyó, avanza a `delivered` | `failed` y `rejected` no son lo mismo, y la diferencia importa para atender un reclamo: `rejected` es «Meta dijo que no», `failed` es «no sabemos si Meta lo vio». :::caution[El estado avanza solo, pero desordenado — y `read` no es un estado] WhatsApp entrega los acuses **desordenados y repetidos**. El estado **nunca retrocede**: un `sent` que llega después de un `delivered` se registra y no lo pisa. El **instante de lectura** se guarda en un campo aparte (`leidoEn`): no existe un estado `read`. Pero **una lectura implica la entrega**. Si llega un `read` sobre un mensaje sin acuse de entrega, el mensaje avanza a `delivered` en ese momento, y eso vale también para un `failed`: si WhatsApp informa después que el mensaje se leyó, termina en `delivered`. Un `leidoEn` con valor significa siempre «entregado». `undelivered` y `expired` existen en el catálogo y **nadie los escribe**: WhatsApp no los emite por este campo. No los esperes. ::: --- ## Recibir los mensajes que te escriben Cada vez que alguien le escribe al número del canal, la plataforma **registra el mensaje**, **te lo cobra** y **te avisa** al webhook que configures para ese canal. El destino se configura desde el panel, en el canal: **Canales → tu número → Webhook de recepción**. No hay endpoint de API para configurarlo: es una superficie humana a propósito. ### Lo que recibes ```http POST https://tu-sistema.cl/hooks/whatsapp Content-Type: application/json X-Webhook-Signature: t=1789008127,v1=3f2a… ``` ```json { "event": "whatsapp.mensaje.entrante", "canalId": "080a7de6-02e2-4682-8c0a-e27ba4a2d623", "contenidoIncluido": true, "mensaje": { "wamid": "wamid.HBgLNTY5ODQzMDYyNDQVAgARGBI…", "de": "56984306244", "tipo": "text", "cuerpo": "hola, necesito ayuda", "recibidoEn": "2026-09-10T02:42:07.000Z", "respondeA": null, "numeroReceptor": "15550823902" } } ``` | Campo | Siempre viene | Qué es | |---|:---:|---| | `event` | Sí | Siempre `whatsapp.mensaje.entrante` | | `canalId` | Sí | El canal que recibió el mensaje. Es el mismo de la ruta de despacho | | `contenidoIncluido` | Sí | `false` cuando el mensaje **no es texto**: ahí `cuerpo` viene `null` | | `mensaje.wamid` | Sí | Identificador del mensaje en WhatsApp. **Es único**: úsalo para deduplicar | | `mensaje.de` | Sí | Quién escribió, en formato internacional **sin `+`** | | `mensaje.tipo` | Sí | El tipo **tal como lo nombra WhatsApp**: `text`, `audio`, `image`, `location`… | | `mensaje.cuerpo` | **No** | El texto. **Sólo en los mensajes de texto**; `null` en todos los demás | | `mensaje.recibidoEn` | Sí | Cuándo lo recibió WhatsApp, en ISO-8601 | | `mensaje.respondeA` | No | El `wamid` del mensaje al que responde, si responde a alguno | | `mensaje.numeroReceptor` | No | Tu número, tal como WhatsApp lo declara | :::caution[🔴 Un mensaje que NO es texto llega SIN su contenido, y se cobra igual] De los tipos que WhatsApp maneja, **sólo el texto trae el contenido en el aviso**. **Qué recibes** de un audio, una imagen, un documento, una ubicación o un contacto: `tipo`, `de`, `recibidoEn`, `wamid`, y `contenidoIncluido: false`. **Qué NO recibes**: el archivo, la transcripción, las coordenadas, la tarjeta de contacto. Todo eso queda guardado en WhatsApp y hay que descargarlo con el testigo del canal, **que no está expuesto todavía**. **El mensaje se cobra igual**, porque lo recibiste y quedó registrado. En el panel el desenlace de la entrega lo dice con palabras: «Entregado al webhook, sin el contenido». La descarga del archivo es **el paso siguiente** de este canal, no una limitación permanente. ::: ### Cómo verificas que el aviso es nuestro La cabecera `X-Webhook-Signature` viene en el formato `t=,v1=`, donde `hmac` es un HMAC-SHA256 de `.` con el secreto del canal. ```js function firmaValida(cabecera, cuerpoCrudo, secreto) { const [t, v1] = cabecera.split(','); const unix = t.replace('t=', ''); const esperado = createHmac('sha256', secreto) .update(`${unix}.${cuerpoCrudo}`) .digest('hex'); return v1 === `v1=${esperado}`; } ``` Es el mismo formato que el resto de los webhooks de la plataforma. **Verifica siempre**: sin la firma, cualquiera que conozca tu URL puede mandarte un aviso. :::note[El secreto se muestra UNA vez] Lo genera la plataforma cuando configuras el webhook, y la pantalla lo muestra **una sola vez**. Después queda guardado cifrado y **no se puede volver a leer**: el panel dice que hay uno y nada más. Si lo pierdes, generas uno nuevo — y el anterior deja de servir en ese momento. ::: ### Reintentos Si tu endpoint no responde `2xx`, se reintenta **12 veces** con espera creciente de 1 s a 1 min: una ventana total de unos **6 minutos**. Agotados los intentos, el mensaje queda marcado como perdido en el registro del canal —y ahí lo puedes ver— pero **no se vuelve a intentar**. **El mensaje se cobra igual**, incluso si tu endpoint nunca lo aceptó: lo recibiste. ### Si el canal no tiene webhook configurado El mensaje **se registra y se cobra**, y no se entrega a ninguna parte. Lo ves en el panel, en el registro del canal, con el desenlace «Sin webhook configurado». **No** cae al webhook general del tenant: son cosas distintas y mezclarlas mandaría los mensajes de un número al sistema equivocado. --- ## Recibir el estado de tus despachos Además de los mensajes que te escriben, el webhook del canal puede recibir **qué pasó con cada mensaje que despachaste**: enviado, entregado o leído, o fallido con su causa. Es opcional y viene **apagado**. ### Cómo lo enciendes En el panel, en el canal: **Canales → tu número → Webhook de recepción**, tarjeta **«Estado de tus despachos»**, interruptor **«Notificar el estado de tus despachos»**. La tarjeta aparece cuando el canal ya tiene un webhook configurado: los eventos llegan **a esa misma URL**, firmados con **el mismo secreto**. Son unos **tres avisos por mensaje**. Encenderlo o apagarlo **no cambia** los mensajes que te escriben ni los ecos: siguen llegando igual. ### Lo que recibes del estado ```http POST https://tu-sistema.cl/hooks/whatsapp Content-Type: application/json X-Webhook-Signature: t=1789400356,v1=9c1d… ``` ```json { "event": "whatsapp.mensaje.estado", "canalId": "601b51f8-4422-4394-85fb-9cfc3eb73f92", "aviso": { "avisoId": "3b9f0c6e-6a51-4c0e-9d1a-2f4b7c8e5a10", "mensajeId": "cmu1eqvzj000ljw66po1sduu3", "wamid": "wamid.HBgLNTY5MTIzNDU2NzgVAgARGBI…", "referenciaExterna": "contrato-4821", "procedencia": "api", "plantilla": { "nombre": "documento_firmado_v1", "idioma": "es" }, "contraparteNormalizada": "56912345678", "destinatarioProveedor": "56912345678", "estado": "delivered", "estadoCrudo": "read", "estadoAnterior": "sent", "secuencia": 40, "ocurridoEn": "2026-09-14T15:39:15.000Z", "recibidoEn": "2026-09-14T15:39:16.204Z", "entregadoEn": "2026-09-14T15:39:15.000Z", "leidoEn": "2026-09-14T15:39:15.000Z", "causa": null, "proveedor": { "id": "wamid.HBgLNTY5MTIzNDU2NzgVAgARGBI…", "status": "read", "timestamp": "1789400355", "recipient_id": "56912345678" } } } ``` | Campo | Siempre viene | Qué es | |---|:---:|---| | `event` | Sí | Siempre `whatsapp.mensaje.estado` | | `canalId` | Sí | El canal que despachó el mensaje | | `aviso.avisoId` | Sí | Identificador único del aviso. **Úsalo para deduplicar** | | `aviso.mensajeId` | Sí | El `id` que devolvió el despacho: la clave de `GET …/mensajes/{id}` | | `aviso.wamid` | Sí | Identificador del mensaje en WhatsApp | | `aviso.referenciaExterna` | No | La que mandaste al despachar, tal cual. `null` si no la mandaste | | `aviso.procedencia` | Sí | Siempre `api`: sólo se notifican los mensajes que despachó la plataforma | | `aviso.plantilla` | Sí | Nombre e idioma de la plantilla enviada | | `aviso.contraparteNormalizada` | Sí | El destinatario, en formato internacional sin `+` | | `aviso.destinatarioProveedor` | No | El destinatario tal como lo informa WhatsApp. Puede diferir del anterior por la normalización del país | | `aviso.estado` | Sí | El estado del mensaje **justo después de este aviso**: `sent`, `delivered` o `failed`. No cambia si el evento se reintenta | | `aviso.estadoCrudo` | Sí | Lo que WhatsApp mandó: `sent`, `delivered`, `read` o `failed` | | `aviso.estadoAnterior` | No | El estado del mensaje antes de este aviso | | `aviso.secuencia` | Sí | El orden del estado: a mayor `secuencia`, más avanzado | | `aviso.ocurridoEn` | Sí | Cuándo ocurrió, según WhatsApp, en ISO-8601 | | `aviso.recibidoEn` | Sí | Cuándo lo recibió la plataforma | | `aviso.entregadoEn` | No | `ocurridoEn` si **este** aviso produjo la entrega, acusada o implícita por una lectura. Si no, `null` | | `aviso.leidoEn` | No | `ocurridoEn` si **este** aviso registró la lectura. Si no, `null` | | `aviso.causa` | No | Sólo con `estado: failed`. Ver abajo | | `aviso.proveedor` | Sí | El aviso de WhatsApp tal como llegó, sin sus datos de tarificación | Con `estado: failed`, `causa` trae la razón que dio WhatsApp: ```json { "codigo": "131026", "titulo": "Message undeliverable", "detalle": "Message Undeliverable.", "elegibleParaRespaldo": true, "requiereIntervencion": false, "desconocido": false } ``` `elegibleParaRespaldo` dice si conviene enviar el mismo contenido por otro medio —un SMS, un correo—; `requiereIntervencion`, si hay que corregir algo antes de reintentar; y `desconocido`, si el código no está clasificado. ### Las reglas del evento 1. **Al menos una vez.** El mismo aviso puede llegar más de una vez: deduplica por `avisoId`. 2. **Sin orden garantizado.** WhatsApp desordena los avisos, y además un reintento puede llegar después de un aviso posterior. Aplica el estado de **mayor `secuencia`** y descarta el resto. 3. **`read` puede llegar sin `delivered`**, pero el evento ya viene con `estado: delivered`. Un `leidoEn` con valor implica entregado. 4. **Tu temporizador manda.** El evento acelera tu decisión, no la reemplaza. Un evento perdido (12 intentos en unos 6 minutos) nunca debe dejar un mensaje sin respaldo. Al vencer tu plazo, [`GET …/mensajes/{id}`](#consultar-el-estado-de-un-despacho) es la fuente de verdad. 5. **Responde 2xx a todo evento**, también a los que ignoras. Con otra respuesta, la plataforma reintenta, deja el aviso perdido y alerta a operación. 6. **Los mensajes que te escriben y los ecos no cambian** con esta opción. 7. **Sin eventos** en sandbox ni para los rechazos del despacho: esos ya están en la respuesta del `POST`. 8. **Un destino por canal.** Si tienes varios entornos, usa un canal por entorno. 9. **Cada intento se firma de nuevo.** En `X-Webhook-Signature: t=,v1=`, `t` es el instante de **ese** intento, y el HMAC-SHA256 se calcula sobre `.` con el secreto del canal. Una ventana de tolerancia de ±300 s sobre `t` alcanza también para el intento 12. Verifica sobre los **bytes crudos** del cuerpo, nunca sobre un JSON vuelto a serializar: ver [Cómo verificas que el aviso es nuestro](#cómo-verificas-que-el-aviso-es-nuestro). 10. **Toda respuesta que no sea 2xx se reintenta**, incluidas `401` y `503`, hasta el intento 12. Ningún status descarta el aviso en el primer intento. Un `401` persistente —por ejemplo, un secreto mal configurado en tu receptor— agota los 12 intentos y deja el aviso perdido. 11. **Responde rápido.** Cada intento espera hasta 30 segundos. Un timeout cuenta como fallo y se reintenta. Guarda el aviso, responde, y procésalo después. 12. **Un reintento de un aviso que ya guardaste debe recibir 2xx**: tu deduplicación por `avisoId` lo reconoce y responde éxito. Si respondes error, el aviso termina perdido aunque lo tengas. --- ## Número en coexistencia con WhatsApp Business App Si conectaste el número **en coexistencia** —sigues atendiendo desde WhatsApp Business App en tu celular y además usas la plataforma—, hay dos cosas más que llegan a tu sistema y al registro del canal. ### Los mensajes que envías desde el celular Cada mensaje que el negocio envía **desde WhatsApp Business App** (o un dispositivo vinculado) se registra en el canal, **se cobra a un precio menor que un despacho** y se avisa al **mismo webhook de recepción**, firmado igual y con los mismos reintentos, con el evento `whatsapp.mensaje.eco`: ```json { "event": "whatsapp.mensaje.eco", "canalId": "080a7de6-02e2-4682-8c0a-e27ba4a2d623", "contenidoIncluido": true, "mensaje": { "wamid": "wamid.HBgLNTY5ODQzMDYyNDQVAgARGBI…", "para": "56984306244", "tipo": "text", "cuerpo": "hola, te escribo desde el celular", "enviadoEn": "2026-09-13T15:55:37.000Z", "numeroEmisor": "56900000000" } } ``` | Campo | Siempre viene | Qué es | |---|:---:|---| | `event` | Sí | Siempre `whatsapp.mensaje.eco` | | `canalId` | Sí | El canal del número | | `contenidoIncluido` | Sí | `false` cuando el mensaje **no es texto**: ahí `cuerpo` viene `null` | | `mensaje.wamid` | Sí | Identificador del mensaje en WhatsApp. **Es único**: úsalo para deduplicar | | `mensaje.para` | Sí | A quién se lo enviaste, en formato internacional **sin `+`** | | `mensaje.tipo` | Sí | El tipo tal como lo nombra WhatsApp | | `mensaje.cuerpo` | **No** | El texto, **sólo** en los mensajes de texto | | `mensaje.enviadoEn` | Sí | Cuándo se envió desde la app, en ISO-8601 | | `mensaje.numeroEmisor` | No | Tu número, tal como WhatsApp lo declara | El `event` es lo que distingue un eco de un entrante: **ramifica por `event`**, no por la forma del cuerpo. Los mensajes que despacha la plataforma **no** generan eco. ### El historial sincronizado Al conectar en coexistencia, la plataforma pide a WhatsApp tus contactos y hasta **180 días de historial** de la app. El historial queda en el registro del canal con procedencia «Historial sincronizado», **no se cobra** y **no se avisa a tu webhook**. ### De dónde llegó cada mensaje: `procedencia` El detalle de un mensaje trae el campo `procedencia`: | `procedencia` | Qué es | Se cobra | Se avisa a tu webhook | |---|---|:---:|:---:| | `api` | Lo despachó la plataforma (tu API key o el panel) | Sí | Sólo su estado, `whatsapp.mensaje.estado`, si lo [encendiste](#recibir-el-estado-de-tus-despachos) | | `webhook` | Alguien le escribió al número | Sí | Sí, `whatsapp.mensaje.entrante` | | `app_celular` | Lo enviaste desde WhatsApp Business App | Sí, precio de eco | Sí, `whatsapp.mensaje.eco` | | `historial` | Llegó con la sincronización del historial | No | No | ### Si desvinculas el número desde la app Si desconectas la plataforma desde WhatsApp Business App, WhatsApp nos avisa: el canal queda **desvinculado**, los administradores del tenant reciben un correo y una notificación, y el despacho responde `409 whatsapp_canal_desvinculado` hasta que alguien vuelva a conectar el número desde el panel. --- ## Códigos de Error | Código | Descripción | Qué significa para ti | |:------:|-------------|-----------------------| | 400 | Payload inválido | Falta un campo, o mandaste `variables` y `componentes` juntos | | 401 | No autorizado | API key inválida, expirada o revocada | | 403 | Prohibido | El rol de la key no alcanza este endpoint, o el servicio `WHATSAPP_MENSAJERIA` no está habilitado | | 404 | No encontrado | El canal no existe **o no es de tu tenant** — indistinguibles a propósito | | 409 | `whatsapp_canal_desvinculado` | El número estaba en coexistencia y se desvinculó desde WhatsApp Business App. **No se escribió ningún mensaje ni se llamó a WhatsApp.** El administrador tiene que volver a conectarlo desde el panel | | 422 | Meta rechazó el envío | Trae **la causa real de Meta**. Ver abajo | | 429 | Límite de mensajería alcanzado | Es el límite del número de tu tenant, no un fallo de la plataforma | ### 422 — la causa real de Meta, sin traducir El `422` es el error que más vas a ver, y el cuerpo trae **el texto exacto que devolvió Meta**, no un mensaje genérico. El campo `error` te da un código con el que ramificar: ```json { "statusCode": 422, "error": "whatsapp_envio_rechazado", "message": "(#132001) Template name does not exist in the translation", "mensajeId": "cmtusmgup0003see2h7njed6t" } ``` El `mensajeId` es la fila que quedó escrita con esta misma causa: pásaselo a `GET /{tenantSlug}/canales/whatsapp/{channelId}/mensajes/{id}` y te devuelve `errorCodigo` y `errorMensaje` sin que tengas que abrir el panel. | `error` | Qué pasó | Qué hacer | |---|---|---| | `whatsapp_envio_rechazado` | Problema de la plantilla —no existe, no está aprobada, los parámetros no calzan— o la ventana de conversación de 24 h está cerrada | Lee `message`: viene de Meta y dice cuál de los casos es. No reintentes igual | | `whatsapp_token_invalido` | El token del número expiró o fue revocado | **No lo puedes arreglar desde tu sistema.** El administrador del tenant tiene que reconectar el número en el panel | | `whatsapp_channel_sin_credenciales` | El canal no está conectado | Ídem: se reconecta desde el panel | ### 429 — el límite es del número, no de la plataforma ```json { "statusCode": 429, "error": "whatsapp_limite_alcanzado", "message": "...", "mensajeId": "cmtusmgup0003see2h7njed6t" } ``` Meta le asigna a cada número un **límite de mensajería** que depende de su calidad y su historial, y ese límite es del tenant. La plataforma **no** tiene un rate limit propio sobre este endpoint: el `429` que recibes viene de Meta. Es reintentable con backoff, pero no ese mismo segundo: si agotaste el límite del día, reintentar en un bucle sólo agrega llamadas rechazadas. ### 5xx Un error de Meta que no cae en ninguna de las categorías de arriba llega como `500`. Es reintentable con backoff exponencial, igual que cualquier `5xx`. Ver [Reglas transversales](/primeros-pasos/reglas-transversales/#forma-de-los-errores). --- ## Consultar el Estado de un Despacho ``` GET /{tenantSlug}/canales/whatsapp/{channelId}/mensajes/{id} ``` El `{id}` es el de la **fila del registro** —el que devolvió el despacho—, no el `wamid`. ```bash curl "https://api.redcumbre.cl/{tenantSlug}/canales/whatsapp/{channelId}/mensajes/cmtugt4ln0003se8nqas72beu" \ -H "Authorization: Bearer {api_key}" ``` ```json { "success": true, "data": { "id": "cmtugt4ln0003se8nqas72beu", "createdAt": "2026-09-09T19:01:56.400Z", "updatedAt": "2026-09-09T19:01:56.931Z", "direccion": "saliente", "procedencia": "api", "canalId": "080a7de6-02e2-4682-8c0a-e27ba4a2d623", "contraparte": "+56912345678", "contraparteNormalizada": "56912345678", "plantillaNombre": "documento_firmado_v1", "plantillaIdioma": "es", "plantillaComponentes": { "cuerpo": { "variables": [{ "posicion": 1, "valor": "María" }, { "posicion": 2, "valor": "Contrato 4821" }] }, "boton": { "indice": "0", "variables": [{ "posicion": 1, "valor": "f/abc123XYZ" }] } }, "estado": "delivered", "wamid": "wamid.HBgLNTY5…", "errorCodigo": null, "errorMensaje": null, "sandbox": false, "cobrado": true, "noCobradoMotivo": null, "aceptadoEn": "2026-09-09T19:01:56.931Z", "entregadoEn": "2026-09-09T19:01:59.000Z", "leidoEn": "2026-09-09T19:03:12.000Z", "tipoMensaje": null, "cuerpo": null, "entregaWebhookEstado": "no_aplica", "entregaWebhookIntentos": 0, "entregaWebhookEn": null } } ``` Lo que trae, y nada más: la plantilla, **las variables que enviaste**, el estado, el `wamid`, la causa exacta del error si lo hubo, las marcas de tiempo —incluidas **la de entrega y la de lectura**— y la marca de sandbox. Es lo que necesitas para responder un reclamo. Los cinco campos del final describen la mitad **entrante** del registro, y en un saliente vienen como en el ejemplo: | Campo | En un saliente | En un entrante | |---|---|---| | `entregadoEn` | El acuse de entrega de WhatsApp, o `null` | `null` | | `leidoEn` | El acuse de lectura, o `null`. **No** cambia el `estado` | `null` | | `tipoMensaje` | `null` — un saliente es una plantilla | El tipo tal como lo nombra WhatsApp | | `cuerpo` | `null` | El texto, **sólo** si el mensaje es de texto | | `entregaWebhookEstado` | `no_aplica` — no hay aviso que entregar | `entregado`, `entregado_sin_contenido`, `perdido` o `sin_webhook` | | `entregaWebhookIntentos` | `0` | Cuántas veces se intentó avisarle a tu sistema | | `entregaWebhookEn` | `null` | Cuándo quedó cerrado el desenlace | Un mensaje de otro tenant, de otro canal, o que no existe, responde **`404`** — los tres indistinguibles, nunca `403`. :::note[Este endpoint devuelve UN mensaje, no la lista] El listado completo del canal es una pantalla del panel, no superficie de API key. Con esta llave consultas los despachos que hiciste, uno por uno, con el `id` que guardaste. ::: --- ## Modo Sandbox Si la API key se creó en **modo sandbox**, el despacho: - **no llama a Meta** — no sale ningún mensaje; - **no genera cobro**; - **sí escribe la fila** del registro, marcada como sandbox. La respuesta declara el modo, para que tu sistema pueda distinguir lo simulado de lo real sin cambiar de endpoint: ```json { "success": true, "data": { "id": "cmtugtxaq0005se8n63n27jx3", "wamid": "wamid.SANDBOX-cmtugtxaq0005se8n63n27jx3", "estado": "submitted", "sandbox": true, "cobrado": false, "noCobradoMotivo": "sandbox" } } ``` El `wamid` de sandbox lleva el prefijo `wamid.SANDBOX-`. Ver [Entornos y Sandbox](/primeros-pasos/entornos/). --- ## Integración JavaScript ```javascript const BASE = 'https://api.redcumbre.cl'; async function despacharWhatsapp({ tenantSlug, channelId, apiKey, destinatario, template }) { const response = await fetch( `${BASE}/${tenantSlug}/canales/whatsapp/${channelId}/enviar`, { method: 'POST', headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ destinatario, templateName: template.nombre, languageCode: template.idioma, componentes: template.componentes, }), } ); const body = await response.json(); if (!response.ok) { // `message` puede ser string o array de strings (validación). // En 422 y 429, `error` trae el código y `message` la causa real de Meta. throw new Error(`${body.error ?? response.status}: ${body.message}`); } const { id, wamid, estado } = body.data; // Guarda `id`: es con lo que consultas después. El `estado` de la respuesta es "submitted" y // avanza solo con los acuses de WhatsApp — consultalo, no lo supongas. return { id, wamid, estado }; } ``` --- ## Preguntas frecuentes **¿Puedo mandar texto libre en vez de una plantilla?** No. Este endpoint despacha **plantillas aprobadas**, que es lo que Meta permite para iniciar una conversación con alguien que no te escribió primero. **¿Cómo sé si el mensaje llegó?** Por el estado del mensaje: cuando WhatsApp acusa la entrega, pasa a `delivered`. Y si el destinatario lo leyó, el campo `leidoEn` se llena — sin cambiar el estado. Los acuses llegan en segundos, pero **desordenados**: consultá el estado, no supongas el orden. **¿Puedo recibir las respuestas de mis destinatarios?** Sí. Configurá el **webhook de recepción del canal** desde el panel: cada mensaje que te escriban se te entrega firmado, con reintentos. Ojo con una cosa: **un mensaje que no es texto llega sin su contenido** — recibís el tipo, quién escribió y cuándo, y el archivo queda en WhatsApp. Se cobra igual. Está explicado arriba y en la sección del webhook. **¿La API key sirve para varios canales?** Sí. La llave no está atada a ningún canal: el canal va en la ruta, y en cada llamada se comprueba que sea de tu tenant. **¿Puedo crear plantillas por API con esta llave?** No. El rol `WHATSAPP_MENSAJERIA_DESPACHO` no alcanza la administración de plantillas. Se crean desde el panel. --- ## Siguiente paso - [Autenticación](/primeros-pasos/autenticacion/) — API keys y modo sandbox - [Reglas transversales](/primeros-pasos/reglas-transversales/) — forma de los errores, reintentos, paginación - [Envío de SMS](/guias/sms/) — el otro canal de mensajería del tenant --- # Developer Program — integra la API Fuente: https://docs.redcumbre.cl/
--- # Autenticación: API Keys y OAuth2 Fuente: https://docs.redcumbre.cl/primeros-pasos/autenticacion/ La API soporta dos métodos de autenticación: ## 1. API Keys (Recomendado para integraciones) Las API Keys son el método recomendado para integrar la API en tu aplicación. ### Obtener una API Key 1. Accede al panel de administración de tu tenant 2. Ve a **Configuración > API Keys** 3. Crea una nueva API Key con los permisos necesarios ### Usar la API Key Incluye el header `Authorization` con tu API Key como Bearer token: ```bash curl -X GET "https://api.redcumbre.cl/tu-tenant/bhe" \ -H "Authorization: Bearer tu-api-key-aqui" ``` ### Modo Sandbox Para pruebas, puedes crear API Keys en **modo sandbox**: - No se emiten documentos reales al SII - No se genera billing - RUTs de prueba disponibles: | RUT | Comportamiento | |-----|----------------| | `78012039-8` | Respuesta exitosa | | `77425402-1` | Múltiples resultados | | `99999999-9` | Error simulado | --- ## 2. OAuth2 (Para aplicaciones web) Si estás construyendo una aplicación web que actúa en nombre de usuarios, usa OAuth2. ### Flujo de Autenticación 1. Redirige al usuario a la página de login de Zitadel 2. El usuario autoriza tu aplicación 3. Recibes un access token 4. Usa el token en el header `Authorization: Bearer {token}` ```bash curl -X GET "https://api.redcumbre.cl/tu-tenant/bhe" \ -H "Authorization: Bearer eyJhbGciOiJSUzI1NiIs..." ``` --- ## Referencia de Endpoints La autenticación no tiene endpoints propios en la API pública: la credencial viaja en el header `Authorization` de **cada** llamada. Para comprobar que tu API Key funciona y ver con qué tenant y rol quedó asociada, usa `GET /{tenantSlug}/whoami` (o `GET /global/whoami` si aún no conoces tu slug). 👉 [Ver los endpoints de verificación de credencial en Swagger](https://api.redcumbre.cl/api-docs#/Sistema) --- # Entornos y modo sandbox Fuente: https://docs.redcumbre.cl/primeros-pasos/entornos/ Antes de escribir la primera llamada conviene despejar esto, porque no funciona como en la mayoría de las APIs. ## Una sola URL base ``` https://api.redcumbre.cl ``` No hay `sandbox.redcumbre.cl`, ni un path `/sandbox`, ni un subdominio de staging para integradores. Todas las llamadas —de prueba y de producción— van al mismo host. :::caution[Si tu asistente de IA inventó otra URL] Es el error más común al integrar la API: un agente asume la convención habitual de `sandbox.` y arma las peticiones contra un host que no existe. La especificación OpenAPI de producción declara **un único servidor**, y es este. ::: ## El sandbox es una propiedad de tu API Key Lo que decide si una llamada es de prueba o real **no es la URL: es la credencial**. Cada API Key se crea marcada como sandbox o no, y eso no se puede cambiar después. Con una key de sandbox: - **No se llama a servicios externos reales.** Nada llega al SII. Las respuestas se resuelven con datos de prueba. - **No se genera facturación.** Ningún evento de billing se emite. - **La respuesta viene marcada** con `sandbox: true`, así tu código puede distinguirlo sin depender de qué key cargó el entorno. - **Los documentos emitidos llevan folio con prefijo `SANDBOX-`.** Es la única marca persistente: si reprocesas un webhook viejo, el folio te dice de dónde salió. - **No sale ningún correo a terceros.** Ni la boleta recién emitida, ni el comprobante de una anulación, ni un reenvío manual del PDF desde el panel. La supresión se decide por el folio del documento, no por la credencial que pide el envío: una boleta emitida en sandbox nunca despacha correo, aunque el reenvío lo pida después un usuario del panel con su sesión normal. Los endpoints de envío responden con su contrato de siempre y `sandbox: true`. Los **webhooks sí se emiten** en sandbox: son la mitad de lo que necesitas ejercitar. Lo que no ocurre es la comunicación con una persona real. ### El flag no se puede editar Para pasar a producción **se crea una API Key nueva**. No hay un endpoint ni una opción de panel que convierta tu key de pruebas en una key real. No es una omisión, es un control deliberado: el secreto de una key de pruebas ya está repartido por los entornos del integrador, y muy probablemente commiteado en algún repositorio. Si ese flag fuera editable, esa credencial ya expuesta pasaría a emitir documentos reales ante el SII y a generar cobros, sin rotar el secreto y sin que nadie se entere. Las dos keys conviven en el mismo tenant. **No son dos cuentas ni dos contratos**: es una credencial más. ## Cómo saber en qué modo estás ```bash curl -s https://api.redcumbre.cl/{tenantSlug}/whoami \ -H "Authorization: Bearer $REDCUMBRE_API_KEY" ``` ```json { "success": true, "data": { "tenant": { "slug": "tu-tenant", "nombre": "Tu Empresa SpA" }, "credencial": { "tipo": "api_key", "nombre": "Integración ERP", "roles": ["FULL-API"], "sandbox": true }, "timestamp": "2026-08-18T14:32:10.512Z" } } ``` Es la primera llamada que conviene hacer al integrar: confirma de una vez que la key es válida, que el `tenantSlug` es el correcto, qué roles tienes y si estás en sandbox. ## `api.dev.redcumbre.cl` no es tu sandbox Vas a encontrar ese dominio si buscas. Es el entorno interno de desarrollo de REDCUMBRE, no está disponible para integradores y su contenido cambia sin aviso. La especificación OpenAPI de producción no lo declara como servidor, justamente para que nadie lo tome por un sandbox público. Para probar, la vía es una API Key de sandbox contra la URL de producción. ## Qué necesitas pedirle a REDCUMBRE Estas cosas no se resuelven por API y ningún asistente las va a poder hacer por vos: | Para | Necesitás | |---|---| | Llamar cualquier endpoint | Una **API Key** (de sandbox o real) emitida para tu tenant | | Usar un módulo concreto | Que el **servicio esté habilitado** en tu tenant | | Emitir documentos tributarios | Un **emisor DTE configurado**, con certificado digital vigente y folios CAF | Escribe a tu contacto en REDCUMBRE para cualquiera de las tres. ## Siguiente paso - [Autenticación](/primeros-pasos/autenticacion/) — cómo firmar tus llamadas - [Reglas transversales](/primeros-pasos/reglas-transversales/) — paginación, idempotencia y errores - [Integrar con tu agente de IA](/primeros-pasos/integrar-con-ia/) — si vas a delegarle la integración --- # Integrar con tu agente de IA Fuente: https://docs.redcumbre.cl/primeros-pasos/integrar-con-ia/ Si programas con Claude, Cursor, ChatGPT o cualquier asistente con acceso a la web, no necesitas leer esta documentación entera ni pasarle archivos adjuntos. Hay una URL que le da todo el contexto de una vez. ## El comando ``` redcumbre:init — Lee https://docs.redcumbre.cl/llms.txt y ejecuta el protocolo de arranque. ``` Cópialo y pégalo en una ventana nueva de tu asistente. No hace falta que agregues nada más. ## Qué recibe tu agente Ese archivo es un mapa del ecosistema escrito para que lo lea una máquina: la URL base, cómo autenticarse, qué dominios existen con su prefijo de rutas, y las reglas que aplican a todos los endpoints (paginación, idempotencia, forma de los errores). No lo contiene todo. Lo que hace es **decirle dónde está el resto**. | Cuando tu agente necesita… | Va solo a… | |---|---| | Entender el panorama y elegir por dónde empezar | `llms.txt` — el archivo de arriba | | El detalle narrativo de un dominio, con ejemplos | La guía correspondiente, enlazada desde ahí | | Todas las guías juntas, en texto plano | [`llms-full.txt`](https://docs.redcumbre.cl/llms-full.txt) | | Los campos exactos, tipos y validaciones | [La especificación OpenAPI](https://api.redcumbre.cl/api-docs-json) | | Ubicar una ruta rápido, sin bajar el spec | [El índice de rutas](https://api.redcumbre.cl/api-docs/index.txt) | Los dos últimos los genera el API en tiempo real, así que nunca están desactualizados respecto de lo que la plataforma realmente expone. ## Qué va a pasar cuando lo pegues El archivo trae un protocolo de arranque, así que tu agente no se queda esperando instrucciones. En este orden: 0. **Comprueba que su copia esté vigente** y la actualiza si hace falta, para no integrar contra una definición que ya cambió. 1. **Saluda de inmediato**, sin credenciales ni configuración. Una llamada real a producción —`GET /hola`— que responde un apodo distinto en cada invocación, tipo `chungungo-turquesa-4821`. Tu agente te lo dice, y ahí sabes que la integración ya está hablando con la plataforma. 2. **Te pregunta qué quieres integrar** y te ofrece las opciones: facturas, boletas de honorarios, boletas de terceros, consulta de RUT, webhooks o carga masiva. Ahí también mira qué credencial tienes: desde agosto de 2026 no queda ningún endpoint de datos abierto sin credencial —el catálogo global de UF, IVA, dólar, regiones y comunas vive en `/global/…` y exige una API key válida, aunque no exige ningún rol en particular—. Si tu proyecto ya tiene una configurada, la usa; si no, te lo dice en vez de inventar una. 3. **Va directo a la receta** del dominio que elijas. 4. **Anota el avance** en `.redcumbre/estado.json` de tu proyecto, para que la próxima vez —o desde otra conversación— no vuelva a preguntarte lo que ya sabe. Ese archivo de estado guarda **el nombre** de la variable de entorno donde tienes la API key, nunca su valor, y va al `.gitignore`. ## Cuando algo no calza: `redcumbre:issue` ``` redcumbre:issue — Reporta a Redcumbre el problema que acabamos de encontrar. ``` Es la otra línea copiable. Tu agente la reconoce igual que `redcumbre:init`, y también la ofrece por su cuenta cuando se topa con un error del servidor, con una guía que contradice el comportamiento real o con una ruta documentada que lo rechaza. **Qué se envía:** la clasificación del problema, qué esperaba y qué pasó, la evidencia técnica de las llamadas involucradas y **tu correo**, para que puedan responderte. La empresa y la credencial las toma el servidor de la propia petición, así que no hay nada que tu agente pueda equivocar ahí. **Siempre te pregunta antes de enviar** — cada vez, no una vez por conversación —, y después te entrega un **número de caso**. Guárdalo: es lo que tienes que citar si escribes a soporte. Redcumbre revisa estos reportes para estabilizar la plataforma. El registro queda en un repositorio interno, así que no vas a poder consultarlo directamente; el número de caso es el handle. Si no tienes API key todavía, este canal no aplica: escribe por el [formulario de contacto](https://redcumbre.cl/contacto). Detalle completo, con el árbol de decisión de cuándo corresponde reportar y cuándo el problema es de la propia petición: [Reportar un problema](/guias/reportar-issue/). ## Si tu asistente no tiene terminal **ChatGPT, Claude.ai y Copilot Chat no van a poder ejecutar el paso 1.** No es una falla de tu asistente ni de la plataforma: navegando la web no alcanzan el API. El protocolo completo funciona en asistentes con acceso a una línea de comandos: **Claude Code, Cursor, Codex CLI, Copilot en el IDE**. Si estás en un chat web, pídele el resto del protocolo igual —el menú, las recetas, el código— y ejecuta tú mismo el `curl` del saludo cuando quieras comprobar la conexión. Si ya sabes qué quieres hacer, díselo junto con la URL y se salta el menú. Para eso están los prompts de abajo. ## Prompts listos para copiar Los tres funcionan tal cual. Solo reemplaza lo que va entre `` por tus datos. ### 1. Integrar la emisión desde cero Es el caso más común: ya tienes tu sistema andando y quieres que emita documentos tributarios. ```text Necesito integrar la facturación electrónica de Redcumbre en mi sistema. Toda la información de la API está acá: https://docs.redcumbre.cl/llms.txt Léela primero, y sigue los enlaces que necesites para el detalle. Mi caso: cuando el cliente aprieta el botón <"Confirmar pedido"> en mi aplicación, quiero que se emita una con los datos de esa venta. Mi stack es y los datos del cliente y las líneas del pedido los tengo en . Antes de escribir código, dime qué necesito tener configurado de mi lado y qué datos me van a hacer falta para armar la petición. ``` ### 2. Migrar desde otro proveedor Si ya emites con otro sistema y quieres reemplazar esa capa. ```text Estoy migrando mi emisión de documentos tributarios electrónicos desde hacia Redcumbre. La documentación está acá: https://docs.redcumbre.cl/llms.txt Este es el código que hoy hace la emisión: Quiero que me digas, en este orden: 1. Qué campos de mi payload actual tienen equivalente directo en la API 2. Cuáles no lo tienen, y qué hago con ellos 3. Qué campos exige la API que hoy no estoy enviando 4. Recién después, el código de reemplazo No asumas equivalencias por el nombre del campo: confírmalas contra la especificación OpenAPI. ``` ### 3. Entender antes de escribir código Cuando todavía estás evaluando, o necesitas explicarle el flujo a alguien. ```text Estoy evaluando integrar Redcumbre para emitir . La documentación está acá: https://docs.redcumbre.cl/llms.txt Explícame: - El flujo completo de una emisión, de principio a fin - Qué necesito tener configurado antes de la primera llamada - Qué puede salir mal y cómo me entero - Qué diferencia hay entre los modos de emisión y cuál me conviene No escribas código todavía. ``` ## Si tu agente se traba Suele ser porque se quedó con el mapa y no siguió los enlaces. Estas tres frases lo destraban: > Lee la especificación OpenAPI completa en https://api.redcumbre.cl/api-docs-json antes de > asumir el nombre o el tipo de un campo. > Consulta la guía específica de este dominio, enlazada en la tabla de dominios del llms.txt. > Si necesitas todas las guías juntas, están en https://docs.redcumbre.cl/llms-full.txt Y si lleva rato en una conversación larga y sospechas que quedó con una versión vieja de la documentación: > Vuelve a ejecutar el paso 0 del protocolo: pide https://docs.redcumbre.cl/llms-version.txt y > compara el hash con el de tu copia. Y un aviso que vale la pena adelantarle, porque es el error que más vemos: > El sandbox no es un servidor distinto. Hay una sola URL base y el modo sandbox es una > propiedad de la API key. No inventes un host de sandbox. ## Lo que tu agente no va a poder hacer Estas tres cosas no se resuelven por API, y ningún asistente las va a conseguir por su cuenta. Si te dice que existe un registro automático, está alucinando: | Para | Necesitas | |---|---| | Llamar cualquier endpoint | Una **API Key** emitida para tu tenant | | Usar un módulo concreto | Que el **servicio esté habilitado** en tu tenant | | Emitir documentos tributarios | Un **emisor DTE configurado**, con certificado digital vigente y folios CAF | Escríbele a tu contacto en REDCUMBRE para cualquiera de las tres. Con la API Key en la mano, el resto tu agente lo puede hacer solo. ## Recomendaciones **Pídele una key de sandbox para empezar.** Una API Key de sandbox no llega al SII ni genera cobros, y su respuesta viene marcada con `sandbox: true`. El flag no se puede cambiar después: para producción se emite una key nueva. Ver [Entornos y sandbox](/primeros-pasos/entornos/). **Que la primera llamada sea `whoami`.** `GET /{tenantSlug}/whoami` confirma de una vez que la key es válida, que el `tenantSlug` es el correcto, qué roles tienes y si estás en sandbox. Ahorra la mitad de los problemas de configuración. **Pídele que use `idempotencyKey`.** Protege contra emisiones duplicadas por timeout o doble click, y no todos los agentes lo agregan si no se lo pides. **Revisa que guarde el folio que devuelve la API.** Es el error de integración más caro: el sistema guarda su propio correlativo interno y descarta el folio del SII, y después ninguna consulta encuentra nada. ## Siguiente paso - [Entornos y sandbox](/primeros-pasos/entornos/) - [Autenticación](/primeros-pasos/autenticacion/) - [Documentos Tributarios Electrónicos](/guias/dte/) - [Todas las guías](/guias/) --- # Introducción a la API de Redcumbre Fuente: https://docs.redcumbre.cl/primeros-pasos/introduccion/resumen/ **La API de Redcumbre** es una plataforma multi-servicios que permite integrar funcionalidades tributarias y empresariales en tus aplicaciones. :::tip[Guías de Integración] Consulta nuestras [guías disponibles](/guias/) para aprender paso a paso cómo integrar cada servicio: emisores, boletas de honorarios, webhooks, y más. ::: Emisión y gestión de BHE integradas directamente con el SII. Arquitectura diseñada para SaaS con aislamiento completo. --- ## Servicios Principales ### Boletas de Honorarios Electrónicas (BHE) Emite boletas de honorarios directamente desde tu aplicación: - Emisión automática al SII - Descarga de PDFs - Gestión de estados - Anulación de boletas ### Emisores Tributarios Gestiona múltiples emisores (empresas/personas) con: - Certificados digitales seguros - Configuración de actividades económicas - Templates personalizados --- ## Arquitectura Multi-Tenant La API está diseñada como una plataforma **multi-tenant**, lo que significa: - Cada cliente tiene su espacio aislado - Datos completamente separados - Configuraciones independientes - Facturación por uso --- ## API Reference Toda la documentación técnica de endpoints está disponible en **Swagger**: Ver Swagger API Reference --- ## Próximos Pasos 1. [Configurar autenticación](/primeros-pasos/autenticacion/) 2. [Explorar todas las guías disponibles](/guias/) 3. [Crear un emisor](/guias/emisores/) 4. [Emitir boletas de honorarios](/guias/boletas-honorarios/) --- # Reglas transversales de la API Fuente: https://docs.redcumbre.cl/primeros-pasos/reglas-transversales/ Estas reglas aplican a más de un módulo. Si ya leíste una guía de dominio, acá está lo que esa guía da por sabido. ## Paginación Los listados usan `limit` y `offset`. **No hay número de página.** | Parámetro | Tipo | Valor | |---|---|---| | `limit` | number | Registros por página. Default `20`, **máximo `300`** | | `offset` | number | Registros a saltar. Default `0` | El máximo de `300` es transversal: sale de una constante compartida entre backend y frontend (`PAGINATION_MAX_LIMIT`), así que es el mismo tope en todos los listados. Si viste otro número en alguna guía, este es el que vale. Un `limit` por encima del máximo responde **400**, no lo recorta en silencio. La respuesta trae los totales al nivel raíz, no dentro de `data`: ```json { "success": true, "data": [ /* ... */ ], "total": 1247, "limit": 20, "offset": 0 } ``` Para recorrer todo: incrementa `offset` de a `limit` hasta que `offset + data.length >= total`. Cuando cambies un filtro, **vuelve `offset` a `0`** — si no, la primera página del nuevo filtro te sale vacía. ## Idempotencia Si tu sistema puede reintentar una operación —por timeout, error de red o doble click— usa `idempotencyKey` para que el reintento no genere un segundo documento. ```json { "idempotencyKey": "orden-compra-4471", "...": "resto del payload" } ``` | | | |---|---| | Tipo | string, máximo **128 caracteres** | | Vigencia | **Permanente.** No expira | | Comportamiento | Si ya existe un documento emitido con esa combinación, se devuelve **el original**: no se crea uno nuevo ni se consume folio | Aplica a la emisión de documentos tributarios (DTE), boletas de honorarios (BHE) y boletas de terceros (BHET). **El alcance no es el mismo en los tres:** | Documento | Alcance de la clave | |---|---| | DTE | `(emisor, ambiente, tipo de documento, idempotencyKey)` | | BHE | `(emisor, idempotencyKey)` | | BHET | `(emisor, idempotencyKey)` | En DTE el **ambiente** (certificación o producción) forma parte del alcance: la misma clave puede usarse una vez en cada ambiente. Es lo que hace que, cuando pases tu emisor de certificación a producción, las claves que gastaste probando no te devuelvan documentos de prueba en lugar de emitir los reales. Las boletas de honorarios no llevan ambiente porque el SII no ofrece certificación para ellas. :::note[Anular libera la clave] Un documento **anulado** deja de ocupar su `idempotencyKey`: la misma clave vuelve a emitir un documento nuevo. Si ves que una clave a veces devuelve el documento original y a veces emite uno nuevo, revisa si el original fue anulado — no es aleatorio. ::: :::note[Cómo se ve un reintento] Cuando la clave ya tenía documento, la respuesta es `201` con `"idempotent": true` y el documento **original** en `data.dte` — el mismo folio, el mismo receptor y los mismos montos de la primera emisión, no los del payload que acabas de mandar. No se emitió nada nuevo. Si tu integración distingue "emití" de "ya estaba emitido", el campo a mirar es `idempotent`, no el status. ::: :::tip[Elige bien la clave] Usa un identificador estable de **tu** sistema —el número de la orden, el id de la venta—, no un UUID generado en el momento de llamar. Un UUID nuevo por intento no protege de nada: cada reintento es una clave distinta y emite un documento distinto. ::: ## Forma de los errores La API no envuelve los errores en el sobre `success/data`. El formato es el nativo de NestJS: ```json { "statusCode": 400, "message": "El precio unitario debe ser al menos 1 en cada línea. 2 línea(s) tienen precio inválido.", "error": "Bad Request" } ``` Cuando falla la validación del payload, `message` puede ser un **array** de strings, uno por campo: ```json { "statusCode": 400, "message": ["El nombre del item es obligatorio", "La cantidad debe ser mayor a 0"], "error": "Bad Request" } ``` Trata `message` como `string | string[]`. Es la causa más común de que un manejador de errores rompa en el primer 400 de validación. ### El campo `reportar`, sólo en errores del servidor Las respuestas con `statusCode >= 500` llevan un campo extra con el enlace a la guía del canal de reporte: ```json { "statusCode": 500, "message": "Internal server error", "reportar": "https://docs.redcumbre.cl/guias/reportar-issue/" } ``` Es **aditivo**: el resto del cuerpo no cambia, y un cliente que ignora el campo no se entera. **Los rechazos de cliente (`4xx`) NO lo llevan, a propósito.** Un `400` es casi siempre un problema de la petición —un campo obligatorio que falta, un valor fuera del enum—, y ofrecer reportarlo produciría reportes de defectos que no existen. El campo aparece justo donde el error sí es nuestro. Ver [Reportar un problema](/guias/reportar-issue/). ### Reintentar o no | Código | Qué pasó | ¿Reintentar? | |---|---|---| | `400` | El payload no pasó validación, o una regla de negocio lo rechazó | **No.** Reintentar igual da lo mismo. Corregí el payload | | `401` | Falta la credencial, o no es válida | **No.** Revisa el header `Authorization` | | `403` | La credencial es válida pero no alcanza ese endpoint, o el `tenantSlug` no le corresponde | **No.** Es un problema de roles o de servicio no habilitado | | `404` | El recurso por ID no existe | **No** | | `409` | Conflicto de estado: el documento ya fue emitido o anulado | **No.** Consulta el estado actual antes de decidir | | `429` | Límite de tasa | **Sí**, respetando la ventana del límite | | `5xx` | Error del lado de Redcumbre o de un servicio externo | **Sí**, con backoff exponencial | :::caution[Un lookup vacío no es 404] Los endpoints de búsqueda por identificador —del tipo "dame el recurso con este RUT"— responden **`200` con `null`** cuando no encuentran nada. El `404` queda reservado para un recurso pedido por ID que debería existir. Si tratas todo `null` como error, vas a romper flujos normales. ::: ## Límites de tasa **No hay un límite de tasa global** en la API. Los límites son por caso de uso, donde el abuso tiene un costo concreto: | Dónde | Límite | |---|---| | SMS con `routeType: "otp"` | 1 mensaje por número cada 5 minutos. Ver [Envío de SMS](/guias/sms/) | | Verificación PIN-RUT | Límite de intentos fallidos. Ver [PIN-RUT](/guias/pin-rut/) | Que hoy no exista un límite global no es una invitación a paralelizar sin control: las emisiones tributarias dependen del SII, que sí tiene sus propios tiempos. Para volumen alto, la vía es [Procesos Batch](/guias/procesos-batch/), no N llamadas concurrentes. ## Modo asíncrono Varios endpoints de emisión aceptan un modo asíncrono: en vez del documento, devuelven un identificador de intento para consultar el estado después. Es lo que conviene cuando estás emitiendo desde una petición web y no quieres que el usuario espere al SII. El detalle de los modos está en la guía de cada dominio. ## Siguiente paso - [Entornos y sandbox](/primeros-pasos/entornos/) — una sola URL base, y el sandbox como propiedad de la key - [Webhooks](/guias/webhooks/) — recibir eventos en vez de consultar por polling - [Documentos Tributarios Electrónicos](/guias/dte/) — el dominio principal --- # Solicita tu cuenta de integración Fuente: https://docs.redcumbre.cl/solicitar-cuenta/ ---