SII web services
The web services client talks to the SII over HTTP: it gets and caches tokens, uploads signed envelopes, books and folio consumption reports, queries the status of uploads and documents, and registers acceptances and claims on received documents. It is plain data plus free functions, with an injectable transport, a retry policy that knows which calls are safe to repeat, and ports for capturing HTTP exchanges and archiving every upload.
dte-sdk 0.8.0 is on npm, the latest release. It is not recommended for
production yet: most areas have not met the real SII, and until 1.0.0 the API
can change in any release. Of the web services client, only the seed and
token over SOAP and the acceptance and claim registry have met the real SII,
and only in production, on 9 and 10 November 2026. Uploads, status queries
and the receipts REST API have not run in either environment yet (tracked in
issue 8), and the registry of
authorized issuers has no run on record. All of it is tested against
synthetic SII answers with a 100 % coverage gate. See Status.
Usage#
Create one client per environment and certificate, then pass it as the first argument of every operation.
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` is the result of signEnvelope(): see /docs/features/envelopes
const receipt = await uploadEnvelope(sii, { envelope })
receipt.trackId // digits only: keep it, it is how you ask for the result
receipt.duplicate // true when the SII already had this file and returned the original TrackID
const status = await waitForUploadStatus(sii, { trackId: receipt.trackId })
status.status // 'accepted', 'acceptedWithObjections' or 'rejected' (never 'pending' here)
status.message // Spanish gloss: the SII's own, else the catalog's
// As the receiver of a purchase invoice: accept its content.
const claim = await registerClaim(sii, {
issuerRut: '10000004-0',
type: 33,
folio: 1234,
action: 'acceptContent',
})
if (!claim.ok) console.log(claimResponseMessage(claim.code)) // business answers are codes, not errorsA TrackID means the SII received the file, not that it accepted the documents. Acceptance comes later and is read with queryUploadStatus or waitForUploadStatus.
The client#
createSiiClient(options) validates the options and returns a frozen object: plain data, no methods, no hidden state. Every operation is a free function that takes it first: uploadEnvelope(sii, …), queryUploadStatus(sii, …), registerClaim(sii, …). Two clients never share anything unless you hand them the same tokenCache.
| Option | Default | What it does |
|---|---|---|
environment | required | 'certification' (host maullin.sii.cl) or 'production' (host palena.sii.cl). Anything else is INVALID_OPTION { field: 'environment' }. |
certificate | required | The certificate that signs the seed. Without it: MISSING_FIELD { field: 'certificate' }. |
companyRut | none | RUT of the company the operations are about. Each call can override it; if neither says, the call fails with MISSING_FIELD { field: 'companyRut' }. |
senderRut | holder of the certificate | RUT of whoever sends (RutEnvia). A certificate without a readable RUT needs this: MISSING_FIELD { field: 'senderRut' }. |
transport | createFetchTransport() | The HTTP port. See Transport. |
tokenCache | createMemoryTokenCache() | Where tokens live: in memory by default, or on disk with createDirectoryTokenCache from dte-sdk/node. See Tokens. |
tokenTtlMs | 3 300 000 (55 min) | How long a token stays in the cache. |
retry | the defaults of the retry policy | A partial policy that merges over the defaults, or false to turn retries off. |
timeouts | { soap: 30000, rest: 15000, upload: 60000 } | Milliseconds per channel: DTEWS SOAP and WSRECLAMO calls, receipts REST calls, uploads on either channel. |
receiptsChannel | 'rest' in production, 'soap' in certification | Channel for EnvioBOLETA uploads. EnvioDTE always goes through DTEUpload. |
logger | silentLogger | See Logger. |
capture | off | See Capture. |
archive | off | See Archive. |
endpoints | the catalogs below | { soap, rest, claims } overrides, for a proxy or a test double. |
now | () => new Date() | The clock. |
sleep | a timer that the caller's signal can cancel | The wait between retries and between status polls. |
Every number must be positive and finite, the retry policy is validated (maxAttempts an integer of 1 or more, jitterRatio from 0 to 1), and RUTs go through rut.ts. A bad value is INVALID_OPTION { field } (for example field: 'timeouts.soap' or 'retry.maxAttempts') or INVALID_RUT { field }.
Per-call overrides#
Every operation accepts { signal, timeoutMs, retry } as its last argument:
const status = await queryUploadStatus(
sii,
{ trackId: '0000000001' },
{ signal: AbortSignal.timeout(20_000), timeoutMs: 8_000, retry: { maxAttempts: 2 } },
)signal cancels the call with REQUEST_ABORTED, timeoutMs replaces the timeout of the channel for this call, and retry merges over the client's policy (or false turns retries off for the call).
The services and where they live#
The client reaches four services. All of them use TLS and the hosts below.
| Service | Certification | Production | Used by |
|---|---|---|---|
DTEWS SOAP (CrSeed, GetTokenFromSeed, QueryEstUp, QueryEstDte) and the DTEUpload CGI | maullin.sii.cl | palena.sii.cl | seeds, tokens, uploads of envelopes, books and reports, status queries |
| Receipts REST API | apicert.sii.cl (seed, token, status) and pangal.sii.cl (upload) | api.sii.cl and rahue.sii.cl | EnvioBOLETA uploads and receipt status |
| WSRECLAMO (acceptance and claim registry) | ws2.sii.cl | ws1.sii.cl | registerClaim and the queries around it |
The catalogs behind it are exported, in case you need a URL: SII_HOSTS, SOAP_ENDPOINTS, REST_ENDPOINTS, CLAIM_WS_ENDPOINTS, CLAIM_WSDL_ENDPOINTS (the WSDL of the registry) and UPLOAD_USER_AGENT. The upload CGI rejects modern browser User-Agent strings, so the client sends the fixed value in UPLOAD_USER_AGENT on uploads.
import { SOAP_ENDPOINTS } from 'dte-sdk'
SOAP_ENDPOINTS.certification.upload // 'https://maullin.sii.cl/cgi_dte/UPL/DTEUpload'Tokens#
The SII authenticates with a seed that you sign and exchange for a token. You do not have to do it by hand: every operation that needs a token calls getToken, which returns the cached one while it has not expired and otherwise runs the whole handshake.
import { getToken, invalidateToken } from 'dte-sdk'
const token = await getToken(sii, 'soap') // cached for tokenTtlMs
await invalidateToken(sii, 'soap') // the next operation authenticates again- Two channels, two tokens.
'soap'serves DTEWS (DTEUpload,QueryEst*) and WSRECLAMO;'rest'serves the receipts API. A DTEWS token and a REST token are different things and never replace each other. getSeed(sii, channel)asks for a seed: a string of digits (12 in practice) that the SII keeps valid for two minutes. It is never cached.signSeed(seed, certificate)is pure and synchronous: it builds<getToken><item><Semilla>…</Semilla></item><Signature…/></getToken>with the XML declaration, one line break and no trailing newline, the layout the REST endpoint is strict about. A seed that is not a string of digits isINVALID_OPTION { field: 'seed' }: it travels as a string because it keeps its leading zeros.- One cache per environment, channel and certificate. The key is
tokenCacheKey(environment, channel, certificate), that is<environment>:<channel>:<sha-1 of the certificate>. Two companies, two environments or two channels never share a token, and the key does not expose the holder. - A token the SII refuses renews itself, once. When a call fails with
SII_TOKEN_INVALID, the cached token is dropped, a new one is obtained and the call runs once more. If the SII refuses the fresh token too, that token is dropped as well and the error surfaces withdetails.attempts: 2. - The handshake is one retry loop around seed, signature and token: every attempt signs a fresh seed, and
maxAttemptsbounds seed and token together. Failures areSII_SEED_FAILED { code, channel }andSII_TOKEN_FAILED { code, channel }.
createMemoryTokenCache() keeps tokens per process, and it is the default. To keep them on disk, use createDirectoryTokenCache({ dir, logger }) from 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' }),
})It writes one file per key, readable only by its owner (on Windows the permissions of dir decide), so a restart and the other processes of the same user reuse the token instead of running the handshake again. It also serves as the sessionCache of the portal client, whose entries hold session cookies. Give it a directory of its own. It never throws: an entry it cannot read or write is a miss, reported only to logger, never with the token.
To share tokens across machines, implement the TokenCache port (get, set, delete, all asynchronous, over a CachedToken of { token, expiresAt } in epoch milliseconds) on Redis, for example, and pass it as tokenCache. A token is a credential: protect the store like one.
Uploads#
Three functions upload a file and return an UploadReceipt. They all send the XML as ISO-8859-1 bytes in a multipart form, with rutSender, dvSender, rutCompany, dvCompany and the file in the part archivo.
import { uploadBook, uploadEnvelope, uploadFolioConsumption } from 'dte-sdk'
await uploadEnvelope(sii, { envelope }) // EnvioDTE or EnvioBOLETA
await uploadBook(sii, { xml: signedBook.xml }) // LibroCompraVenta or LibroGuia
await uploadFolioConsumption(sii, { xml: signedReport.xml }) // ConsumoFolios (RCOF)Which channel#
| What | Channel | Notes |
|---|---|---|
EnvioDTE | DTEUpload (SOAP side), always | Passing channel: 'rest' is INVALID_OPTION { field: 'channel' }. |
EnvioBOLETA in production | receipts REST API | The default of receiptsChannel. |
EnvioBOLETA in certification | DTEUpload | The default of receiptsChannel. |
| Books and folio consumption reports | DTEUpload, always |
uploadEnvelope takes { envelope, companyRut?, senderRut?, channel? }. The envelope is a SignedEnvelope from signEnvelope or just { kind, xml } for XML produced somewhere else. The REST channel takes at most 500 receipts per envelope: more is refused before anything is sent, as SII_UPLOAD_REJECTED { status: 'TOO_MANY', count }.
uploadBook and uploadFolioConsumption take { xml, companyRut?, senderRut?, fileName? }. The file name is what the SII sees in the multipart part: EnvioDTE.xml, EnvioBOLETA.xml, LibroGuia.xml for a guide book (detected from its root element), LibroCV.xml for any other book and ConsumoFolios.xml for a report. fileName overrides it, but only letters, digits, _, . and -, ending in .xml; anything else is INVALID_OPTION { field: 'fileName' }.
The XML must say it is ISO-8859-1#
The bytes that travel are ISO-8859-1, so the XML has to declare that encoding. With another encoding, or none (XML then defaults to UTF-8), the SII would read a different text than the one that was signed. The declaration must be the first thing in the file: whitespace before it is dropped, and anything else that does not open with <?xml … encoding="ISO-8859-1" …?> (any case, single or double quotes) is refused with INVALID_OPTION { field: 'xml' } before anything is sent. The envelopes and books this SDK builds already comply. A character outside ISO-8859-1 is ENCODING_UNSUPPORTED_CHAR.
The receipt#
type UploadReceipt = {
trackId: string // digits only
receivedAt: string // 'YYYY-MM-DDTHH:MM:SS' Chile time (the client clock when the SII omits it)
kind: 'dte' | 'receipt' | 'book' | 'folioConsumption'
channel: 'soap' | 'rest'
fileName: string
duplicate: boolean
raw: string // the SII answer as received
}raw is the answer exactly as it arrived. The archive port receives a copy with secrets redacted; raw is not redacted, so do not log it blindly.
Duplicates#
If the SII says it already has the file (STATUS 99, or a STATUS 7 whose errors are not schema or charset errors and that names a TrackID), the upload does not fail: it returns the original TrackID with duplicate: true. That makes it safe to run the same upload twice, for instance after a crash that lost the first answer. The archive keeps such an answer under a different name, so it never overwrites the record of the original upload.
Rejections#
Anything else the SII does not accept is a SII_UPLOAD_REJECTED whose details hold status (the STATUS of DTEUpload, as a number), errors (the lines of DETAIL/ERROR, entities decoded), fileName, operation and message (the Spanish gloss of the status). The STATUS codes are in Errors, reception statuses.
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
}
}Status#
All the status functions are read-only, so they follow the full retry policy.
Upload status#
queryUploadStatus(sii, { trackId, companyRut?, channel? }) asks the SII what happened to an upload. The TrackID must be digits only (it is placed in a URL and in a SOAP parameter): anything else is INVALID_OPTION { field: 'trackId' }. channel is 'soap' by default (QueryEstUp) and 'rest' for receipts uploaded through the REST API.
type UploadStatus = {
code: string // 'EPR', 'RPR', 'RCH', 'REC', …
status: 'accepted' | 'acceptedWithObjections' | 'pending' | 'rejected' | 'queryError' | 'unknown'
message: string // the SII's gloss, else the catalog's
trackId: string
channel: 'soap' | 'rest'
counts: { reported: number; accepted: number; rejected: number; objections: number } | null
documentType: string | null // SOAP only
documents: UploadStatusDocument[] // REST only: per document, with its errors
raw: string
}status comes from classifyUploadStatus(code, channel). The channel matters for one code: RLV is a rejection on SOAP and success on REST. The negative codes -1 to -14 are errors of the query service, not of your envelope, so they throw SII_QUERY_ERROR { code } and are retried. A token the SII refuses (-3, -13, 001 to 003, HTTP 401) renews the token once.
Waiting for the final answer#
import { waitForUploadStatus } from 'dte-sdk'
const status = await waitForUploadStatus(
sii,
{ trackId: receipt.trackId, intervalMs: 10_000, timeoutMs: 15 * 60_000 },
{ signal: controller.signal },
)waitForUploadStatus polls every intervalMs (5 000 by default) until the status stops being pending, up to timeoutMs (600 000 by default). Its rules:
- The interval replaces the retry policy. Each poll is one request; the
retryof the client and of the call are ignored. A failure thatisRetryableErroraccepts (query errors, transport failures, timeouts, HTTP 429/405/5xx, unreadable pages) counts as "still pending", so a flaky query service does not end the wait. Any other error (an invalid token, an HTTP 4xx, a failed handshake) ends it at once. timeoutMsis a budget. The timeout of each poll is the smaller of the normal timeout and the time that remains, and so is each sleep. The wait ends attimeoutMs, not after a retry policy has run its course on every poll.- How it ends. With the
UploadStatusthat is not pending; withWAIT_TIMEOUT { trackId, attempts, lastCode }when the budget runs out (lastCodeis the last status code, query-error code or error code, so it tells you why); withREQUEST_ABORTEDwhen yoursignalfires. - It uses the client's
nowandsleep, which makes it testable without waiting.
Document status#
queryDocumentStatus(sii, input) asks about one document: QueryEstDte over SOAP for every type except receipts, and the REST per-document endpoint for 39 and 41 (channel overrides). The REST API answers about ten minutes after the upload.
import { queryDocumentStatus } from 'dte-sdk'
const document = await queryDocumentStatus(sii, {
receiverRut: '12345678-5',
type: 33,
folio: 1,
issueDate: '2026-11-05',
totalAmount: 119000, // rounded to a whole number
})
document.code // 'DOK'
document.status // 'received'
document.message // 'Documento recibido por el SII, datos coinciden'companyRut defaults to the client's. consultantRut (SOAP only) defaults to the sender, that is whoever authenticates, not the company. The date is sent as DDMMYYYY on SOAP and DD-MM-YYYY on REST. Invalid input is INVALID_DOCUMENT_TYPE, INVALID_RUT, INVALID_FOLIO, INVALID_AMOUNT or INVALID_DATE. The DocumentStatus.status classes are in Errors, document statuses.
Is this folio already used?#
folioReceived(sii, { type, folio, companyRut? }) answers whether the SII already holds a document with that type and folio. It asks QueryEstDte as the company itself, with the final-consumer receiver, today's date in Chile and an amount of 1, and reads the answer from the code and the gloss alone:
| Result | Meaning |
|---|---|
false | The folio is free (FAU, or a gloss that says "no recibido"). |
true | The folio is used (a gloss that says "recibido"). |
null | Undetermined: the caller decides. |
Use it before reusing a folio that you got again from a CAF: the SII rejects a repeated one.
Acceptance and claim registry#
The registry (WSRECLAMO) is where the receiver of an invoice accepts or claims it. It accepts document types 33, 34 and 43, authenticates with the DTEWS token and has its own SOAP vocabulary; the SDK hides it behind five functions, a summary and a pure helper that applies the eight-day rule.
The three types are data in the package: CLAIM_DOCUMENT_TYPES lists them and isClaimDocumentType(type) is its type guard, so you can pick the documents the registry takes, for one among the received documents you checked with verifyReceivedDocuments. Any other type is INVALID_DOCUMENT_TYPE, thrown before anything is sent.
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#
Registers an action on a received document. The action is the English name of a code in CLAIM_ACTIONS:
action | SII code | Meaning |
|---|---|---|
acceptContent | ACD | Accepts the content of the document. |
claimContent | RCD | Claims the content of the document. |
acknowledgeGoods | ERM | Acknowledges receipt of goods or services. |
claimPartialShortfall | RFP | Claims a partial shortfall of goods. |
claimTotalShortfall | RFT | Claims a total shortfall of goods. |
Any other value is INVALID_OPTION { field: 'action' }.
Business answers are codes, not errors. The SII answers situations such as "already claimed" or "more than 8 days since reception" with a code. registerClaim returns them as data:
type ClaimResult = {
code: number // codResp: 0 ok, 1 to 19 and 27 business answers, -1 internal error
description: string // descResp, up to 70 characters
ok: boolean // code === 0
raw: string // inner XML of the answer
}claimResponseMessage(code) gives the Spanish gloss of any code (the full list is in Errors, claim codes). Only what is outside the protocol is thrown: a SOAP fault (SII_SOAP_FAULT), an unreadable answer (SII_RESPONSE_INVALID), an HTTP error, a refused token, a transport failure.
A claim changes the state of the document at the SII and, in production, the real one (a claimed invoice stops being transferable, for example). registerClaim is never sent twice on purpose: see the retry policy. Test in certification first, and keep the result you got.
The queries#
| Function | SII operation | Returns |
|---|---|---|
listClaimEvents | listarEventosHistDoc | ClaimResult plus events: { code, description, responsibleRut, date, dateIso } in the order the SII lists them. responsibleRut is null when the SII sends none or an invalid one. |
queryTransferable | consultarDocDteCedible | ClaimResult plus transferable: true for codes 23 and 25, false for 20, 21 and 24, null otherwise. transferabilityMessage(code) is the gloss. |
queryReceptionDate | consultarFechaRecepcionSii | { receivedAt, raw }: when the SII received the document, as YYYY-MM-DDTHH:MM:SS, or null when the answer is empty or not a date. |
receiverStatus | built on listClaimEvents | { status, lastEvent, code }, where status is 'accepted' (ACD), 'goodsReceived' (ERM), 'claimed' (RCD, RFP, RFT), 'paidInCash' (PAG, a document the SII marked paid in cash), 'none' (no events, or only NCA and ENC, the credit-note events) or 'unknown'. The latest event decides, by date and then by position. |
An empty list of events is none for codResp 15, 16 and 18 and unknown for any other code. Document types outside 33, 34 and 43 are INVALID_DOCUMENT_TYPE, and a folio that is not a positive integer is INVALID_FOLIO.
receiverAnswer#
receiverAnswer(listed, receivedAt, today) answers what the receiver did, with the eight-day rule of Ley 19.983. It is pure: it makes no request. You give it the whole result of listClaimEvents, the receivedAt of queryReceptionDate (or null) and today in Chile as YYYY-MM-DD, which chileDate() gives.
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' while the receiver has said nothing and the eight days run, 'unknown' if the query failed
claimDeadline // '2026-11-13' for a document the SII received on 2026-11-05The answer is the first that applies, in this order:
answer | When |
|---|---|
unknown | The events query failed: no events, and a codResp other than 15, 16 or 18 (the SII's internal error, a company that may not consult the document, a wrong folio). Nothing can be said. |
annulled | There is an NCA event: a credit note annulled the document. |
claimed | There is an RCD, RFP or RFT event. |
accepted | There is an ACD event. |
paidInCash | There is a PAG event ("DTE Pagado al Contado"): the SII marked the document paid in cash as it received it, and the registry refuses any acceptance, claim or goods received on it with code 27. There is nothing to answer, and claimDeadline is null. |
tacitlyAccepted | None of the above, and today is past claimDeadline: eight calendar days after the SII received the document, the law takes it as accepted. |
goodsReceived | An ERM event, with the eight days still running. |
pending | Anything else. |
claimDeadline is the last day to claim: the day of reception plus eight, as YYYY-MM-DD, or null when receivedAt is null and for paidInCash, which takes no claim. Without it nothing can be tacitly accepted. Unlike receiverStatus, which reads the latest event only, receiverAnswer looks at all the events, in any order. ENC changes nothing. A today that is not a date is INVALID_DATE.
Pass the whole result, not its events. unknown is read as receiverStatus reads an empty list: with no events, only codResp 15, 16 and 18 list events or say there are none. Events, when the SII sent some, speak whatever the code. The events alone still work, but cannot tell a failed query: a query that failed gives no events, which read as pending and, past the eight days, as tacitlyAccepted, a document accepted by law because the SII did not answer. Before 0.7.0 receiverAnswer took only the events, and that is what it did. ReceiverAnswer gains unknown, so an exhaustive switch on it needs the case.
A document paid in cash takes no answer. When an invoice is issued as paid in cash, the SII registers PAG on it as it receives it, and the registry then refuses an acceptance, a claim or goods received on it with code 27, as production answered an acceptance on 11 November 2026. Before 0.8.0 receiverAnswer read PAG as changing nothing, so such a document read as pending with eight days to claim, and as tacitlyAccepted after them, inviting an answer the SII refuses. An annulment, a claim or an acceptance the registry holds still comes first, with its deadline. If you offer the registry's actions from the answer, offer none on paidInCash; if you show claimDeadline, it is null for it. A document issued as free (Sin costo) is refused with the same code, but no events listing of one has been seen, so whether it carries PAG is not known; until then it reads as before. ReceiverAnswer and ReceiverStatus gain paidInCash, so an exhaustive switch on either needs the case.
Authorized issuer registry#
queryAuthorizedIssuer(sii, { rut }) asks the public registry of authorized electronic issuers of the SII (ee_empresa_rut, on maullin.sii.cl or palena.sii.cl by environment) about a RUT. The registry asks for no certificate and no session, and the call changes nothing, so it follows the full retry policy.
import { queryAuthorizedIssuer } from 'dte-sdk'
const issuer = await queryAuthorizedIssuer(sii, { rut: '76543210-3' })
issuer.businessName // 'Empresa Ejemplo SpA'
issuer.resolutionNumber // 0 in certification
issuer.resolutionDate // '2026-11-05'
issuer.documents[0] // { type: 33, description: 'Factura Electrónica', authorizedFrom: '2026-11-05', deauthorizedOn: null }It returns an AuthorizedIssuer, an IssuerRegistryEntry plus the rut asked for:
| Field | Type | Meaning |
|---|---|---|
rut | SiiRut | The RUT asked for. |
businessName | string | null | Business name. |
resolutionNumber | number | null | Number of the resolution that authorizes the issuer in this environment: the NroResol of the cover. It is 0 in certification. |
resolutionDate | string | null | Date of that resolution, YYYY-MM-DD: the FchResol of the cover. |
regionalOffice | string | null | Regional office of the SII, as a Roman numeral. It is not the address of the company. |
documents | AuthorizedDocumentType[] | Each document type listed, authorized now or before. |
Each AuthorizedDocumentType has type (the document type, 30 or more), description (its name as the registry shows it), authorizedFrom (the day it was authorized) and deauthorizedOn (the day it stopped being authorized, or null while it still is). The registry page is an old CGI table: a label that moved leaves its field null instead of a guess.
The errors:
| Code | When |
|---|---|
INVALID_RUT | The RUT is not valid. |
SII_PORTAL_REJECTED | The SII says the RUT is not an authorized issuer: details.rejection is 'companyNotAuthorized'. |
SII_PORTAL_UNEXPECTED_PAGE | The answer has neither a business name nor document types. |
SII_HTTP_ERROR and the transport codes | The request failed. |
queryAuthorizedIssuer needs only an environment and the means to send, not a whole client: any object with environment, transport, capture, logger, now, retry and sleep works (the type is IssuerRegistryClient). A SiiClient fits, and so does the client of the portal. The default timeout is 20 seconds; the last argument takes the usual { signal, timeoutMs, retry }.
Transport#
The transport is the HTTP port: a function from HttpRequest (method, URL, headers, body bytes, timeout and signal) to HttpResponse (status, lower-cased headers, body bytes). Any HTTP status is a response; only network, TLS, timeout and abort failures are thrown. The client works in bytes and decodes each channel with its own charset: ISO-8859-1 for DTEWS SOAP and the DTEUpload CGI, UTF-8 for the REST API and WSRECLAMO. It never guesses.
createFetchTransport() is the default. It uses globalThis.fetch (Node, Bun, Deno) or the fetch you pass as { fetch }, and it does not follow redirects: the SII never redirects a well-formed call, and following one would hide an error page. Without a fetch, it fails with TRANSPORT_UNAVAILABLE.
For control over TLS, use the Node adapter from dte-sdk/node. It loads node:https on first use, never at import.
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') },
}),
})TLS verification is on by default, and the SII hosts verify against the public roots that Node trusts by default, so the defaults should just work. The knobs of tls are escape hatches, each opt-in:
| Option | Effect |
|---|---|
ca | Extra CA certificates (PEM, one string or a list). They are added to the public roots bundled with Node, not put in their place: the right fix for a corporate proxy or a private CA. |
minVersion, maxVersion | Pin the TLS version ('TLSv1.2' or 'TLSv1.3'). |
rejectUnauthorized | false turns off verification of the server certificate. Only if you must, and never in production. |
legacyRenegotiation | true allows unsafe legacy renegotiation. No SII host needs it today. |
clientCertificate | A client certificate for mutual TLS; this is what the portal uses. Not needed for the web services. |
keepAlive (default true) reuses sockets. A TLS failure is TRANSPORT_TLS and details.reason carries the OpenSSL or operating-system code (for example UNABLE_TO_GET_ISSUER_CERT_LOCALLY), so you see what failed instead of a generic message. See Security before turning verification off.
You can also write your own transport, for a proxy, a test double or a runtime with its own HTTP stack. One rule matters: a failure that happens after the response headers arrived (while the body was being read) must carry details.responseStarted: true, because it tells the client that the SII has answered, and an upload or a claim is then not sent again.
The retry policy#
DEFAULT_RETRY_POLICY is:
| Field | Default | Meaning |
|---|---|---|
maxAttempts | 6 | Total attempts, the first included (1 means no retry). |
initialDelayMs | 2000 | Wait before the second attempt. |
backoffMultiplier | 1.8 | Growth factor between attempts. |
maxDelayMs | 15000 | Cap for any wait. |
jitterRatio | 0.2 | Symmetric jitter: plus or minus 20 % of the wait. |
retryOn | isRetryableError | Which errors to retry. |
The waits grow as 2 s, 3.6 s, 6.5 s, 11.7 s and 15 s (capped), before jitter. A Retry-After or X-Retry-After header from the SII replaces the computed wait, still capped at maxDelayMs. The error that finally surfaces carries details.attempts.
isRetryableError(error) is true for TRANSPORT_FAILED, REQUEST_TIMEOUT, SII_RESPONSE_INVALID, SII_QUERY_ERROR, SII_HTTP_ERROR with HTTP status 429, 405 or 5xx, and SII_UPLOAD_REJECTED with status 9 ("system blocked"). It is false for everything else, TRANSPORT_TLS and SII_TOKEN_INVALID included.
const sii = createSiiClient({
environment: 'certification',
certificate,
companyRut: '76543210-3',
retry: { maxAttempts: 3, initialDelayMs: 1000 }, // merges over the defaults
})
const strict = createSiiClient({ environment: 'certification', certificate, retry: false }) // no retries at allCalls that change state are never sent twice#
An upload (uploadEnvelope, uploadBook, uploadFolioConsumption) and registerClaim change state at the SII. If the answer got lost and the request is sent again, the SII may end up with two copies, or two claims. So these calls apply a second, narrower rule on top of the policy: they are sent again only while no SII answer has arrived, that is, for TRANSPORT_FAILED and REQUEST_TIMEOUT raised before the response headers.
- Once the SII answered, whatever the HTTP status or the code, and even if only part of the reply arrived (
details.responseStarted: true), the call is never re-sent. A second copy is exactly the duplicate that the client would then have to recover from. - The one exception is a refused token (
STATUS5, HTTP 401,SII_TOKEN_INVALID): the SII did not take the file, so the call runs once more with a fresh token. retryOncan only narrow. The call retries only when its own rule and the policy'sretryOnboth agree.retryOn: () => falsenever re-sends; noretryOncan make an upload re-send after an answer.
Queries are read-only and follow the full policy. Getting a token is one retry loop around seed, signature and token. waitForUploadStatus replaces the policy with its polling interval, as described above.
Capture#
capture receives every HTTP attempt, already redacted, so you can keep evidence for an SII support ticket. Each HttpExchange has the operation name, the channel ('soap', 'rest' or 'portal'), the attempt number, the request and the response (or the failure), the start instant and the duration. A capture that throws is logged and never fails the operation.
import { createMemoryCapture } from 'dte-sdk'
import { createDirectoryCapture } from 'dte-sdk/node'
const memory = createMemoryCapture()
// createSiiClient({ …, capture: memory.capture })
memory.exchanges() // a copy of everything captured so far
// On disk (Node): one JSON file per exchange plus an index.jsonl
const onDisk = createDirectoryCapture({ dir: './sii-capture', prefix: 'worker1-' })createDirectoryCapture({ dir, prefix?, logger? }) writes <dir>/<prefix><seq>-<operation>-<status or ERR>.json for each exchange, creates the directory on first use, and appends a line to <dir>/index.jsonl. The prefix keeps several replicas that share a volume apart.
What is redacted. The client redacts the exchange before it reaches the port (redactExchange is exported, for your own adapters):
- the content of the elements
TOKEN,SEMILLAandRSASK, in any case, with or without a namespace prefix, raw or XML-escaped, and also when the text was cut before the closing tag; TOKEN=assignments and the portal session cookies (NETSCAPE_LIVEWIRE…,CSESSIONID);- the portal
conversationIdin JSON; - PEM private key blocks;
- the values of the headers
Cookie,Set-Cookie,AuthorizationandProxy-Authorization.
Bodies are cut at 512 KiB of UTF-8, with a suffix that says how many bytes were dropped. RUTs and the XML stay, because SII support needs them to diagnose an upload: a capture still holds personal and commercial data. Protect the directory accordingly.
Archive#
archive keeps the exact bytes of every upload the SII answered, with its result, so you can prove what you sent and recover after a crash. It is called for accepted uploads, for duplicates and for rejections.
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 })Each UploadRecord has the environment, the kind, the channel, the trackId (or null for a rejection), duplicate, the company and sender RUTs, the file name, the send instant (ISO 8601, UTC), the exact bytes sent (xml) and the answer (httpStatus, the SII code and raw with tokens and seeds redacted).
createDirectoryUploadArchive({ dir, logger? }) writes the bytes as <name>.xml and the record without them as <name>.json, under <dir>/<environment>/<yyyy-mm>/. The name is:
| Case | Name |
|---|---|
| Accepted | <trackId>-<kind> |
| Duplicate answer | <trackId>-<kind>-duplicate-<yyyymmddHHMMSS> |
| Rejected, no TrackID | rejected-<yyyymmddHHMMSS>-<kind> |
A duplicate names the TrackID of the original upload, so it gets its own timestamped name and never replaces that upload's record. The timestamp is the UTC send instant. An archive that fails is logged and never fails the upload: the SII already has the file, and you need its TrackID or its error, not the port's. The directory adapter does not restrict file permissions beyond the defaults of your process; see Security.
Logger#
logger is a port with four functions, debug, info, warn and error, each (message, context?). It is silentLogger by default; pass console or your own. The messages are fixed strings such as 'sii http', 'sii retrying' and 'sii token obtained', and the context holds operation names, attempt numbers, HTTP statuses, SII codes and durations. Messages never carry tokens, RUTs or XML.
const sii = createSiiClient({ environment: 'certification', certificate, logger: console })Low-level helpers#
For your own adapters and tests, the building blocks are exported:
buildSoapEnvelope({ method, params, namespace? })builds a SOAP 1.1 envelope (UTF-8). Parameters go in the order the WSDL declares them, because the service matches them by position, not by name.parseSoapResponse(xml)returns{ fault, returnValue, returnXml }without a full XML parse: the SII answers are flat and sometimes carry stray entities.buildMultipartBody({ boundary, fields, file })builds the multipart form of an upload, with the file bytes copied untouched.signSeed,tokenCacheKeyandredactExchange, described above.