Saltar al contenido
dte-sdk

API de Node (dte-sdk/node)

Los adaptadores que necesitan Node: el transporte node:https, que presenta certificados cliente y deja ajustar TLS, la captura HTTP, el archivo de envíos, la caché de tokens y sesiones y el registro de folios en disco, y la búsqueda de archivos CAF en disco, con el alcance de cada uno en el registro. Cargan módulos node: en el primer uso, nunca al importar. Cada adaptador se conecta a un puerto de la entrada raíz (transport, capture, archive, tokenCache) o del portal (sessionCache, el registro de folios); la búsqueda de archivos CAF es un conjunto de funciones que llamas tú.

TypeScript
import {
  createNodeHttpsTransport,
  createDirectoryCapture,
  createDirectoryUploadArchive,
  createDirectoryTokenCache,
  createDirectoryFolioRegistry,
  cafFileScope,
  findCafFiles,
  findLatestCafFile,
  resolveCafFile,
} from 'dte-sdk/node'

Un cliente armado con los adaptadores de Node:

TypeScript
import { createSiiClient } from 'dte-sdk'
import { createNodeHttpsTransport, createDirectoryCapture, createDirectoryUploadArchive } from 'dte-sdk/node'
 
const sii = createSiiClient({
  environment: 'certification',
  certificate,
  companyRut: '76543210-3',
  transport: createNodeHttpsTransport(),
  capture: createDirectoryCapture({ dir: './sii-capture' }),
  archive: createDirectoryUploadArchive({ dir: './sii-uploads' }),
})

El segundo parámetro de cada fábrica (load o loadModules) existe solo para que los tests del propio SDK reemplacen los módulos de Node; se omite de las firmas de abajo. Los archivos que escriben la captura y el archivo de envíos usan los modos por defecto de Node (se aplica el umask del proceso); no fijan permisos explícitos. Deja los directorios donde solo tu servicio pueda leerlos. La caché de tokens y el registro de folios son la excepción: fijan ellos mismos 0700 y 0600. Para el almacén del runner de certificación, que sí fija 0700 y 0600, ver dte-sdk/portal.

Todo lo de esta página está probado con datos sintéticos. El comportamiento TLS de los hosts del SII se midió el 2026-11-01: verifican con el almacén de confianza del sistema y negocian TLS 1.2 o 1.3 sin ayuda. Del cliente de los servicios web, solo la semilla y el token por SOAP y el registro de aceptación o reclamo se han probado contra el SII real, y solo en producción; los envíos y las consultas de estado aún no se ejecutan en ninguno de los dos ambientes (issue 8).

Transporte#

createNodeHttpsTransport devuelve un HttpTransport sobre node:https. La verificación del servidor está activada por defecto. Todos los demás campos TLS son salidas opt-in: tls.ca suma certificados de CA a las raíces públicas incluidas en Node y nunca las reemplaza (la solución correcta para un proxy corporativo o una CA privada), y tls.rejectUnauthorized: false desactiva la verificación, algo que no debes hacer contra el SII. tls.clientCertificate recibe el privateKeyPem y el chainPem de un Certificate, para TLS mutuo (el login al portal). Una petición que no se puede armar (una URL que no se interpreta, una cabecera que Node rechaza) falla con TRANSPORT_FAILED, como cualquier otra falla previa al intercambio. Si node:https no se puede cargar, la primera petición falla con TRANSPORT_UNAVAILABLE.

TypeScript
// Transporte sobre `node:https`, cargado en el primer uso (nunca al importar). Verifica el
// certificado del servidor por defecto; ver `NodeTlsOptions` para los ajustes opt-in.
// `loadModules` existe solo para tests.
function createNodeHttpsTransport(options?: NodeHttpsTransportOptions): HttpTransport
TipoDescripción
TlsVersionVersiones de TLS que el adaptador puede fijar. Definido como 'TLSv1.2' | 'TLSv1.3'.
NodeTlsOptionsAjustes TLS del adaptador Node.
NodeHttpsTransportOptionsOpciones de createNodeHttpsTransport.

