Node API (dte-sdk/node)
The adapters that need Node: the node:https transport, which presents client certificates and lets you tune TLS, the on-disk HTTP capture, upload archive, token and session cache and folio registry, and the search for CAF files on disk, with the registry scope of each. They load node: modules on first use, never at import. Each adapter plugs into a port of the root entry (transport, capture, archive, tokenCache) or of the portal (sessionCache, the folio registry); the CAF file search is a set of functions you call.
import {
createNodeHttpsTransport,
createDirectoryCapture,
createDirectoryUploadArchive,
createDirectoryTokenCache,
createDirectoryFolioRegistry,
cafFileScope,
findCafFiles,
findLatestCafFile,
resolveCafFile,
} from 'dte-sdk/node'A client wired with the Node adapters:
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' }),
})The second parameter of each factory (load or loadModules) exists only so the SDK's own tests can replace the Node modules; it is left out of the signatures below. The files that the capture and the upload archive write use Node's default modes (the process umask applies); they set no explicit permissions. Put the directories somewhere only your service can read. The token cache and the folio registry are the exception: they set 0700 and 0600 themselves. For the store of the certification runner, which does set 0700 and 0600, see dte-sdk/portal.
Everything on this page is tested against synthetic data. The TLS behaviour of the SII hosts was measured on 2026-11-01: they verify with the system trust store and negotiate TLS 1.2 or 1.3 without help. 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; uploads and status queries have not run in either environment yet (issue 8).
Transport#
createNodeHttpsTransport returns an HttpTransport over node:https. Server verification is on by default. Every other TLS field is an opt-in escape hatch: tls.ca adds CA certificates to the public roots bundled with Node and never replaces them (the right fix for a corporate proxy or a private CA), and tls.rejectUnauthorized: false turns verification off, which you should not do against the SII. tls.clientCertificate takes the privateKeyPem and chainPem of a Certificate, for mutual TLS (the portal login). A request that cannot be built (a URL that does not parse, a header Node refuses) fails with TRANSPORT_FAILED, like any other failure before the exchange. If node:https cannot be loaded, the first request fails with TRANSPORT_UNAVAILABLE.
// Transport over `node:https`, loaded on first use (never at import). Verifies the server
// certificate by default; see `NodeTlsOptions` for the opt-in knobs. `loadModules` exists for
// tests only.
function createNodeHttpsTransport(options?: NodeHttpsTransportOptions): HttpTransport| Type | Description |
|---|---|
TlsVersion | TLS protocol versions the adapter can pin. Defined as 'TLSv1.2' | 'TLSv1.3'. |
NodeTlsOptions | TLS knobs of the Node adapter. |
NodeHttpsTransportOptions | Options of createNodeHttpsTransport. |
Fields of NodeTlsOptions
| Field | Type | Description |
|---|---|---|
ca? | string | readonly string[] | Extra trusted CA certificates (PEM), added to the public roots bundled with Node, never in their place: the right fix for a corporate proxy or a private CA. |
rejectUnauthorized? | boolean | Verify the server certificate (default true). |
minVersion? | TlsVersion | Lowest TLS version. |
maxVersion? | TlsVersion | Highest TLS version. |
legacyRenegotiation? | boolean | Allow unsafe legacy renegotiation (SSL_OP_ALLOW_UNSAFE_LEGACY_RENEGOTIATION | SSL_OP_LEGACY_SERVER_CONNECT); not needed by any SII host today. |
clientCertificate? | Pick<Certificate, 'privateKeyPem' | 'chainPem'> | Client certificate for mutual TLS (portal login); the chain must be leaf → intermediates → root, which Certificate.chainPem is. |
Fields of NodeHttpsTransportOptions
| Field | Type | Description |
|---|---|---|
tls? | NodeTlsOptions | TLS options; verification stays on unless tls.rejectUnauthorized is false. |
keepAlive? | boolean | Reuse sockets (default true). |
Capture#
createDirectoryCapture is an HttpCapture port that writes one JSON file per HTTP attempt and appends one line per attempt to an index. The client redacts each exchange before it reaches the port: tokens, seeds, portal session cookies, conversationId, RSASK and PEM private keys become [redacted], and bodies are cut at 512 KiB. RUTs and XML stay, because SII support needs them to diagnose an upload. It never throws: a failure to write goes to logger.warn.
// Writes `<dir>/<prefix><seq>-<operation>-<status|ERR>.json` per exchange and appends a line to
// `<dir>/index.jsonl`. Never throws: failures go to `logger.warn`. `loadModules` exists for tests
// only.
function createDirectoryCapture(options: DirectoryCaptureOptions): HttpCapture| Type | Description |
|---|---|
DirectoryCaptureOptions | Options of createDirectoryCapture. |
Fields of DirectoryCaptureOptions
| Field | Type | Description |
|---|---|---|
dir | string | Target directory, created on first use. |
prefix? | string | File name prefix that separates replicas sharing a volume. |
logger? | Logger | Where failures are reported (default silent). |
What it writes under dir (created on first use):
<dir>/
index.jsonl
<prefix>0001-<operation>-<status>.json
<prefix>0002-<operation>-<status>.jsonindex.jsonl has one line per attempt. <operation> is the operation name (for example uploadEnvelope) and <status> is the HTTP status, or ERR when no response came. Each index.jsonl line is a JSON object with seq, startedAt, operation, method, url, status (null when there was no response), durationMs and file. The sequence number is padded to four digits and is kept in memory by each adapter instance, so it starts at 1 again in a new process: use a new dir, or a prefix, per run or per replica if you want to keep earlier files apart.
Upload archive#
createDirectoryUploadArchive is an UploadArchive port. For every upload that got an answer from the SII it keeps the exact bytes sent and a record of the exchange. It never throws: a failure goes to logger.warn.
// Writes the bytes sent as `<name>.xml` and the record without them as `<name>.json` under
// `<dir>/<environment>/<yyyy-mm>/` (`name` from `uploadRecordName`). Never throws. `loadModules`
// is for tests.
function createDirectoryUploadArchive(options: DirectoryUploadArchiveOptions): UploadArchive| Type | Description |
|---|---|
DirectoryUploadArchiveOptions | Options of createDirectoryUploadArchive. |
Fields of DirectoryUploadArchiveOptions
| Field | Type | Description |
|---|---|---|
dir | string | Root directory; <environment>/<yyyy-mm>/ is created under it. |
logger? | Logger | Where failures are reported (default silent). |
What it writes:
<dir>/<environment>/<yyyy-mm>/<name>.xml
<dir>/<environment>/<yyyy-mm>/<name>.json<environment> is certification or production, and <yyyy-mm> is the UTC month of the send. <name> is <trackId>-<kind>; for an answer that reports a duplicate it is <trackId>-<kind>-duplicate-<yyyymmddHHMMSS>, so it does not replace the record of the original upload; and for an upload without a TrackID it is rejected-<yyyymmddHHMMSS>-<kind>. <kind> is dte, receipt, book or folioConsumption. The .json record has the fields of UploadRecord except xml, and its response.raw has tokens and seeds redacted. The .xml file holds the documents you sent, with your customers' data in them.
Token cache#
createDirectoryTokenCache is a TokenCache kept on disk. It serves as the tokenCache of the SII client and as the sessionCache of the portal client, so a restart reuses the token or the portal session instead of logging in again, which the SII rations per RUT. The processes of the same user share the entries. It stores a token (or, for the portal, the session cookies) and its expiry, one JSON file per key under dir. Tokens and portal sessions are credentials: give it a directory of its own.
// A token cache on disk: one JSON file per key under `dir`, written atomically (a temp file of its
// own, created exclusively, then renamed) and readable by the owner only. The entry names its key,
// so two keys that share a file name never get each other's token. Only a regular file of the user
// of the process is read, and the read never waits on what is not a file. It never throws: a
// failure to read or write is a miss, reported to `logger` with the operation and never with the
// token. `loadModules` exists for tests only.
function createDirectoryTokenCache(options: DirectoryTokenCacheOptions): TokenCache| Type | Description |
|---|---|
DirectoryTokenCacheOptions | Options of createDirectoryTokenCache. |
Fields of DirectoryTokenCacheOptions
| Field | Type | Description |
|---|---|---|
dir | string | Directory of the entries, one <key>.json each, for this cache alone; created on first use for the owner only, while an existing one keeps its permissions. |
logger? | Logger | Where failures are reported (default silent). |
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 }),
})The same call serves the portal client: createPortalClient({ …, sessionCache: createDirectoryTokenCache({ dir: './sii-sessions' }) }).
What it does with the files under dir (created on first use with mode 0700; an existing directory keeps its permissions):
- One file per key.
<key>.json, with every character of the key other than letters, digits,_and-replaced by-. Each file is created with mode0600, and it also holds the key it belongs to: two keys that map to the same file name (a:banda-b, orAandaon a file system that ignores case) never get each other's token, the entry of another key is a miss. - Only your own regular files are read. The file is opened without blocking and read through the same handle that checks it, and it is refused (a miss) unless it is a regular file owned by the user of the process: a device, a directory, a pipe or a file another user left in
diris never read. On Windows, where files have no owner mode and there are no user ids, the permissions ofdirdecide, and the owner is not checked. - A failure is a miss. A missing file, one that is not an entry, a write that fails:
getanswersnullandsetanddeletedo nothing, and the failure goes tologger.warnastoken cache failedwith theoperationand thereason. The reason never carries the token or a parse error, because a portal entry holds the session cookies.
Processes that use one portal session at the same time also need a shared lock: the cache shares the session, not the turn to use it. See dte-sdk/portal. The TokenCache type and the in-memory cache are in dte-sdk.
Folio registry#
createDirectoryFolioRegistry returns a FolioRegistry (reserved, sent and annulled folios) kept in one JSON file, readable by the owner only. It is for a single process. Keep the directory across restarts: if you lose it, folios get repeated at the SII.
// A registry in one JSON file, written atomically (a temp file of its own, created exclusively,
// then renamed), so a reader never sees half a ledger, and readable by the owner only, like the
// token cache, since the ledger holds the companies' RUTs, their track ids and what callers keep in
// `extra` (on Windows, where files have no such mode, the permissions of `dir` decide). It is for a
// single process. `load` exists for tests only.
function createDirectoryFolioRegistry(options: DirectoryFolioRegistryOptions): FolioRegistry| Type | Description |
|---|---|
DirectoryFolioRegistryOptions | Options of createDirectoryFolioRegistry. |
Fields of DirectoryFolioRegistryOptions
| Field | Type | Description |
|---|---|---|
dir | string | Directory of folios.json; created on first use for the owner only, while an existing one keeps its permissions. It must survive restarts: losing it repeats folios at the SII. |
now? | () => Date | Clock. |
The registry is one file, <dir>/folios.json. The directory is created with mode 0700; one that already exists keeps its permissions. Each save writes a temp file of its own beside it (folios.json.<uuid>.tmp), created exclusively with mode 0600, and renames it over folios.json, so a reader never sees half a ledger and nothing already at that name, a symlink included, is written through. A write or a rename that fails removes the temp file. A folios.json written before 0.5.0, with the default modes, becomes owner-only on its next save. On Windows, where files have no such mode, the permissions of dir decide. If node:fs cannot be loaded, the operations of the registry fail with TRANSPORT_UNAVAILABLE. The FolioRegistry type and the in-memory registry are in dte-sdk/portal.
CAF files#
A directory with the CAF files that the SII gave you, searched for the one to issue with. The three search functions read only: reserving the folio is the job of the registry, and asking the SII for a new CAF when nothing is found is yours. cafFileScope reads nothing: it gives the registry scope of a file. The CAF XML holds the private key of the CAF, so the directory is a secret (see Security).
// Every usable CAF file of a type and company below a directory, newest first (by modification
// time, then path). Usable means it parses as a CAF, its `TD` is the type, its `RE` is the company
// (read from the file, never from where it lies) and it has not expired, with the one-day margin
// of `isCafExpired`; with `includeExpired` the expired ones come too, marked `expired`. Each file
// is read in the encoding its XML declaration names: ISO-8859-1 as such, UTF-8 or no encoding named
// as strict UTF-8, so a file whose bytes are not that encoding, or that names another, is skipped
// rather than misread. A folder or file that cannot be read is skipped, and a directory that does
// not exist gives an empty list.
function findCafFiles(options: FindCafFilesOptions): Promise<readonly CafFile[]>
// The newest usable CAF file of a type and company below a directory (see `findCafFiles`), or null;
// never an expired one, whatever `includeExpired` says.
function findLatestCafFile(options: FindCafFilesOptions): Promise<CafFile | null>
// The newest usable CAF file, never an expired one, that still has `required` free folios, or null
// when none has. With a registry the free folios are `registry.remaining` for the scope of the file
// (`cafFileScope`: company, type, range, `environment` and the fingerprint of its content), which
// comes back as `scope`; without one, the whole range. It only reads.
function resolveCafFile(options: ResolveCafFileOptions): Promise<ResolvedCafFile | null>
// The registry scope of a CAF file, to reserve and count its folios with: the company (`RE`), type
// (`TD`) and range (`RNG`) of its CAF, the environment given, and the `cafFingerprint` of its text,
// so two files of one range never share an entry. It is the scope `resolveCafFile` returns. It
// matches the file on disk only when given the text that file holds.
function cafFileScope(file: Pick<CafFile, 'xml' | 'caf'>, environment: Environment): FolioScope| Type | Description |
|---|---|
FindCafFilesOptions | Options of findCafFiles and findLatestCafFile. |
CafFile | A CAF file found on disk. |
ResolveCafFileOptions | Options of resolveCafFile. With a registry, the environment of its scopes is required. |
ResolvedCafFile | The CAF file resolveCafFile chose and its free folios. A CafFile plus remaining and scope. |
Fields of FindCafFilesOptions
| Field | Type | Description |
|---|---|---|
dir | string | Directory searched, with its folders down to five levels below it. |
type | DocumentType | TD: document type of the CAF. |
issuerRut | string | RE: company RUT the CAF must be issued to, in any shape. |
now? | () => Date | Clock for the expiry check. |
includeExpired? | boolean | findCafFiles only: also the files whose CAF expired, marked expired, so you can say why one is not used; false by default. findLatestCafFile and resolveCafFile never take one. |
Fields of CafFile
| Field | Type | Description |
|---|---|---|
path | string | Path of the file, joined from dir. |
xml | string | The file content, decoded as its XML declaration says, as cafFingerprint and parseCaf take it. |
caf | Caf | The parsed CAF. |
modifiedAt | Date | Last modification of the file. |
expired | boolean | The CAF has expired, with the one-day margin of isCafExpired; true only with includeExpired. A CafFile built by hand, as in a test double, needs expired: false. |
Fields of ResolveCafFileOptions
The fields of FindCafFilesOptions plus these:
| Field | Type | Description |
|---|---|---|
required? | number | Free folios the CAF must have; default 1. |
registry? | FolioRegistry | Registry that says how many folios of each CAF are still unused. Without it, every folio of the range counts as free. With it, environment is required. |
environment? | Environment | Environment of the registry scopes, and of the scope returned; without it scope is null. |
Fields of ResolvedCafFile
The fields of CafFile plus these:
| Field | Type | Description |
|---|---|---|
remaining | number | Folios of the range still unused. |
scope | FolioScope | null | The registry scope of the file, to reserve its folios with; null when no environment was given. |
What the search does:
- It walks
dirand its folders down to five levels below it, and looks at the.xmlfiles only. - Each file is read in the encoding its XML declaration names. One that declares ISO-8859-1, as the SII writes its CAF files, is read as such, so an Ñ or an accent in the company's name reaches
RSintact and the<CAF>block keeps the text the SII signed. One that declares UTF-8, or no encoding, is read as strict UTF-8. A file whose bytes are not the encoding it names, or that names another one, is skipped rather than misread, and a UTF-8 byte order mark is dropped. Before 0.7.0 every file was read as UTF-8, and a CAF saved as the SII served it, with an Ñ or an accent, failed the SII's check of every stamp made with it. - A file counts only if it parses as a CAF of that
typewhoseREisissuerRut. The company is read from the file and never from the folder: a shared folder may hold CAF files of other companies, and a stamp signed with one makes the SII reject the whole envelope. - A CAF that has expired, with the one-day margin of
isCafExpired, is left out. WithincludeExpired: true,findCafFileslists it too, withexpired: true, so you can say why a file is not used;findLatestCafFileandresolveCafFilenever take one. - A folder or file that cannot be read is skipped. A
dirthat does not exist gives an empty list. - The result is ordered newest first by modification time, then by path.
A file with no XML declaration and ISO-8859-1 bytes is no longer found: declare its encoding, as the SII's file does, or save it as UTF-8.
The registry scope of some files moved in 0.7.0. cafFingerprint hashes the text as read, so the scope resolveCafFile returns changed for a file saved with a UTF-8 byte order mark (the mark is no longer in the text) and for one declared ISO-8859-1 with bytes above 0x7F (read as Latin-1 now, where they were replacement characters). A registry entry kept under the old fingerprint no longer matches the file, and resolveCafFile would read its whole range as free, folios already used included. Before issuing from such a file again, move its entry to the new scope (cafFileScope of the file as now read), or re-save the file without the mark, or as UTF-8, and move the entry then. A file in plain UTF-8, or ASCII, keeps its fingerprint.
cafFileScope is the scope resolveCafFile builds. Anything with the text of a CAF and the CAF parsed from it has one too, such as an ObtainedCaf of the portal, but it is the saved file's scope only when given the text the file holds: an app that rewrites a downloaded CAF before saving it, its declaration say, takes the scope from the text it saved.
Each function throws a DteError for a bad option, before reading anything:
| Code | When |
|---|---|
INVALID_OPTION (field: 'dir') | dir is not a non-empty string. |
INVALID_DOCUMENT_TYPE | type is not a document type. |
INVALID_RUT | issuerRut is not a RUT. |
INVALID_OPTION (field: 'required') | required is not an integer of at least 1 (resolveCafFile). |
INVALID_OPTION (field: 'environment') | A registry was given without environment (resolveCafFile). |
TRANSPORT_UNAVAILABLE | node:fs cannot be loaded. |
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 goes to issueDocument