Saltar al contenido
DocumentaciónPrimeros pasos: emitir una boleta

Primeros pasos: emitir una boleta

Configurar ConfigToken, cargar el CAF, armar un Documento tipo 39 con todos sus campos obligatorios, emitir con emitir, leer el resultado y consultar el estado.

Ver Markdown

Para agentes: esta página en Markdown está en /docs/primeros-pasos.md, toda la documentación en /llms-full.txt y las reglas de integración en /agents.md.

En esta página

Objetivo: generar una boleta 39, enviarla al SII y saber si la aceptó. emitir de @devlas-cl/sii hace todo el camino en una llamada: reserva el folio en AlmacenCaf, valida, calcula montos, arma el XML, lo timbra, lo firma, lo valida contra el XSD, arma y firma el sobre y lo sube (boletas 39 y 41 por la API REST; el resto por DTEUpload). Nivel: Libre. Una boleta 39 emitida con emitir fue aceptada por el SII en el ambiente de certificación, con TSTED, TmstFirma y TmstFirmaEnv en hora de Chile.

Qué necesitas

Qué Cómo se obtiene Dónde se usa
Certificado digital (.pfx y su clave) del titular autorizado Lo tramita el contribuyente. abrirCertificado(pfx, clave) lo lee; la clave nunca se registra. Firmar el DTE, el sobre y la semilla
Folios (un CAF) del tipo 39 Un CAF por tipo, ambiente y RUT emisor. Lo descargas del portal del SII o lo pide autoCaf (nivel AutoCAF). AlmacenCaf
La resolución del ambiente (fchResol, nroResol) De la ficha de la empresa (consultarEmpresa devuelve fechaResolucion y numeroResolucion). En certificación nroResol es 0. Nunca un valor por defecto: mezclar ambientes da RCT (rechazo en carátula). caratula
Los puertos Http, AlmacenCaf, Registro y, opcional, AlmacenSesion para tokens Los implementa tu sistema ConfigToken y emitir

1. Configuración (ConfigToken)

Las llamadas con token (envío, estado, reclamos) reciben ConfigToken = ConfigBase & { rutTitular, firmador, almacenTokens?, guardaDeAmbiente? }, donde ConfigBase = { http, reloj, ambiente, registro?, esperar?, reintentos?, conservarCrudo? }.