Campos de NodeTlsOptions

CampoTipoDescripción
ca?string | readonly string[]CA adicionales de confianza (PEM), que se suman a las raíces públicas incluidas en Node y nunca las reemplazan: la solución correcta para un proxy corporativo o una CA privada.
rejectUnauthorized?booleanVerificar el certificado del servidor (por defecto true).
minVersion?TlsVersionVersión mínima de TLS.
maxVersion?TlsVersionVersión máxima de TLS.
legacyRenegotiation?booleanPermitir renegociación legacy insegura; ningún host del SII lo necesita hoy.
clientCertificate?Pick<Certificate, 'privateKeyPem' | 'chainPem'>Certificado cliente para TLS mutuo (login al portal); la cadena debe ir hoja → intermedios → raíz, como Certificate.chainPem.

Campos de NodeHttpsTransportOptions

CampoTipoDescripción
tls?NodeTlsOptionsOpciones TLS; la verificación sigue activa salvo que tls.rejectUnauthorized sea false.
keepAlive?booleanReutilizar sockets (por defecto true).

Captura#

createDirectoryCapture es un puerto HttpCapture que escribe un archivo JSON por intento HTTP y agrega una línea por intento a un índice. El cliente tacha cada intercambio antes de que llegue al puerto: tokens, semillas, cookies de sesión del portal, conversationId, RSASK y llaves privadas PEM pasan a [redacted], y los cuerpos se cortan a 512 KiB. Los RUT y el XML se conservan, porque el soporte del SII los necesita para diagnosticar un envío. Nunca lanza: una falla al escribir va a logger.warn.

TypeScript
// Escribe `<dir>/<prefijo><seq>-<operación>-<estado|ERR>.json` por intercambio y agrega una línea
// a `<dir>/index.jsonl`. Nunca lanza: las fallas van a `logger.warn`. `loadModules` existe solo
// para tests.
function createDirectoryCapture(options: DirectoryCaptureOptions): HttpCapture
TipoDescripción
DirectoryCaptureOptionsOpciones de createDirectoryCapture.

Campos de DirectoryCaptureOptions

CampoTipoDescripción
dirstringDirectorio destino, creado en el primer uso.
prefix?stringPrefijo de nombre que separa réplicas que comparten volumen.
logger?LoggerDónde se reportan las fallas (por defecto silencioso).

Lo que escribe bajo dir (se crea en el primer uso):

Text
<dir>/
  index.jsonl
  <prefix>0001-<operation>-<status>.json
  <prefix>0002-<operation>-<status>.json

index.jsonl tiene una línea por intento. <operation> es el nombre de la operación (por ejemplo uploadEnvelope) y <status> es el estado HTTP, o ERR si no llegó respuesta. Cada línea de index.jsonl es un objeto JSON con seq, startedAt, operation, method, url, status (null si no hubo respuesta), durationMs y file. El número de secuencia lleva cuatro dígitos y cada instancia del adaptador lo mantiene en memoria, así que vuelve a 1 en un proceso nuevo: usa un dir nuevo, o un prefix, por corrida o por réplica si quieres mantener separados los archivos anteriores.

Archivo de envíos#

createDirectoryUploadArchive es un puerto UploadArchive. De cada envío que obtuvo respuesta del SII guarda los bytes exactos enviados y un registro del intercambio. Nunca lanza: una falla va a logger.warn.

TypeScript
// Escribe los bytes enviados como `<nombre>.xml` y el registro sin ellos como `<nombre>.json` bajo
// `<dir>/<ambiente>/<aaaa-mm>/` (`nombre` según `uploadRecordName`). Nunca lanza. `loadModules` es
// para tests.
function createDirectoryUploadArchive(options: DirectoryUploadArchiveOptions): UploadArchive
TipoDescripción
DirectoryUploadArchiveOptionsOpciones de createDirectoryUploadArchive.

Campos de DirectoryUploadArchiveOptions

