Envelopes
The SII does not receive a document alone: it receives an envelope (EnvioDTE, or EnvioBOLETA for receipts) that holds one or more signed documents under a cover (Caratula) and is itself signed. createEnvelope builds it from your signed documents and the cover data, signEnvelope signs it with the certificate of whoever sends it, and encodeLatin1 turns the XML into the ISO-8859-1 bytes the upload needs.
This page also covers the serialization helpers (text encoding and the XML helpers) because the envelope is where they matter. Everything lives in the dte-sdk entry point.
Envelopes validate against the official EnvioDTE and EnvioBOLETA schemas
in the tests, but none has been sent to the real SII yet. See
Status.
Usage#
import { readFileSync } from 'node:fs'
import {
createEnvelope,
encodeLatin1,
issueDocument,
loadCertificateFromPkcs12,
parseCaf,
signEnvelope,
verifyEnvelopeSignature,
} from 'dte-sdk'
const certificate = loadCertificateFromPkcs12(readFileSync('firma.pfx'), process.env.PFX_PASSWORD!)
const caf = parseCaf(readFileSync('caf-33.xml', 'utf8'))
const invoice = issueDocument({ document: input, caf, certificate })
const envelope = signEnvelope(
createEnvelope({
kind: 'dte',
documents: [invoice],
cover: {
issuerRut: '76543210-3',
senderRut: certificate.rut!,
resolutionDate: '2026-10-31',
resolutionNumber: 0,
},
}),
certificate,
)
envelope.setId // 'DTE_SetDoc_15_11_2026_15_30_00_000'
envelope.cover.subtotals // [{ type: 33, count: 1 }]
envelope.xml // '<?xml version="1.0" encoding="ISO-8859-1"?>\n<EnvioDTE …'
verifyEnvelopeSignature(envelope.xml).valid // true
const bytes = encodeLatin1(envelope.xml) // the bytes of the file the SII receivesTo send it, hand the signed envelope to the SII client (see SII web services).
Create an envelope#
createEnvelope({ kind, documents, cover, now? }) groups signed documents under a cover. It validates the kind, normalizes the RUTs and the resolution date, counts the documents per type for the cover (SubTotDTE) and generates the set id. It returns an Envelope (kind, setId, cover, documents), not yet signed.
Kinds#
kind | Root element | Holds | Schema |
|---|---|---|---|
'dte' | EnvioDTE | Invoices, settlement and purchase invoices, guides and notes (33, 34, 43, 46, 52, 56, 61). | EnvioDTE_v10.xsd |
'receipt' | EnvioBOLETA | Receipts (39 and 41). | EnvioBOLETA_v11.xsd |
envelopeRootName(kind) returns the root element name for a kind.
Receipts and the rest never travel together. A 'dte' envelope with a receipt, or a 'receipt' envelope with an invoice, throws ENVELOPE_KIND_MISMATCH (details: kind and the offending type). Issue the receipts in one envelope and the other documents in another. An empty documents list throws ENVELOPE_EMPTY.
The cover#
cover is an EnvelopeCoverInput:
| Field | SII tag | Required | Meaning |
|---|---|---|---|
issuerRut | RutEmisor | yes | RUT of the issuing company. |
senderRut | RutEnvia | yes | RUT of the person who signs and sends the envelope: the holder of the certificate (certificate.rut). |
resolutionDate | FchResol | yes | Date of the SII resolution that authorized you as an issuer. YYYY-MM-DD or DD-MM-YYYY. |
resolutionNumber | NroResol | yes | Number of that resolution. It is 0 in the certification environment. |
receiverRut | RutReceptor | no | RUT of the receiver of the envelope. Defaults to the SII (60803000-K, SII_RUT). Always the SII on receipt envelopes. |
signedAt | TmstFirmaEnv | no | Signing timestamp, YYYY-MM-DDTHH:MM:SS. Defaults to now in Chile. |
setId | SetDTE/@ID | no | Identifier of the set. Generated when omitted. |
The normalized EnvelopeCover that comes back has all of them, with the RUTs in SII shape (76543210-3), resolutionDate as YYYY-MM-DD and subtotals, the number of documents of each type ({ type, count }, TpoDTE and NroDTE).
On a 'dte' envelope the subtotals list 33, then 61, then 56, then the other types in ascending order. A 'receipt' envelope lists the types in the order they first appear.
Timestamps are Chile time (America/Santiago), never UTC: a TmstFirmaEnv built from the UTC clock puts the envelope about three hours in the future ("Error en Carátula"). The SDK builds it from the Chile time of now, so you do not have to.
The resolution (date and number) belongs to the company and the environment: the SII gives you one when it authorizes you as an issuer, and the number is 0 in certification. The SDK only checks that senderRut is a valid RUT; use the RUT of the holder of the certificate that signs the envelope.
The set id#
generateSetId(prefix, now?) builds the identifier of the SetDTE: PREFIX_dd_mm_yyyy_hh_mm_ss_mmm, in Chile time:
import { generateSetId } from 'dte-sdk'
generateSetId('LOTE', new Date('2026-11-15T18:30:05.007Z')) // 'LOTE_15_11_2026_15_30_05_007'createEnvelope uses it with the prefix DTE_SetDoc for a 'dte' envelope and ENVIOBOLETA for a 'receipt' one, unless you pass cover.setId. The id is unique within a process (it carries the milliseconds); if several processes build envelopes at the same instant, or you want to find an envelope by its id later, give your own setId.
Errors#
| Code | When |
|---|---|
ENVELOPE_EMPTY | There are no documents. |
ENVELOPE_KIND_MISMATCH | A receipt in a 'dte' envelope, or another document in a 'receipt' envelope. |
INVALID_RUT | issuerRut, senderRut or receiverRut is not a valid RUT (details.field is cover.issuerRut and so on). |
INVALID_DATE | resolutionDate is not a real date. |
Render and sign#
signEnvelope(envelope, certificate) renders the envelope XML and signs the SetDTE element with the certificate of the sender. It returns a SignedEnvelope: the envelope plus xml (the signed XML, with its XML declaration) and signature ({ digestValue, signatureValue }).
buildEnvelopeXml(envelope) renders the unsigned XML, which signEnvelope calls:
- It opens with the declaration
<?xml version="1.0" encoding="ISO-8859-1"?>. - The root (
EnvioDTEorEnvioBOLETA) carries the SII namespace,xmlns:xsi, thexsi:schemaLocationof the schema andversion="1.0". SetDTEhas theIDand holds theCaratulaand the signed documents, one per line.- Each document keeps its own namespaces, because its digest depends on them, and loses its own declaration if it had one.
The documents inside are exactly the xml of each SignedDocument: they are not re-signed, and any change to them after signing breaks their signature.
verifyEnvelopeSignature(xml) verifies the SetDTE signature of an EnvioDTE or EnvioBOLETA XML, yours or received, and returns the same SignatureVerification as verifyDocumentSignature: referenceId (the set id), digestValid, signatureValid and valid. It throws XML_PARSE_FAILED for malformed XML or a wrong root and SIGNATURE_NOT_FOUND when there is no signature. Changing any value of the cover (the resolution number, for instance) breaks it. It does not verify the signatures of the documents inside: do that with verifyDocumentSignature on each xml. For an envelope a supplier sent, verifyReceivedDocuments checks the envelope and every document inside, signature, stamp and CAF, in one call (see Verify received documents).
Mixing receipts and documents#
The two kinds are separate envelopes with separate channels. A receipt envelope (EnvioBOLETA) always targets the SII and uses the ENVIOBOLETA set prefix:
const receipts = signEnvelope(
createEnvelope({
kind: 'receipt',
documents: [receipt1, receipt2],
cover: { issuerRut: '76543210-3', senderRut: certificate.rut!, resolutionDate: '2026-10-31', resolutionNumber: 0 },
}),
certificate,
)
receipts.cover.receiverRut // '60803000-K', even if the cover said otherwiseIf one batch of work has invoices and receipts, split documents by isReceipt(document.header.type) and build one envelope for each kind.
Serialize for upload#
The SII reads the upload as ISO-8859-1 bytes. That has three consequences, and the SDK takes care of the first two:
- The text of a document must fit in ISO-8859-1.
createDocumentremoves every character above U+00FF from the free text (emoji, curly quotes). The accents and theñof Spanish are inside ISO-8859-1 and stay. - The XML must start with a declaration that names ISO-8859-1. The envelope and the books the SDK builds do (
<?xml version="1.0" encoding="ISO-8859-1"?>). The upload functions check it and refuse anything else withINVALID_OPTIONanddetails.field: 'xml'before a byte is sent; whitespace before the declaration is dropped. Without the declaration, XML defaults to UTF-8 and the SII would read a different text than the one that was signed. - The bytes are one per character.
encodeLatin1(text)returns aUint8Arraywith the ISO-8859-1 bytes;decodeLatin1(bytes)goes the other way (one character per byte).
import { decodeLatin1, encodeLatin1 } from 'dte-sdk'
encodeLatin1('Ñuñoa') // Uint8Array [209, 117, 241, 111, 97]
decodeLatin1(new Uint8Array([209, 117, 241, 111, 97])) // 'Ñuñoa'
encodeLatin1('precio €') // throws ENCODING_UNSUPPORTED_CHARencodeLatin1 throws ENCODING_UNSUPPORTED_CHAR with details { index, codePoint } for a character above U+00FF. It never truncates it to its low byte, which is how corrupted control characters used to reach the SII. It can still happen if you put text in a field createDocument does not sanitize (an email, an option, an item code): run sanitizeText on it, or fix the text, before signing, because the signature covers the text as it is.
The signed envelope XML is already Latin-1 safe when the documents come from createDocument: encodeLatin1(envelope.xml).length equals envelope.xml.length. Save or send the bytes, not the JavaScript string. Writing the file with writeFileSync(path, envelope.xml) encodes it as UTF-8 and breaks every accent.
import { writeFileSync } from 'node:fs'
writeFileSync('envio.xml', encodeLatin1(envelope.xml))XML helpers#
The SDK builds and reads its XML with a few small helpers, exported because the SII answers (SOAP, XML in text form) need the same work:
| Function | What it does |
|---|---|
buildXml(object) | Serializes a plain object to compact XML. Keys become elements in insertion order, @_name keys become attributes, arrays repeat the element and values are escaped. |
parseXml(xml) | Parses XML into a plain object: elements are keys, repeated elements are arrays, attributes are @_name keys, and every value stays a string (007 stays '007'). Throws XML_PARSE_FAILED when the XML is not well formed. |
parseXmlWithoutNamespaces(xml) | Like parseXml, without namespace prefixes (SII:RESPUESTA becomes RESPUESTA). Handy for SOAP answers. |
extractTagContent(xml, tag) | The text of the first <tag>…</tag>, or null. Regular-expression based, for flat SII answers: it does not handle attributes or nesting. |
decodeXmlEntities(xml) | Decodes the five XML entities. SOAP answers often carry escaped XML. |
escapeXmlText(value) | Escapes a text value with the five XML entities (&, <, >, ", '). |
escapeXmlAttribute(value) | Escapes a value for an attribute in double quotes (&, <, "). |
wrapBase64(value, width?) | Removes whitespace from a base64 string and wraps it at width columns (64 by default). |
formatBase64Columns(xml) | Rewraps SignatureValue, Modulus and X509Certificate at 64 columns, the layout the SII expects. |
import { buildXml, extractTagContent, parseXml } from 'dte-sdk'
buildXml({ Envio: { '@_version': '1.0', Linea: ['a', 'b&c'], Total: 10 } })
// '<Envio version="1.0"><Linea>a</Linea><Linea>b&c</Linea><Total>10</Total></Envio>'
parseXml('<A v="1"><B>x</B><B>y</B></A>') // { A: { B: ['x', 'y'], '@_v': '1' } }
extractTagContent('<RESP><TRACKID>123456</TRACKID></RESP>', 'TRACKID') // '123456'Text and time helpers#
| Function | What it does |
|---|---|
sanitizeText(text) | Normalizes free text for a DTE field: drops apostrophes and double quotes, maps typographic dashes and ellipsis to ASCII, removes what is outside ISO-8859-1 and trims. |
foldToAscii(text) | Folds text to printable ASCII (á to a, ß to ss), as the TED needs. |
truncateText(text, maxLength, preserveWords?) | Sanitizes and cuts text to maxLength; with preserveWords it backs up to the last space if that is past 70 % of the limit. |
assertMaxLength(value, maxLength, field) | Throws FIELD_TOO_LONG when a text exceeds the limit. |
chileDate(now?) | Today's date in Chile, YYYY-MM-DD. |
chileTimestamp(now?) | YYYY-MM-DDTHH:MM:SS in Chile time, without zone or milliseconds (TmstFirma, TmstFirmaEnv, TSTED). |
chileDateTimeParts(now?) | The zero-padded year, month, day, hour, minute and second of an instant in Chile. |
toIsoDate(value, field?) | Normalizes DD-MM-YYYY, DD/MM/YYYY or YYYY-MM-DD to YYYY-MM-DD. Throws INVALID_DATE. |
isIsoDate(value) | true for a real calendar date in YYYY-MM-DD. |
toSiiRut(value, field?), isSiiRut(value), splitSiiRut(rut) | RUT helpers over rut.ts: validate and format as 12345678-5, check the shape, split into body and check digit. |
The date and time helpers use the Chile time zone through Intl, never the time zone of the process.
Next steps#
- SII web services: upload the envelope and follow its status.
- Sign: the certificate and the signatures behind
signEnvelope. - Books: the SII books go in their own signed XML.
- Certification: the test sets are sent as envelopes too.