Servicios web del SII
El cliente de servicios web habla con el SII por HTTP: obtiene y guarda en caché los tokens, sube sobres, libros y reportes de consumo de folios firmados, consulta el estado de los envíos y de los documentos, y registra aceptaciones y reclamos sobre documentos recibidos. Es dato plano más funciones libres, con un transporte inyectable, una política de reintentos que sabe qué llamadas se pueden repetir sin riesgo y puertos para capturar los intercambios HTTP y archivar cada envío.
La 0.8.0 de dte-sdk está en npm, la versión más reciente. Todavía no se
recomienda para producción: la mayoría de las áreas no se ha probado contra el
SII real, y hasta la 1.0.0 la API puede cambiar en cualquier versión. Del
cliente de 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,
el 9 y el 10 de noviembre de 2026. Los envíos, las consultas de estado y la
API REST de boletas aún no se ejecutan en ninguno de los dos ambientes (se
sigue en el issue 8), y del
registro de emisores autorizados no consta ninguna ejecución. Todo está
probado contra respuestas sintéticas del SII, con un umbral de cobertura del
100 %. Revisa Estado.
Uso#
Crea un cliente por ambiente y certificado, y pásalo como primer argumento de cada operación.
import { readFileSync } from 'node:fs'
import {
claimResponseMessage,
createSiiClient,
loadCertificateFromPkcs12,
registerClaim,
uploadEnvelope,
waitForUploadStatus,
} from 'dte-sdk'
const certificate = loadCertificateFromPkcs12(readFileSync('firma.pfx'), process.env.PFX_PASSWORD!)
const sii = createSiiClient({
environment: 'certification',
certificate,
companyRut: '76543210-3',
})
// `envelope` es el resultado de signEnvelope(): ver /docs/features/envelopes
const receipt = await uploadEnvelope(sii, { envelope })
receipt.trackId // solo dígitos: guárdalo, es como pides el resultado
receipt.duplicate // true cuando el SII ya tenía este archivo y devolvió el TrackID original
const status = await waitForUploadStatus(sii, { trackId: receipt.trackId })
status.status // 'accepted', 'acceptedWithObjections' o 'rejected' (aquí nunca 'pending')
status.message // glosa en español: la del SII, si no la del catálogo
// Como receptor de una factura de compra: acepta su contenido.
const claim = await registerClaim(sii, {
issuerRut: '10000004-0',
type: 33,
folio: 1234,
action: 'acceptContent',
})
if (!claim.ok) console.log(claimResponseMessage(claim.code)) // las respuestas de negocio son códigos, no erroresUn TrackID significa que el SII recibió el archivo, no que aceptó los documentos. La aceptación llega después y se lee con queryUploadStatus o waitForUploadStatus.
El cliente#
createSiiClient(options) valida las opciones y devuelve un objeto congelado: dato plano, sin métodos ni estado oculto. Cada operación es una función libre que lo recibe primero: uploadEnvelope(sii, …), queryUploadStatus(sii, …), registerClaim(sii, …). Dos clientes no comparten nada, salvo que les pases el mismo tokenCache.
| Opción | Por defecto | Qué hace |
|---|---|---|
environment | obligatoria | 'certification' (host maullin.sii.cl) o 'production' (host palena.sii.cl). Cualquier otro valor es INVALID_OPTION { field: 'environment' }. |
certificate | obligatoria | El certificado que firma la semilla. Sin él: MISSING_FIELD { field: 'certificate' }. |
companyRut | ninguno | RUT de la empresa sobre la que se opera. Cada llamada puede reemplazarlo; si ninguno lo indica, la llamada falla con MISSING_FIELD { field: 'companyRut' }. |
senderRut | titular del certificado | RUT de quien envía (RutEnvia). Un certificado sin RUT legible lo necesita: MISSING_FIELD { field: 'senderRut' }. |
transport | createFetchTransport() | El puerto HTTP. Ver Transporte. |
tokenCache | createMemoryTokenCache() | Dónde viven los tokens: en memoria por defecto, o en disco con createDirectoryTokenCache de dte-sdk/node. Ver Tokens. |
tokenTtlMs | 3 300 000 (55 min) | Cuánto tiempo permanece un token en la caché. |
retry | los valores de la política de reintentos | Una política parcial que se mezcla sobre los valores por defecto, o false para desactivar los reintentos. |
timeouts | { soap: 30000, rest: 15000, upload: 60000 } | Milisegundos por canal: llamadas SOAP de DTEWS y WSRECLAMO, llamadas REST de boletas, envíos por cualquiera de los dos canales. |
receiptsChannel | 'rest' en producción, 'soap' en certificación | Canal para los envíos EnvioBOLETA. EnvioDTE siempre va por DTEUpload. |
logger | silentLogger | Ver Logger. |
capture | apagado | Ver Captura. |
archive | apagado | Ver Archivo de envíos. |
endpoints | los catálogos de más abajo | Reemplazos de { soap, rest, claims }, para un proxy o un doble de pruebas. |
now | () => new Date() | El reloj. |
sleep | un temporizador que el signal del llamador puede cancelar | La espera entre reintentos y entre sondeos de estado. |
Todo número debe ser positivo y finito, la política de reintentos se valida (maxAttempts entero de 1 o más, jitterRatio de 0 a 1) y los RUT pasan por rut.ts. Un valor inválido es INVALID_OPTION { field } (por ejemplo field: 'timeouts.soap' o 'retry.maxAttempts') o INVALID_RUT { field }.
Ajustes por llamada#
Toda operación acepta { signal, timeoutMs, retry } como último argumento:
const status = await queryUploadStatus(
sii,
{ trackId: '0000000001' },
{ signal: AbortSignal.timeout(20_000), timeoutMs: 8_000, retry: { maxAttempts: 2 } },
)signal cancela la llamada con REQUEST_ABORTED, timeoutMs reemplaza el timeout del canal solo para esta llamada, y retry se mezcla sobre la política del cliente (o false desactiva los reintentos de la llamada).
Los servicios y dónde viven#
El cliente llega a cuatro servicios. Todos usan TLS y los hosts de abajo.
| Servicio | Certificación | Producción | Lo usan |
|---|---|---|---|
DTEWS SOAP (CrSeed, GetTokenFromSeed, QueryEstUp, QueryEstDte) y el CGI DTEUpload | maullin.sii.cl | palena.sii.cl | semillas, tokens, envío de sobres, libros y reportes, consultas de estado |
| API REST de boletas | apicert.sii.cl (semilla, token, estado) y pangal.sii.cl (envío) | api.sii.cl y rahue.sii.cl | envío de EnvioBOLETA y estado de boletas |
| WSRECLAMO (registro de aceptación y reclamo) | ws2.sii.cl | ws1.sii.cl | registerClaim y las consultas asociadas |
Los catálogos que están detrás se exportan, por si necesitas una URL: SII_HOSTS, SOAP_ENDPOINTS, REST_ENDPOINTS, CLAIM_WS_ENDPOINTS, CLAIM_WSDL_ENDPOINTS (el WSDL del registro) y UPLOAD_USER_AGENT. El CGI de subida rechaza los User-Agent de navegadores modernos, así que el cliente envía en los envíos el valor fijo de UPLOAD_USER_AGENT.
import { SOAP_ENDPOINTS } from 'dte-sdk'
SOAP_ENDPOINTS.certification.upload // 'https://maullin.sii.cl/cgi_dte/UPL/DTEUpload'Tokens#
El SII autentica con una semilla que tú firmas y cambias por un token. No tienes que hacerlo a mano: toda operación que necesita un token llama a getToken, que devuelve el de la caché mientras no venza y, si no, ejecuta el intercambio completo.
import { getToken, invalidateToken } from 'dte-sdk'
const token = await getToken(sii, 'soap') // en caché durante tokenTtlMs
await invalidateToken(sii, 'soap') // la próxima operación vuelve a autenticarse- Dos canales, dos tokens.
'soap'sirve a DTEWS (DTEUpload,QueryEst*) y a WSRECLAMO;'rest'sirve a la API de boletas. El token DTEWS y el token REST son cosas distintas y nunca se reemplazan entre sí. getSeed(sii, channel)pide una semilla: un string de dígitos (12 en la práctica) que el SII mantiene vigente dos minutos. Nunca se guarda en caché.signSeed(seed, certificate)es pura y síncrona: arma<getToken><item><Semilla>…</Semilla></item><Signature…/></getToken>con la declaración XML, un salto de línea y sin salto final, la disposición con que el endpoint REST es estricto. Una semilla que no sea un string de dígitos esINVALID_OPTION { field: 'seed' }: viaja como string porque conserva sus ceros iniciales.- Una caché por ambiente, canal y certificado. La clave es
tokenCacheKey(environment, channel, certificate), es decir<ambiente>:<canal>:<sha-1 del certificado>. Dos empresas, dos ambientes o dos canales nunca comparten un token, y la clave no expone al titular. - Un token que el SII rechaza se renueva solo, una vez. Cuando una llamada falla con
SII_TOKEN_INVALID, se descarta el token en caché, se obtiene uno nuevo y la llamada se ejecuta una vez más. Si el SII rechaza también el token nuevo, ese token también se descarta y el error sale condetails.attempts: 2. - El intercambio es un solo ciclo de reintentos alrededor de semilla, firma y token: cada intento firma una semilla nueva, y
maxAttemptsacota semilla y token juntos. Las fallas sonSII_SEED_FAILED { code, channel }ySII_TOKEN_FAILED { code, channel }.
createMemoryTokenCache() guarda los tokens por proceso, y es la que se usa por defecto. Para guardarlos en disco, usa createDirectoryTokenCache({ dir, logger }) de dte-sdk/node:
import { createSiiClient } from 'dte-sdk'
import { createDirectoryTokenCache } from 'dte-sdk/node'
const sii = createSiiClient({
environment: 'production',
certificate,
companyRut: '76543210-3',
tokenCache: createDirectoryTokenCache({ dir: './sii-tokens' }),
})Escribe un archivo por clave, legible solo por su dueño (en Windows deciden los permisos de dir), así que un reinicio y los demás procesos del mismo usuario reutilizan el token en vez de repetir el intercambio. También sirve como sessionCache del cliente del portal, cuyas entradas guardan las cookies de la sesión. Dale un directorio propio. Nunca lanza: una entrada que no puede leer o escribir cuenta como un fallo de caché, que solo se reporta al logger, nunca con el token.
Para compartir tokens entre máquinas, implementa el puerto TokenCache (get, set, delete, todos asíncronos, sobre un CachedToken de { token, expiresAt } en milisegundos epoch) sobre Redis, por ejemplo, y pásalo como tokenCache. Un token es una credencial: protege el almacén como tal.
Envíos#
Tres funciones suben un archivo y devuelven un UploadReceipt. Todas envían el XML como bytes ISO-8859-1 en un formulario multipart, con rutSender, dvSender, rutCompany, dvCompany y el archivo en la parte archivo.
import { uploadBook, uploadEnvelope, uploadFolioConsumption } from 'dte-sdk'
await uploadEnvelope(sii, { envelope }) // EnvioDTE o EnvioBOLETA
await uploadBook(sii, { xml: signedBook.xml }) // LibroCompraVenta o LibroGuia
await uploadFolioConsumption(sii, { xml: signedReport.xml }) // ConsumoFolios (RCOF)Por qué canal#
| Qué | Canal | Notas |
|---|---|---|
EnvioDTE | DTEUpload (lado SOAP), siempre | Pasar channel: 'rest' es INVALID_OPTION { field: 'channel' }. |
EnvioBOLETA en producción | API REST de boletas | El valor por defecto de receiptsChannel. |
EnvioBOLETA en certificación | DTEUpload | El valor por defecto de receiptsChannel. |
| Libros y reportes de consumo de folios | DTEUpload, siempre |
uploadEnvelope recibe { envelope, companyRut?, senderRut?, channel? }. El sobre es un SignedEnvelope de signEnvelope o solo { kind, xml } para XML generado en otra parte. El canal REST acepta como máximo 500 boletas por sobre: más que eso se rechaza antes de enviar nada, como SII_UPLOAD_REJECTED { status: 'TOO_MANY', count }.
uploadBook y uploadFolioConsumption reciben { xml, companyRut?, senderRut?, fileName? }. El nombre de archivo es lo que ve el SII en la parte multipart: EnvioDTE.xml, EnvioBOLETA.xml, LibroGuia.xml para un libro de guías (se detecta por su elemento raíz), LibroCV.xml para cualquier otro libro y ConsumoFolios.xml para un reporte. fileName lo reemplaza, pero solo con letras, dígitos, _, . y -, terminado en .xml; cualquier otra cosa es INVALID_OPTION { field: 'fileName' }.
El XML debe declarar ISO-8859-1#
Los bytes que viajan son ISO-8859-1, así que el XML tiene que declarar esa codificación. Con otra codificación, o ninguna (el XML entonces asume UTF-8), el SII leería un texto distinto del que se firmó. La declaración debe ser lo primero del archivo: el espacio en blanco antes de ella se descarta, y cualquier cosa que no abra con <?xml … encoding="ISO-8859-1" …?> (en cualquier caja, con comillas simples o dobles) se rechaza con INVALID_OPTION { field: 'xml' } antes de enviar nada. Los sobres y libros que arma este SDK ya la cumplen. Un carácter fuera de ISO-8859-1 es ENCODING_UNSUPPORTED_CHAR.
El recibo#
type UploadReceipt = {
trackId: string // solo dígitos
receivedAt: string // 'AAAA-MM-DDTHH:MM:SS' hora de Chile (el reloj del cliente si el SII la omite)
kind: 'dte' | 'receipt' | 'book' | 'folioConsumption'
channel: 'soap' | 'rest'
fileName: string
duplicate: boolean
raw: string // la respuesta del SII tal como llegó
}raw es la respuesta exactamente como llegó. El puerto de archivo recibe una copia con los secretos tachados; raw no está tachada, así que no la registres a ciegas.
Duplicados#
Si el SII dice que ya tiene el archivo (STATUS 99, o un STATUS 7 cuyos errores no son de esquema ni de charset y que nombra un TrackID), el envío no falla: devuelve el TrackID original con duplicate: true. Eso hace seguro repetir el mismo envío, por ejemplo tras una caída que perdió la primera respuesta. El archivo de envíos guarda esa respuesta con otro nombre, así que nunca reemplaza el registro del envío original.
Rechazos#
Todo lo demás que el SII no acepta es un SII_UPLOAD_REJECTED cuyos details traen status (el STATUS de DTEUpload, como número), errors (las líneas de DETAIL/ERROR, con las entidades decodificadas), fileName, operation y message (la glosa en español del estado). Los códigos STATUS están en Errores, estados de recepción.
import { isDteError } from 'dte-sdk'
try {
await uploadEnvelope(sii, { envelope })
} catch (error) {
if (isDteError(error) && error.code === 'SII_UPLOAD_REJECTED') {
console.error(error.details.status, error.details.errors)
} else {
throw error
}
}Estado#
Todas las funciones de estado son de solo lectura, así que siguen la política de reintentos completa.
Estado de un envío#
queryUploadStatus(sii, { trackId, companyRut?, channel? }) pregunta al SII qué pasó con un envío. El TrackID debe ser solo dígitos (se coloca en una URL y en un parámetro SOAP): cualquier otra cosa es INVALID_OPTION { field: 'trackId' }. channel es 'soap' por defecto (QueryEstUp) y 'rest' para boletas subidas por la API REST.
type UploadStatus = {
code: string // 'EPR', 'RPR', 'RCH', 'REC', …
status: 'accepted' | 'acceptedWithObjections' | 'pending' | 'rejected' | 'queryError' | 'unknown'
message: string // la glosa del SII, si no la del catálogo
trackId: string
channel: 'soap' | 'rest'
counts: { reported: number; accepted: number; rejected: number; objections: number } | null
documentType: string | null // solo SOAP
documents: UploadStatusDocument[] // solo REST: por documento, con sus errores
raw: string
}status viene de classifyUploadStatus(code, channel). El canal importa para un código: RLV es rechazo en SOAP y éxito en REST. Los códigos negativos -1 a -14 son errores del servicio de consulta, no de tu sobre, así que lanzan SII_QUERY_ERROR { code } y se reintentan. Un token que el SII rechaza (-3, -13, 001 a 003, HTTP 401) renueva el token una vez.
Esperar la respuesta final#
import { waitForUploadStatus } from 'dte-sdk'
const status = await waitForUploadStatus(
sii,
{ trackId: receipt.trackId, intervalMs: 10_000, timeoutMs: 15 * 60_000 },
{ signal: controller.signal },
)waitForUploadStatus sondea cada intervalMs (5 000 por defecto) hasta que el estado deja de ser pending, con un máximo de timeoutMs (600 000 por defecto). Sus reglas:
- El intervalo reemplaza a la política de reintentos. Cada sondeo es una sola petición; el
retrydel cliente y el de la llamada se ignoran. Una falla queisRetryableErroracepta (errores de consulta, fallas de transporte, timeouts, HTTP 429/405/5xx, páginas ilegibles) cuenta como "aún pendiente", así que un servicio de consulta inestable no termina la espera. Cualquier otro error (un token inválido, un HTTP 4xx, un intercambio de token fallido) la termina de inmediato. timeoutMses un presupuesto. El timeout de cada sondeo es el menor entre el timeout normal y el tiempo que queda, y lo mismo vale para cada pausa. La espera termina entimeoutMs, no después de que una política de reintentos se agote en cada sondeo.- Cómo termina. Con el
UploadStatusque ya no está pendiente; conWAIT_TIMEOUT { trackId, attempts, lastCode }cuando se agota el presupuesto (lastCodees el último código de estado, de error de consulta o de error, así que dice por qué); conREQUEST_ABORTEDcuando se dispara tusignal. - Usa el
nowy elsleepdel cliente, lo que permite probarla sin esperar.
Estado de un documento#
queryDocumentStatus(sii, input) pregunta por un documento: QueryEstDte por SOAP para todos los tipos salvo boletas, y el endpoint REST por documento para 39 y 41 (channel lo cambia). La API REST responde unos diez minutos después del envío.
import { queryDocumentStatus } from 'dte-sdk'
const document = await queryDocumentStatus(sii, {
receiverRut: '12345678-5',
type: 33,
folio: 1,
issueDate: '2026-11-05',
totalAmount: 119000, // se redondea a entero
})
document.code // 'DOK'
document.status // 'received'
document.message // 'Documento recibido por el SII, datos coinciden'companyRut es por defecto el del cliente. consultantRut (solo SOAP) es por defecto quien envía, es decir quien se autentica, no la empresa. La fecha se envía como DDMMAAAA en SOAP y DD-MM-AAAA en REST. Una entrada inválida es INVALID_DOCUMENT_TYPE, INVALID_RUT, INVALID_FOLIO, INVALID_AMOUNT o INVALID_DATE. Las clases de DocumentStatus.status están en Errores, estados de documento.
¿Este folio ya está usado?#
folioReceived(sii, { type, folio, companyRut? }) responde si el SII ya tiene un documento con ese tipo y folio. Consulta QueryEstDte como la propia empresa, con el receptor consumidor final, la fecha de hoy en Chile y monto 1, y lee la respuesta solo del código y de la glosa:
| Resultado | Significado |
|---|---|
false | El folio está libre (FAU, o una glosa que dice "no recibido"). |
true | El folio está usado (una glosa que dice "recibido"). |
null | Indeterminado: decide el llamador. |
Úsala antes de reutilizar un folio que volviste a obtener de un CAF: el SII rechaza uno repetido.
Registro de aceptación y reclamo#
El registro (WSRECLAMO) es donde el receptor de una factura la acepta o la reclama. Acepta los tipos de documento 33, 34 y 43, se autentica con el token DTEWS y tiene su propio vocabulario SOAP; el SDK lo esconde detrás de cinco funciones, un resumen y una función pura que aplica la regla de los ocho días.
Los tres tipos son datos del paquete: CLAIM_DOCUMENT_TYPES los lista e isClaimDocumentType(type) es su guarda de tipo, para elegir los documentos que acepta el registro, por ejemplo entre los documentos recibidos que revisaste con verifyReceivedDocuments. Cualquier otro tipo es INVALID_DOCUMENT_TYPE, que se lanza antes de enviar nada.
import {
CLAIM_ACTIONS,
listClaimEvents,
queryReceptionDate,
queryTransferable,
receiverStatus,
registerClaim,
transferabilityMessage,
} from 'dte-sdk'
const document = { issuerRut: '10000004-0', type: 33 as const, folio: 1234 }
await registerClaim(sii, { ...document, action: 'acknowledgeGoods' })
const events = await listClaimEvents(sii, document)
const transferable = await queryTransferable(sii, document)
const reception = await queryReceptionDate(sii, document)
const summary = await receiverStatus(sii, document)registerClaim#
Registra una acción sobre un documento recibido. La action es el nombre en inglés de un código de CLAIM_ACTIONS:
action | Código SII | Significado |
|---|---|---|
acceptContent | ACD | Acepta el contenido del documento. |
claimContent | RCD | Reclama el contenido del documento. |
acknowledgeGoods | ERM | Otorga recibo de mercaderías o servicios. |
claimPartialShortfall | RFP | Reclama una falta parcial de mercaderías. |
claimTotalShortfall | RFT | Reclama una falta total de mercaderías. |
Cualquier otro valor es INVALID_OPTION { field: 'action' }.
Las respuestas de negocio son códigos, no errores. El SII responde situaciones como "DTE ya reclamado" o "han pasado más de 8 días desde la recepción" con un código. registerClaim las devuelve como dato:
type ClaimResult = {
code: number // codResp: 0 ok, 1 a 19 y 27 respuestas de negocio, -1 error interno
description: string // descResp, hasta 70 caracteres
ok: boolean // code === 0
raw: string // XML interior de la respuesta
}claimResponseMessage(code) entrega la glosa en español de cualquier código (la lista completa está en Errores, códigos de reclamo). Solo se lanza lo que queda fuera del protocolo: un fault SOAP (SII_SOAP_FAULT), una respuesta ilegible (SII_RESPONSE_INVALID), un error HTTP, un token rechazado, una falla de transporte.
Un reclamo cambia el estado del documento en el SII y, en producción, el del documento real (una factura reclamada deja de ser cedible, por ejemplo). registerClaim nunca se envía dos veces a propósito: ver la política de reintentos. Prueba primero en certificación y conserva el resultado que obtuviste.
Las consultas#
| Función | Operación del SII | Devuelve |
|---|---|---|
listClaimEvents | listarEventosHistDoc | ClaimResult más events: { code, description, responsibleRut, date, dateIso } en el orden en que los lista el SII. responsibleRut es null cuando el SII no envía ninguno o envía uno inválido. |
queryTransferable | consultarDocDteCedible | ClaimResult más transferable: true para los códigos 23 y 25, false para 20, 21 y 24, null en otro caso. transferabilityMessage(code) es la glosa. |
queryReceptionDate | consultarFechaRecepcionSii | { receivedAt, raw }: cuándo recibió el SII el documento, como AAAA-MM-DDTHH:MM:SS, o null cuando la respuesta viene vacía o no es una fecha. |
receiverStatus | construida sobre listClaimEvents | { status, lastEvent, code }, donde status es 'accepted' (ACD), 'goodsReceived' (ERM), 'claimed' (RCD, RFP, RFT), 'paidInCash' (PAG, un documento que el SII marcó pagado al contado), 'none' (sin eventos, o solo NCA y ENC, los eventos de notas de crédito) o 'unknown'. Decide el último evento, por fecha y luego por posición. |
Una lista de eventos vacía es none para los codResp 15, 16 y 18 y unknown para cualquier otro código. Un tipo de documento distinto de 33, 34 y 43 es INVALID_DOCUMENT_TYPE, y un folio que no es un entero positivo es INVALID_FOLIO.
receiverAnswer#
receiverAnswer(listed, receivedAt, today) responde qué hizo el receptor, con la regla de los ocho días de la Ley 19.983. Es pura: no hace ninguna petición. Le pasas el resultado completo de listClaimEvents, el receivedAt de queryReceptionDate (o null) y la fecha de hoy en Chile como AAAA-MM-DD, que entrega chileDate().
import { chileDate, listClaimEvents, queryReceptionDate, receiverAnswer } from 'dte-sdk'
const listed = await listClaimEvents(sii, document)
const { receivedAt } = await queryReceptionDate(sii, document)
const { answer, claimDeadline } = receiverAnswer(listed, receivedAt, chileDate())
answer // 'pending' mientras el receptor no ha dicho nada y corren los ocho días, 'unknown' si la consulta falló
claimDeadline // '2026-11-13' para un documento que el SII recibió el 2026-11-05La respuesta es la primera que aplique, en este orden:
answer | Cuándo |
|---|---|
unknown | La consulta de eventos falló: sin eventos, y con un codResp distinto de 15, 16 o 18 (el error interno del SII, una empresa que no puede consultar el documento, un folio equivocado). Nada puede decirse. |
annulled | Hay un evento NCA: una nota de crédito anuló el documento. |
claimed | Hay un evento RCD, RFP o RFT. |
accepted | Hay un evento ACD. |
paidInCash | Hay un evento PAG ("DTE Pagado al Contado"): el SII marcó el documento pagado al contado al recibirlo, y el registro rechaza cualquier aceptación, reclamo o recibo de mercaderías sobre él con el código 27. No hay nada que responder, y claimDeadline es null. |
tacitlyAccepted | Ninguno de los anteriores, y hoy pasó claimDeadline: ocho días corridos después de que el SII recibió el documento, la ley lo da por aceptado. |
goodsReceived | Hay un evento ERM, con los ocho días aún corriendo. |
pending | Cualquier otro caso. |
claimDeadline es el último día para reclamar: el día de la recepción más ocho, como AAAA-MM-DD, o null cuando receivedAt es null y para paidInCash, que no admite reclamo. Sin él nada puede quedar aceptado tácitamente. A diferencia de receiverStatus, que lee solo el último evento, receiverAnswer mira todos los eventos, en cualquier orden. ENC no cambia nada. Un today que no es una fecha es INVALID_DATE.
Pasa el resultado completo, no sus events. unknown se lee como receiverStatus lee una lista vacía: sin eventos, solo los codResp 15, 16 y 18 listan eventos o dicen que no hay. Los eventos, cuando el SII envió alguno, mandan sea cual sea el código. Solo los eventos todavía sirven, pero no pueden decir que la consulta falló: una consulta fallida no da eventos, que se leen como pending y, pasados los ocho días, como tacitlyAccepted, un documento aceptado por ley porque el SII no respondió. Antes de la 0.7.0 receiverAnswer recibía solo los eventos, y eso es lo que hacía. ReceiverAnswer gana unknown, así que un switch exhaustivo sobre él necesita el caso.
Un documento pagado al contado no admite respuesta. Cuando una factura se emite pagada al contado, el SII le registra PAG al recibirla, y el registro rechaza desde entonces una aceptación, un reclamo o un recibo de mercaderías sobre ella con el código 27, como respondió producción a una aceptación el 11 de noviembre de 2026. Antes de la 0.8.0 receiverAnswer leía PAG como algo que no cambia nada, así que un documento así se leía como pending con ocho días para reclamar, y como tacitlyAccepted pasados esos días, invitando a una respuesta que el SII rechaza. Una anulación, un reclamo o una aceptación que tenga el registro sigue mandando primero, con su plazo. Si ofreces las acciones del registro a partir de la respuesta, no ofrezcas ninguna sobre paidInCash; si muestras claimDeadline, es null para él. Un documento emitido gratuito (Sin costo) se rechaza con el mismo código, pero todavía no se ha visto el listado de eventos de uno, así que no se sabe si trae PAG; mientras tanto se lee como antes. ReceiverAnswer y ReceiverStatus ganan paidInCash, así que un switch exhaustivo sobre cualquiera de los dos necesita el caso.
Registro de emisores autorizados#
queryAuthorizedIssuer(sii, { rut }) consulta el registro público de emisores electrónicos autorizados del SII (ee_empresa_rut, en maullin.sii.cl o palena.sii.cl según el ambiente) por un RUT. El registro no pide certificado ni sesión, y la llamada no cambia nada, así que sigue la política de reintentos completa.
import { queryAuthorizedIssuer } from 'dte-sdk'
const issuer = await queryAuthorizedIssuer(sii, { rut: '76543210-3' })
issuer.businessName // 'Empresa Ejemplo SpA'
issuer.resolutionNumber // 0 en certificación
issuer.resolutionDate // '2026-11-05'
issuer.documents[0] // { type: 33, description: 'Factura Electrónica', authorizedFrom: '2026-11-05', deauthorizedOn: null }Devuelve un AuthorizedIssuer, un IssuerRegistryEntry más el rut consultado:
| Campo | Tipo | Significado |
|---|---|---|
rut | SiiRut | El RUT consultado. |
businessName | string | null | Razón social. |
resolutionNumber | number | null | Número de la resolución que autoriza al emisor en este ambiente: el NroResol de la carátula. En certificación es 0. |
resolutionDate | string | null | Fecha de esa resolución, AAAA-MM-DD: el FchResol de la carátula. |
regionalOffice | string | null | Dirección regional del SII, en números romanos. No es la dirección de la empresa. |
documents | AuthorizedDocumentType[] | Cada tipo de documento listado, autorizado ahora o antes. |
Cada AuthorizedDocumentType trae type (el tipo de documento, 30 o más), description (su nombre tal como lo muestra el registro), authorizedFrom (el día en que se autorizó) y deauthorizedOn (el día en que dejó de estar autorizado, o null mientras lo siga). La página del registro es una tabla de un CGI antiguo: una etiqueta que se movió deja su campo en null en vez de adivinar.
Los errores:
| Código | Cuándo |
|---|---|
INVALID_RUT | El RUT no es válido. |
SII_PORTAL_REJECTED | El SII dice que el RUT no es emisor autorizado: details.rejection es 'companyNotAuthorized'. |
SII_PORTAL_UNEXPECTED_PAGE | La respuesta no trae ni razón social ni tipos de documento. |
SII_HTTP_ERROR y los códigos de transporte | La petición falló. |
queryAuthorizedIssuer necesita solo un ambiente y con qué enviar, no un cliente completo: sirve cualquier objeto con environment, transport, capture, logger, now, retry y sleep (el tipo es IssuerRegistryClient). Calza un SiiClient, y también el cliente del portal. El timeout por defecto es de 20 segundos; el último argumento acepta el { signal, timeoutMs, retry } de siempre.
Transporte#
El transporte es el puerto HTTP: una función de HttpRequest (método, URL, cabeceras, bytes del cuerpo, timeout y signal) a HttpResponse (estado, cabeceras en minúsculas, bytes del cuerpo). Cualquier estado HTTP es una respuesta; solo se lanzan las fallas de red, TLS, timeout y cancelación. El cliente trabaja en bytes y decodifica cada canal con su propio charset: ISO-8859-1 para SOAP de DTEWS y el CGI DTEUpload, UTF-8 para la API REST y WSRECLAMO. Nunca adivina.
createFetchTransport() es el transporte por defecto. Usa globalThis.fetch (Node, Bun, Deno) o el fetch que pases como { fetch }, y no sigue redirecciones: el SII nunca redirige una llamada bien formada, y seguir una ocultaría una página de error. Sin un fetch, falla con TRANSPORT_UNAVAILABLE.
Para controlar TLS, usa el adaptador de Node de dte-sdk/node. Carga node:https en el primer uso, nunca al importar.
import { readFileSync } from 'node:fs'
import { createSiiClient } from 'dte-sdk'
import { createNodeHttpsTransport } from 'dte-sdk/node'
const sii = createSiiClient({
environment: 'production',
certificate,
companyRut: '76543210-3',
transport: createNodeHttpsTransport({
tls: { ca: readFileSync('corporate-proxy-ca.pem', 'utf8') },
}),
})La verificación TLS está activada por defecto, y los hosts del SII verifican contra las raíces públicas en que Node confía por defecto, así que los valores por defecto deberían bastar. Las opciones de tls son salidas de emergencia, todas opt-in:
| Opción | Efecto |
|---|---|
ca | CA adicionales (PEM, un string o una lista). Se suman a las raíces públicas incluidas en Node, no las reemplazan: la solución correcta para un proxy corporativo o una CA privada. |
minVersion, maxVersion | Fijan la versión de TLS ('TLSv1.2' o 'TLSv1.3'). |
rejectUnauthorized | false desactiva la verificación del certificado del servidor. Solo si no hay otra salida, y nunca en producción. |
legacyRenegotiation | true permite la renegociación legacy insegura. Ningún host del SII la necesita hoy. |
clientCertificate | Un certificado cliente para TLS mutuo; es lo que usa el portal. No se necesita para los servicios web. |
keepAlive (por defecto true) reutiliza sockets. Una falla de TLS es TRANSPORT_TLS y details.reason trae el código de OpenSSL o del sistema operativo (por ejemplo UNABLE_TO_GET_ISSUER_CERT_LOCALLY), así ves qué falló en vez de un mensaje genérico. Lee Seguridad antes de desactivar la verificación.
También puedes escribir tu propio transporte, para un proxy, un doble de pruebas o un runtime con su propia pila HTTP. Una regla importa: una falla que ocurre después de que llegaron las cabeceras de la respuesta (mientras se leía el cuerpo) debe traer details.responseStarted: true, porque le dice al cliente que el SII respondió, y un envío o un reclamo ya no se vuelve a enviar.
La política de reintentos#
DEFAULT_RETRY_POLICY es:
| Campo | Por defecto | Significado |
|---|---|---|
maxAttempts | 6 | Intentos totales, incluido el primero (1 significa sin reintento). |
initialDelayMs | 2000 | Espera antes del segundo intento. |
backoffMultiplier | 1.8 | Factor de crecimiento entre intentos. |
maxDelayMs | 15000 | Tope de cualquier espera. |
jitterRatio | 0.2 | Jitter simétrico: más o menos 20 % de la espera. |
retryOn | isRetryableError | Qué errores reintentar. |
Las esperas crecen como 2 s, 3,6 s, 6,5 s, 11,7 s y 15 s (con tope), antes del jitter. Una cabecera Retry-After o X-Retry-After del SII reemplaza la espera calculada, siempre con tope maxDelayMs. El error que finalmente sale trae details.attempts.
isRetryableError(error) es true para TRANSPORT_FAILED, REQUEST_TIMEOUT, SII_RESPONSE_INVALID, SII_QUERY_ERROR, SII_HTTP_ERROR con estado HTTP 429, 405 o 5xx, y SII_UPLOAD_REJECTED con status 9 ("sistema bloqueado"). Es false para todo lo demás, incluidos TRANSPORT_TLS y SII_TOKEN_INVALID.
const sii = createSiiClient({
environment: 'certification',
certificate,
companyRut: '76543210-3',
retry: { maxAttempts: 3, initialDelayMs: 1000 }, // se mezcla sobre los valores por defecto
})
const strict = createSiiClient({ environment: 'certification', certificate, retry: false }) // sin reintentosLas llamadas que cambian estado nunca se envían dos veces#
Un envío (uploadEnvelope, uploadBook, uploadFolioConsumption) y registerClaim cambian el estado en el SII. Si la respuesta se perdió y la petición se envía de nuevo, el SII podría quedar con dos copias, o con dos reclamos. Por eso estas llamadas aplican una segunda regla, más estricta, encima de la política: se reenvían solo mientras no haya llegado respuesta del SII, es decir, para TRANSPORT_FAILED y REQUEST_TIMEOUT ocurridos antes de las cabeceras de la respuesta.
- Una vez que el SII respondió, cualquiera sea el estado HTTP o el código, e incluso si solo llegó parte de la respuesta (
details.responseStarted: true), la llamada no se vuelve a enviar. Una segunda copia es justamente el duplicado del que el cliente tendría que recuperarse. - La única excepción es un token rechazado (
STATUS5, HTTP 401,SII_TOKEN_INVALID): el SII no recibió el archivo, así que la llamada se ejecuta una vez más con un token nuevo. retryOnsolo puede restringir. La llamada reintenta únicamente cuando su propia regla y elretryOnde la política coinciden.retryOn: () => falsenunca reenvía; ningúnretryOnpuede lograr que un envío se reenvíe después de una respuesta.
Las consultas son de solo lectura y siguen la política completa. Obtener un token es un solo ciclo de reintentos alrededor de semilla, firma y token. waitForUploadStatus reemplaza la política por su intervalo de sondeo, como se describió arriba.
Captura#
capture recibe cada intento HTTP, ya con los secretos tachados, para que conserves evidencia para un ticket de soporte del SII. Cada HttpExchange trae el nombre de la operación, el canal ('soap', 'rest' o 'portal'), el número de intento, la petición y la respuesta (o la falla), el instante de inicio y la duración. Una captura que lanza se registra y nunca hace fallar la operación.
import { createMemoryCapture } from 'dte-sdk'
import { createDirectoryCapture } from 'dte-sdk/node'
const memory = createMemoryCapture()
// createSiiClient({ …, capture: memory.capture })
memory.exchanges() // una copia de todo lo capturado hasta ahora
// En disco (Node): un archivo JSON por intercambio más un index.jsonl
const onDisk = createDirectoryCapture({ dir: './sii-capture', prefix: 'worker1-' })createDirectoryCapture({ dir, prefix?, logger? }) escribe <dir>/<prefijo><seq>-<operación>-<estado o ERR>.json por cada intercambio, crea el directorio en el primer uso y agrega una línea a <dir>/index.jsonl. El prefix separa réplicas que comparten un volumen.
Qué se tacha. El cliente tacha el intercambio antes de que llegue al puerto (redactExchange se exporta, para tus propios adaptadores):
- el contenido de los elementos
TOKEN,SEMILLAyRSASK, en cualquier caja, con o sin prefijo de espacio de nombres, crudos o escapados en XML, y también cuando el texto quedó cortado antes de la etiqueta de cierre; - las asignaciones
TOKEN=y las cookies de sesión del portal (NETSCAPE_LIVEWIRE…,CSESSIONID); - el
conversationIddel portal en JSON; - los bloques de llave privada PEM;
- los valores de las cabeceras
Cookie,Set-Cookie,AuthorizationyProxy-Authorization.
Los cuerpos se cortan a 512 KiB de UTF-8, con un sufijo que dice cuántos bytes se descartaron. Los RUT y el XML se conservan, porque el soporte del SII los necesita para diagnosticar un envío: una captura sigue conteniendo datos personales y comerciales. Protege el directorio en consecuencia.
Archivo de envíos#
archive conserva los bytes exactos de cada envío que el SII respondió, junto con su resultado, para que puedas demostrar qué enviaste y recuperarte después de una caída. Se llama para envíos aceptados, para duplicados y para rechazos.
import { createMemoryUploadArchive } from 'dte-sdk'
import { createDirectoryUploadArchive } from 'dte-sdk/node'
const { archive, records } = createMemoryUploadArchive()
// createSiiClient({ …, archive })
const onDisk = createDirectoryUploadArchive({ dir: './sii-uploads' })
// createSiiClient({ …, archive: onDisk })Cada UploadRecord trae el ambiente, el tipo, el canal, el trackId (o null en un rechazo), duplicate, los RUT de la empresa y de quien envía, el nombre de archivo, el instante de envío (ISO 8601, UTC), los bytes exactos enviados (xml) y la respuesta (httpStatus, el code del SII y raw con tokens y semillas tachados).
createDirectoryUploadArchive({ dir, logger? }) escribe los bytes como <nombre>.xml y el registro sin ellos como <nombre>.json, bajo <dir>/<ambiente>/<aaaa-mm>/. El nombre es:
| Caso | Nombre |
|---|---|
| Aceptado | <trackId>-<kind> |
| Respuesta de duplicado | <trackId>-<kind>-duplicate-<aaaammddHHMMSS> |
| Rechazado, sin TrackID | rejected-<aaaammddHHMMSS>-<kind> |
Un duplicado nombra el TrackID del envío original, por eso recibe su propio nombre con marca de tiempo y nunca reemplaza el registro de ese envío. La marca de tiempo es el instante de envío en UTC. Un archivo que falla se registra y nunca hace fallar el envío: el SII ya tiene el archivo, y tú necesitas su TrackID o su error, no el del puerto. El adaptador de directorio no restringe los permisos de los archivos más allá de los valores por defecto de tu proceso; ver Seguridad.
Logger#
logger es un puerto con cuatro funciones, debug, info, warn y error, cada una (message, context?). Por defecto es silentLogger; pasa console o el tuyo. Los mensajes son strings fijos como 'sii http', 'sii retrying' y 'sii token obtained', y el contexto trae nombres de operación, números de intento, estados HTTP, códigos del SII y duraciones. Los mensajes nunca traen tokens, RUT ni XML.
const sii = createSiiClient({ environment: 'certification', certificate, logger: console })Utilidades de bajo nivel#
Para tus propios adaptadores y pruebas, se exportan los bloques de construcción:
buildSoapEnvelope({ method, params, namespace? })arma un sobre SOAP 1.1 (UTF-8). Los parámetros van en el orden que declara el WSDL, porque el servicio los asocia por posición, no por nombre.parseSoapResponse(xml)devuelve{ fault, returnValue, returnXml }sin un parseo XML completo: las respuestas del SII son planas y a veces traen entidades sueltas.buildMultipartBody({ boundary, fields, file })arma el formulario multipart de un envío, con los bytes del archivo copiados sin cambios.signSeed,tokenCacheKeyyredactExchange, descritas arriba.