CampoTipoDescripción
dirstringDirectorio raíz; debajo se crea <ambiente>/<aaaa-mm>/.
logger?LoggerDónde se reportan las fallas (por defecto silencioso).

Lo que escribe:

Text
<dir>/<environment>/<yyyy-mm>/<name>.xml
<dir>/<environment>/<yyyy-mm>/<name>.json

<environment> es certification o production, y <yyyy-mm> es el mes UTC del envío. <name> es <trackId>-<kind>; para una respuesta que informa un duplicado es <trackId>-<kind>-duplicate-<aaaammddHHMMSS>, así no reemplaza el registro del envío original; y para un envío sin TrackID es rejected-<aaaammddHHMMSS>-<kind>. <kind> es dte, receipt, book o folioConsumption. El registro .json tiene los campos de UploadRecord salvo xml, y su response.raw tiene los tokens y semillas tachados. El archivo .xml contiene los documentos que enviaste, con los datos de tus clientes.

Caché de tokens#

createDirectoryTokenCache es un TokenCache guardado en disco. Sirve como tokenCache del cliente del SII y como sessionCache del cliente del portal, así un reinicio reutiliza el token o la sesión del portal en vez de volver a loguearse, cosa que el SII raciona por RUT. Los procesos del mismo usuario comparten las entradas. Guarda un token (o, en el portal, las cookies de la sesión) y su expiración, en un archivo JSON por clave bajo dir. Los tokens y las sesiones del portal son credenciales: dale un directorio propio.

TypeScript
// Una caché de tokens en disco: un archivo JSON por clave bajo `dir`, escrito de forma atómica (un
// archivo temporal propio, creado en exclusiva, y renombre) y legible solo por el dueño. La entrada
// nombra su clave, así dos claves que comparten nombre de archivo nunca reciben el token de la otra.
// Solo se lee un archivo regular del usuario del proceso, y la lectura nunca espera por lo que no es
// un archivo. Nunca lanza: una falla al leer o escribir es un fallo de caché, reportado a `logger`
// con la operación y nunca con el token. `loadModules` existe solo para tests.
function createDirectoryTokenCache(options: DirectoryTokenCacheOptions): TokenCache
TipoDescripción
DirectoryTokenCacheOptionsOpciones de createDirectoryTokenCache.

Campos de DirectoryTokenCacheOptions

CampoTipoDescripción
dirstringDirectorio de las entradas, un <clave>.json por cada una, solo para esta caché; se crea en el primer uso solo para el dueño, y uno que ya existe conserva sus permisos.
logger?LoggerDónde se reportan las fallas (por defecto silencioso).
TypeScript
import { createSiiClient } from 'dte-sdk'
import { createDirectoryTokenCache } from 'dte-sdk/node'
 
const sii = createSiiClient({
  environment: 'certification',
  certificate,
  companyRut: '76543210-3',
  tokenCache: createDirectoryTokenCache({ dir: './sii-tokens', logger: console }),
})

La misma llamada sirve al cliente del portal: createPortalClient({ …, sessionCache: createDirectoryTokenCache({ dir: './sii-sessions' }) }).

Lo que hace con los archivos bajo dir (se crea en el primer uso con modo 0700; un directorio que ya existe conserva sus permisos):

  • Un archivo por clave. <clave>.json, con cada carácter de la clave que no sea letra, dígito, _ o - reemplazado por -. Cada archivo se crea con modo 0600 y además guarda la clave a la que pertenece: dos claves que dan el mismo nombre de archivo (a:b y a-b, o A y a en un sistema de archivos que no distingue mayúsculas) nunca reciben el token de la otra; la entrada de otra clave es un fallo de caché.
  • Solo se leen tus propios archivos regulares. El archivo se abre sin bloquear y se lee por el mismo manejador que lo revisa, y se rechaza (fallo de caché) a menos que sea un archivo regular del usuario del proceso: un dispositivo, un directorio, una tubería o un archivo que otro usuario dejó en dir nunca se lee. En Windows, donde los archivos no tienen ese modo y no hay ids de usuario, deciden los permisos de dir y no se revisa el dueño.
  • Una falla es un fallo de caché. Un archivo que no existe, uno que no es una entrada, una escritura que falla: get responde null y set y delete no hacen nada, y la falla va a logger.warn como token cache failed con la operation y el reason. El motivo nunca lleva el token ni un error de parseo, porque una entrada del portal guarda las cookies de la sesión.