import { readFile } from 'node:fs/promises'import type { AlmacenCaf, AlmacenSesion, Ambiente, Http, Registro } from '@devlas-cl/comun'import { type ConfigToken, crearFirmadorSemilla } from '@devlas-cl/sii'import { abrirCertificado } from '@devlas-cl/xml'
// Implementaciones de los puertos en tu sistema:declare const http: Httpdeclare const almacenCaf: AlmacenCaf       // reservarFolio atómico entre réplicasdeclare const almacenTokens: AlmacenSesion // opcional: reutiliza el token entre llamadas y réplicasdeclare const registro: Registro           // opcional: eventos y errores
const ambiente: Ambiente = 'certificacion'
// El .pfx del titular y su clave. La librería nunca registra la clave.const cert = abrirCertificado(await readFile('certificado.pfx'), process.env.CLAVE_PFX ?? '')if (!cert.ok) throw new Error(cert.error.codigo)const certificado = cert.valor // certificado.rutTitular: RUT de la persona titular
const configToken: ConfigToken = {  http,  reloj: { ahora: () => new Date() },  ambiente,  rutTitular: certificado.rutTitular,          // quien firma y envía  firmador: crearFirmadorSemilla(certificado), // firma la semilla para obtener el token  almacenTokens,  registro,}

2. Cargar el CAF en el almacén

Si no usas AutoCAF, guardas tú el archivo que descargaste del SII. Guarda el texto original completo (con la llave privada), no caf.valor.xml (que es solo el bloque <CAF> y no sirve para timbrar). Lee el archivo como ISO-8859-1.

import { readFile } from 'node:fs/promises'import type { AlmacenCaf, ClaveCaf } from '@devlas-cl/comun'import { leerCaf } from '@devlas-cl/xml'
declare const almacenCaf: AlmacenCaf
const clave: ClaveCaf = { rutEmisor: '7654321-6', tipoDte: 39, ambiente: 'certificacion' }
const archivoCaf = await readFile('caf-39.xml', 'latin1')const caf = leerCaf(archivoCaf)if (!caf.ok) throw new Error(caf.error.codigo)const { folioDesde, folioHasta, vencimiento } = caf.valor // vencimiento: null en 39 y 41 (no vencen)
const ranura = await almacenCaf.guardarCaf(clave, {  xml: archivoCaf, // texto COMPLETO: cífralo en reposo  desde: folioDesde,  hasta: folioHasta,  ...(vencimiento === null ? {} : { venceEn: vencimiento.getTime() }),}) // 'activa' | 'siguiente'

Si ya emitiste folios de ese rango con otro sistema, pasa { ultimoFolioUsado: n } como tercer argumento de guardarCaf.

3. El documento

Documento de @devlas-cl/dominio usa el vocabulario del SII. Campos obligatorios por tipo: tipo, fechaEmision (AAAA-MM-DD), emisor, base, lineas. En la boleta, indServicio es obligatorio por validación aunque el tipo lo declare opcional. Emisor exige rut, razonSocial, giro, direccion y comuna. Linea exige nombre y cantidad; precioUnitario es el precio por unidad.

import { type Documento, fechaTributaria } from '@devlas-cl/dominio'
const boleta: Documento = {  tipo: 39,  fechaEmision: fechaTributaria(new Date()), // AAAA-MM-DD en hora de Chile, nunca toISOString()  emisor: {    rut: '7654321-6',    razonSocial: 'Comercial de Prueba SpA',    giro: 'Venta al por menor',    direccion: 'Calle Uno 100',    comuna: 'Santiago',  },  base: 'bruto',  // los precios incluyen IVA (la boleta va siempre en bruto)  indServicio: 3, // 1 y 2 servicios periódicos, 3 venta y servicios, 4 espectáculos  lineas: [    { nombre: 'Café de grano 250 g', cantidad: 2, precioUnitario: 4990 },    { nombre: 'Galleta de avena', cantidad: 1, precioUnitario: 1500 },  ],}

Reglas de la boleta que la librería aplica por ti:

  • Sin receptor, se declara al consumidor final 66666666-6. Un RUT real es obligatorio si el total supera 135 UF, y entonces también medioPago.
  • La boleta lleva el detalle en bruto y no declara TasaIVA, MntBruto ni DscRcgGlobal; un descuento global se reparte en las líneas.
  • Largos del XSD sin recortar: un nombre (NmbItem) de más de 80 caracteres es un error VALIDACION con campo, límite y largo recibido. El excedente va en descripcion (DscItem, hasta 1000).
  • En la boleta exenta (41), cada línea lleva exento: true; una línea sin esa marca es el error local LINEA_AFECTA_EN_BOLETA_EXENTA.
  • Una boleta de total cero es advertencia: el SII la acepta con reparo y el folio se gasta.

4. Revisar antes de cobrar (opcional)

prevalidarEmision es pura (sin red). Devuelve hallazgos con codigo, nivel (error, advertencia, info), campo, mensaje, accion y fuente, sin gastar folio:

import type { Documento } from '@devlas-cl/dominio'import { prevalidarEmision } from '@devlas-cl/xml'
declare const boleta: Documento
const hallazgos = prevalidarEmision(boleta, { ahora: new Date() })const bloqueantes = hallazgos.filter((h) => h.nivel === 'error')if (bloqueantes.length > 0) {  // no cobrar todavía: mostrar h.mensaje y h.accion}

ContextoPrevalidacion acepta además folio, caf (un CafPublico), valorUf (para el umbral de 135 UF), rutEnvia y rutTitular.

5. Emitir

Firma: emitir(cfg: ConfigToken, entrada: EntradaEmitir): Promise<Resultado<DteEmitido>>, con EntradaEmitir = { documento, certificado, caratula: { fchResol, nroResol, rutReceptor?, tmstFirmaEnv? }, almacenCaf, declaraImpuestoAdicional? } y DteEmitido = { trackId: string, envio, dte: DtePreparado, sobre: Sobre }.

import { type ErrorSii, mensajeParaUsuario } from '@devlas-cl/comun'import type { AlmacenCaf } from '@devlas-cl/comun'import type { Documento } from '@devlas-cl/dominio'import { type ConfigToken, emitir } from '@devlas-cl/sii'import type { Certificado } from '@devlas-cl/xml'
declare const configToken: ConfigTokendeclare const certificado: Certificadodeclare const almacenCaf: AlmacenCafdeclare const boleta: Documento
const sinFolios = (e: ErrorSii): boolean =>  e.codigo === 'VALIDACION' && (e.causa as { regla?: string } | undefined)?.regla === 'sin_folios'
const r = await emitir(configToken, {  documento: boleta,                                 // sin folio: lo entrega el almacén  certificado,  caratula: { fchResol: '2026-10-07', nroResol: 0 }, // resolución del ambiente (en certificación, 0)  almacenCaf,})
if (!r.ok) {  if (sinFolios(r.error)) {    // no quedan folios vigentes: llamar a autoCaf o encolar el documento  }  const m = mensajeParaUsuario(r.error) // { titulo, queHacer, detalle, nivel, reintentable }  console.error(m.titulo, m.queHacer, m.detalle) // detalle: texto literal del SII o null} else {  const { trackId, dte } = r.valor  // dte.folio, dte.xml (DTE firmado), dte.ted (para el PDF417), dte.plan.Totales.MntTotal, dte.advertencias  // Tu sistema guarda dte.xml (6 años) y trackId COMO TEXTO (hasta 15 dígitos en boletas).}

Qué pasa con el folio:

  • Si algo falla antes de que el sobre pueda llegar al SII (documento inválido, CAF, XSD, token, red sin que nada llegara), el folio vuelve al almacén.
  • Si el envío pudo llegar (tiempo agotado, corte, 5xx o rechazo del SII), el folio queda consumido, el error se devuelve tal cual y no se reenvía. Consulta el estado antes de decidir. Ese caso deja el evento emitir.envio_incierto en el Registro, con tipo y folio.
  • Sin folios devuelve VALIDACION con causa.regla === 'sin_folios'.

6. Consultar el estado

El track id solo dice que el sobre subió. La boleta puede quedar rechazada después: consulta siempre. Una boleta se consulta por el canal rest; facturas, notas, guías y libros por soap.

import { siguienteConsultaDeEstado, veredictoFinalDeEnvio } from '@devlas-cl/protocolo'import { type ConfigToken, consultarEstadoEnvio } from '@devlas-cl/sii'
declare const configToken: ConfigTokendeclare const trackId: stringdeclare const enviadoEn: Datedeclare const consultasHechas: number
const estado = await consultarEstadoEnvio(configToken, { canal: 'rest', trackId, rutEmisor: '7654321-6' })if (estado.ok) {  // estado.valor: { canal, trackId, codigo, glosa, veredicto, porTipo, numeroAtencion, fechaRecepcion, detalleReparosRechazos }  const final = veredictoFinalDeEnvio(estado.valor)  // final.veredicto: 'en_proceso' | 'aceptado' | 'aceptado_con_reparos' | 'parcial' | 'rechazado' | 'desconocido'}
const siguiente = siguienteConsultaDeEstado(  { enviadoEn, consultasHechas, ultimoCodigo: estado.ok ? estado.valor.codigo : null },  new Date(),)// { accion: 'consultar', en, esperaMs } | { accion: 'cerrar', veredicto } | { accion: 'dejar_de_consultar' }
  • EPR (veredicto procesado) es del sobre, no del documento: usa veredictoFinalDeEnvio para sumar los conteos de porTipo.
  • Aceptado con reparos vale como aceptado. Rechazado nunca fue válido: corrige la causa y emite con folio nuevo.
  • Tiempos medidos en certificación: la boleta tuvo veredicto en menos de 2 minutos. Consulta desde los 60 segundos.

Detalle de códigos, canales y reenvío tras un corte: docs/estado.

Lo que pones tú

http, almacenCaf, almacenTokens y registro son tus implementaciones de los puertos. El contrato de Http es una función solicitar(solicitud) que no sigue redirecciones, no guarda cookies ni reintenta, envía las cabeceras en el orden dado y entrega el cuerpo en bytes tal como llegó. AlmacenCaf debe reservar folios de forma atómica entre réplicas (por ejemplo con Postgres).

Pendiente en esta documentación: la guía completa de los puertos (con adaptadores de referencia para node:https, Redis y Postgres y sus pruebas de contrato) existe en la documentación del producto y aún no está publicada aquí. Pídela al crear tu cuenta.

La otra puerta: piezas sueltas

Para intervenir entre pasos o agrupar hasta 500 boletas en un sobre: almacenCaf.reservarFolio(clave), leerCaf, prepararDte y prepararSobre de @devlas-cl/xml, y enviarBoletas de @devlas-cl/sii. Regla de oro: un folio se libera (almacenCaf.liberarFolio(clave, folio, reserva)) solo si algo falló antes de enviar.

import type { AlmacenCaf, ClaveCaf } from '@devlas-cl/comun'import type { Documento } from '@devlas-cl/dominio'import { type ConfigToken, enviarBoletas } from '@devlas-cl/sii'import { type Certificado, leerCaf, prepararDte, prepararSobre } from '@devlas-cl/xml'
declare const configToken: ConfigTokendeclare const almacenCaf: AlmacenCafdeclare const certificado: Certificadodeclare const boleta: Documentodeclare const clave: ClaveCaf
const ahora = configToken.reloj.ahora()const reservado = await almacenCaf.reservarFolio(clave)if (reservado === null) throw new Error('sin folios vigentes')
const caf = leerCaf(reservado.caf.xml)const dte = caf.ok  ? prepararDte({ documento: boleta, folio: reservado.folio, caf: caf.valor, certificado, ahora })  : cafif (!dte.ok) {  await almacenCaf.liberarFolio(clave, reservado.folio, reservado.reserva) // el SII no vio este folio} else {  const sobre = prepararSobre({ documentos: [dte.valor], caratula: { fchResol: '2026-10-07', nroResol: 0 }, certificado, ahora })  if (!sobre.ok) {    await almacenCaf.liberarFolio(clave, reservado.folio, reservado.reserva)  } else {    // Desde aquí el folio queda consumido aunque el envío falle.    const envio = await enviarBoletas(configToken, {      sobre: sobre.valor.xml,      rutEmisor: boleta.emisor.rut,      rutEnvia: configToken.rutTitular,    })  }}

prepararSobre elige EnvioBOLETA si el primer documento es boleta y EnvioDTE si no; mezclar familias o emisores es un error antes de firmar. Cuándo cerrar un grupo de boletas lo responde siguienteAccionDeEnvio de @devlas-cl/protocolo (por omisión espera 5 minutos desde la más antigua; la norma exige que cada boleta llegue al SII en máximo una hora).