Los procesos que usan una misma sesión del portal a la vez necesitan además un lock compartido: la caché comparte la sesión, no el turno para usarla. Ver dte-sdk/portal. El tipo TokenCache y la caché en memoria están en dte-sdk.

Registro de folios#

createDirectoryFolioRegistry devuelve un FolioRegistry (folios reservados, enviados y anulados) guardado en un archivo JSON, legible solo por el dueño. Es para un solo proceso. Conserva el directorio entre reinicios: si lo pierdes, se repiten folios ante el SII.

TypeScript
// Un registro en un archivo JSON, escrito de forma atómica (un archivo temporal propio, creado en
// exclusiva, y renombre), así un lector nunca ve medio libro, y legible solo por el dueño, como la
// caché de tokens, porque el libro guarda los RUT de las empresas, sus track ids y lo que quien
// llama guarda en `extra` (en Windows, donde los archivos no tienen ese modo, deciden los permisos
// de `dir`). Es para un solo proceso. `load` existe solo para tests.
function createDirectoryFolioRegistry(options: DirectoryFolioRegistryOptions): FolioRegistry
TipoDescripción
DirectoryFolioRegistryOptionsOpciones de createDirectoryFolioRegistry.

Campos de DirectoryFolioRegistryOptions

CampoTipoDescripción
dirstringDirectorio de folios.json; se crea en el primer uso solo para el dueño, y uno que ya existe conserva sus permisos. Debe sobrevivir a los reinicios: perderlo repite folios ante el SII.
now?() => DateReloj.

El registro es un solo archivo, <dir>/folios.json. El directorio se crea con modo 0700; uno que ya existe conserva sus permisos. Cada guardado escribe un archivo temporal propio a su lado (folios.json.<uuid>.tmp), creado en exclusiva con modo 0600, y lo renombra sobre folios.json, así un lector nunca ve medio libro y nunca se escribe a través de lo que ya esté en ese nombre, ni siquiera de un enlace simbólico. Si la escritura o el renombre fallan, el archivo temporal se borra. Un folios.json escrito antes de la 0.5.0, con los modos por defecto, queda solo para el dueño en su siguiente guardado. En Windows, donde los archivos no tienen ese modo, deciden los permisos de dir. Si node:fs no se puede cargar, las operaciones del registro fallan con TRANSPORT_UNAVAILABLE. El tipo FolioRegistry y el registro en memoria están en dte-sdk/portal.

Archivos CAF#

Un directorio con los archivos CAF que te entregó el SII, donde se busca el que se usará para emitir. Las tres funciones de búsqueda solo leen: reservar el folio le toca al registro, y pedir un CAF nuevo al SII cuando no se encuentra ninguno te toca a ti. cafFileScope no lee nada: da el alcance de un archivo en el registro. El XML del CAF contiene la clave privada del CAF, así que el directorio es un secreto (ver Seguridad).

TypeScript
// Todos los archivos CAF utilizables de un tipo y empresa bajo un directorio, del más nuevo al más
// viejo (por fecha de modificación, luego ruta). Utilizable quiere decir que se parsea como CAF, su
// `TD` es el tipo, su `RE` es la empresa (leído del archivo, nunca de dónde está) y no ha vencido,
// con el margen de un día de `isCafExpired`; con `includeExpired` vienen también los vencidos,
// marcados `expired`. Cada archivo se lee en la codificación que nombra su declaración XML:
// ISO-8859-1 como tal, UTF-8 o ninguna como UTF-8 estricto, así que un archivo cuyos bytes no son
// esa codificación, o que nombra otra, se salta en vez de leerse mal. Una carpeta o archivo que no
// se pueda leer se salta, y un directorio que no existe da una lista vacía.
function findCafFiles(options: FindCafFilesOptions): Promise<readonly CafFile[]>
 
// El archivo CAF utilizable más nuevo de un tipo y empresa bajo un directorio (ver `findCafFiles`),
// o null; nunca uno vencido, diga lo que diga `includeExpired`.
function findLatestCafFile(options: FindCafFilesOptions): Promise<CafFile | null>
 
// El archivo CAF utilizable más nuevo, nunca uno vencido, que todavía tiene `required` folios
// libres, o null si ninguno los tiene. Con un registro los folios libres son `registry.remaining`
// para el alcance del archivo (`cafFileScope`: empresa, tipo, rango, `environment` y la huella de su
// contenido), que vuelve como `scope`; sin registro, todo el rango. Solo lee.
function resolveCafFile(options: ResolveCafFileOptions): Promise<ResolvedCafFile | null>
 
// El alcance en el registro de un archivo CAF, para reservar y contar sus folios: la empresa (`RE`),
// el tipo (`TD`) y el rango (`RNG`) de su CAF, el ambiente indicado y la `cafFingerprint` de su
// texto, para que dos archivos de un mismo rango nunca compartan entrada. Es el alcance que devuelve
// `resolveCafFile`. Coincide con el archivo en disco solo si recibe el texto que guarda ese archivo.
function cafFileScope(file: Pick<CafFile, 'xml' | 'caf'>, environment: Environment): FolioScope
TipoDescripción
FindCafFilesOptionsOpciones de findCafFiles y findLatestCafFile.
CafFileUn archivo CAF encontrado en disco.
ResolveCafFileOptionsOpciones de resolveCafFile. Con un registro, el ambiente de sus alcances es obligatorio.
ResolvedCafFileEl archivo CAF que eligió resolveCafFile y sus folios libres. Un CafFile más remaining y scope.

Campos de FindCafFilesOptions

CampoTipoDescripción
dirstringDirectorio donde se busca, con sus carpetas hasta cinco niveles más abajo.
typeDocumentTypeTD: tipo de documento del CAF.
issuerRutstringRE: RUT de la empresa a la que debe estar emitido el CAF, en cualquier forma.
now?() => DateReloj para revisar el vencimiento.
includeExpired?booleanSolo findCafFiles: también los archivos cuyo CAF venció, marcados expired, para que puedas decir por qué uno no se usa; false por defecto. findLatestCafFile y resolveCafFile nunca toman uno.

Campos de CafFile

CampoTipoDescripción
pathstringRuta del archivo, armada desde dir.
xmlstringEl contenido del archivo, decodificado como dice su declaración XML, como lo toman cafFingerprint y parseCaf.
cafCafEl CAF parseado.
modifiedAtDateÚltima modificación del archivo.
expiredbooleanEl CAF venció, con el margen de un día de isCafExpired; true solo con includeExpired. Un CafFile armado a mano, como en un doble de prueba, necesita expired: false.

Campos de ResolveCafFileOptions

Los campos de FindCafFilesOptions más estos:

CampoTipoDescripción
required?numberFolios libres que debe tener el CAF; por defecto 1.
registry?FolioRegistryRegistro que dice cuántos folios de cada CAF siguen sin usar. Sin él, todos los folios del rango cuentan como libres. Con él, environment es obligatorio.
environment?EnvironmentAmbiente de los alcances del registro y del scope devuelto; sin él scope es null.

Campos de ResolvedCafFile

Los campos de CafFile más estos:

CampoTipoDescripción
remainingnumberFolios del rango aún sin usar.
scopeFolioScope | nullEl alcance del archivo en el registro, para reservar sus folios; null si no se indicó environment.

Lo que hace la búsqueda:

  • Recorre dir y sus carpetas hasta cinco niveles más abajo, y mira solo los archivos .xml.
  • Cada archivo se lee en la codificación que nombra su declaración XML. Uno que declara ISO-8859-1, como escribe el SII sus archivos CAF, se lee como tal, así que una Ñ o un acento en el nombre de la empresa llega intacto a RS y el bloque <CAF> conserva el texto que firmó el SII. Uno que declara UTF-8, o ninguna codificación, se lee como UTF-8 estricto. Un archivo cuyos bytes no son la codificación que nombra, o que nombra otra, se salta en vez de leerse mal, y una marca de orden de bytes UTF-8 se descarta. Antes de la 0.7.0 todo archivo se leía como UTF-8, y un CAF guardado tal como lo entregó el SII, con una Ñ o un acento, hacía fallar el chequeo del SII de cada timbre hecho con él.
  • Un archivo cuenta solo si se parsea como CAF de ese type cuyo RE es issuerRut. La empresa se lee del archivo y nunca de la carpeta: una carpeta compartida puede tener archivos CAF de otras empresas, y un timbre firmado con uno hace que el SII rechace el sobre entero.
  • Un CAF vencido, con el margen de un día de isCafExpired, se deja fuera. Con includeExpired: true, findCafFiles también lo lista, con expired: true, para que puedas decir por qué un archivo no se usa; findLatestCafFile y resolveCafFile nunca toman uno.
  • Una carpeta o archivo que no se pueda leer se salta. Un dir que no existe da una lista vacía.
  • El resultado va del más nuevo al más viejo por fecha de modificación, luego por ruta.

Un archivo sin declaración XML y con bytes ISO-8859-1 ya no se encuentra: declara su codificación, como lo hace el archivo del SII, o guárdalo como UTF-8.

El alcance en el registro de algunos archivos cambió en la 0.7.0. cafFingerprint calcula la huella del texto tal como se lee, así que el alcance que devuelve resolveCafFile cambió para un archivo guardado con una marca de orden de bytes UTF-8 (la marca ya no está en el texto) y para uno declarado ISO-8859-1 con bytes sobre 0x7F (ahora se leen como Latin-1, donde antes eran caracteres de reemplazo). Una entrada del registro guardada con la huella antigua ya no coincide con el archivo, y resolveCafFile leería todo su rango como libre, incluidos los folios ya usados. Antes de volver a emitir con un archivo así, mueve su entrada al alcance nuevo (cafFileScope del archivo tal como se lee ahora), o vuelve a guardar el archivo sin la marca, o como UTF-8, y mueve la entrada entonces. Un archivo en UTF-8 simple, o ASCII, conserva su huella.

cafFileScope es el alcance que arma resolveCafFile. Cualquier cosa con el texto de un CAF y el CAF parseado de él también tiene uno, como un ObtainedCaf del portal, pero es el alcance del archivo guardado solo si recibe el texto que guarda el archivo: una app que reescribe un CAF descargado antes de guardarlo, su declaración por ejemplo, toma el alcance del texto que guardó.

Cada función lanza un DteError ante una opción inválida, antes de leer nada:

CódigoCuándo
INVALID_OPTION (field: 'dir')dir no es un string no vacío.
INVALID_DOCUMENT_TYPEtype no es un tipo de documento.
INVALID_RUTissuerRut no es un RUT.
INVALID_OPTION (field: 'required')required no es un entero de al menos 1 (resolveCafFile).
INVALID_OPTION (field: 'environment')Se indicó un registry sin environment (resolveCafFile).
TRANSPORT_UNAVAILABLEnode:fs no se puede cargar.
TypeScript
import { createDirectoryFolioRegistry, resolveCafFile } from 'dte-sdk/node'
 
const registry = createDirectoryFolioRegistry({ dir: './folios' })
const file = await resolveCafFile({
  dir: './cafs',
  type: 33,
  issuerRut: '76543210-3',
  required: 1,
  registry,
  environment: 'production',
})
if (file === null) throw new Error('No CAF with free folios: request one')
const folio = await registry.reserve(file.scope!) // file.caf va a issueDocument