Skip to content

Get folios from the SII

Every document needs a folio, and a folio comes from a CAF (Código de Autorización de Folios): a file with a range of folios and the private key that signs the stamp of each document. You get it at the SII portal. This example does it from code with dte-sdk/portal: it logs in with your certificate, reads who you are for the SII, checks how many folios the SII would give you, requests a CAF, stores it safely, keeps track of the folios with a local registry and issues a document with the first one.

dte-sdk 0.8.0 is on npm, the latest release (see Installation). 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. The portal functions were tested against synthetic pages, with one important limit: login, logout, taxpayer data and the refusal pages were measured against the real SII, but the folio and CAF pages of an enrolled company have not been seen yet, because they need a company enrolled as an issuer. Quota, CAF requests, annulment and re-obtaining are written from what the portal is known to do. Read Status, try it in certification and expect surprises.

The CAF XML is a secret. It holds the private key that signs every stamp of the range: whoever has the file can issue documents in your name. Store it where only your service can read it, never in a repository, a log or an error report.

What you'll build#

A program, folios.ts, that:

  • opens the portal as the holder of the certificate and reads the profile of the company: name, activity, address and the resolution of the environment,
  • reads the folio quota of type 33, without stamping anything,
  • requests a CAF of up to 50 folios, and reads how many it was really granted,
  • saves the XML with restricted permissions, parses it, and reserves a folio in the local registry,
  • issues an invoice with that folio, and closes the portal session.

Sending the invoice is the job of Issue an invoice.

Prerequisites#

  • Node 22.12 or later, in an ES module project, with dte-sdk installed. The portal needs Node: fetch cannot present a client certificate. See Installation.
  • Your certificate as firma.pfx, with its password in PFX_PASSWORD.
  • The certificate must be enabled as a login method at sii.cl. This is a manual step at the SII, in Cambiar Clave y datos de autenticación, option "Utilizando Clave Tributaria o Certificado Digital". Until you do it, every login throws SII_PORTAL_CERT_NOT_ENABLED.
  • A company enrolled as an electronic issuer in the environment you use, and the holder of the certificate with permission to request folios for it. Without them the SII refuses with the reasons in the table below.
  • The RUTs and names in the example are fictitious: use your own company.

The full program#

TypeScript
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { isDteError, issueDocument, loadCertificateFromPkcs12, parseCaf } from 'dte-sdk'
import { createDirectoryFolioRegistry } from 'dte-sdk/node'
import {
  closePortalSession,
  createPortalClient,
  fetchIssuerProfile,
  portalRejectionMessage,
  queryFolioQuota,
  requestCaf,
  type PortalRejection,
} from 'dte-sdk/portal'
 
const COMPANY_RUT = '76543210-3'
const TYPE = 33
const WANTED = 50 // folios we would like
const LEAST = 10 // fewer than this is not worth a CAF: it would sit unused and lower the next quota
 
const certificate = loadCertificateFromPkcs12(await readFile('firma.pfx'), process.env.PFX_PASSWORD!)
const portal = createPortalClient({ environment: 'certification', certificate, companyRut: COMPANY_RUT })
const folios = createDirectoryFolioRegistry({ dir: './data/folios' })
 
try {
  // 1. Who we are for the SII in this environment
  const profile = await fetchIssuerProfile(portal)
  console.log(`issuer: ${profile.businessName}, resolution ${profile.resolutionNumber} of ${profile.resolutionDate}`)
 
  // 2. How many folios the SII would give us now. Nothing is stamped yet
  const quota = await queryFolioQuota(portal, { type: TYPE })
  if (quota.blocked) {
    console.log('The SII is not stamping this type now: issue with the folios you already have.')
  } else {
    console.log(
      quota.unrestricted
        ? 'quota: the SII is not rationing this type'
        : `quota: at most ${quota.maxAuthorized}, ${quota.availableFolios} stamped and unused`,
    )
 
    // 3. Ask for the CAF and read how many folios we really got
    const quantity = Math.min(WANTED, quota.maxAuthorized ?? WANTED)
    const obtained = await requestCaf(portal, { type: TYPE, quantity, minQuantity: LEAST })
    const { from, to } = obtained.caf.range
    console.log(`CAF obtained: folios ${from}-${to} (${obtained.granted} granted)`)
 
    // 4. The XML holds the private key of the CAF: a secret. Save it where only this service reads it
    const file = `./secrets/caf-${TYPE}-${from}-${to}.xml`
    await mkdir('./secrets', { recursive: true, mode: 0o700 })
    await writeFile(file, obtained.xml, { encoding: 'latin1', mode: 0o600 })
    console.log(`saved to ${file}`)
 
    // 5. From here on it is the file that counts, as in any later run
    const caf = parseCaf(await readFile(file, 'latin1'))
    const scope = { companyRut: COMPANY_RUT, type: TYPE, range: caf.range, environment: 'certification' } as const
    const folio = await folios.reserve(scope)
    console.log(`registry: folio ${folio} reserved, ${await folios.remaining(scope)} left`)
 
    // 6. Issue with it (sending it is the invoice example)
    const invoice = issueDocument({
      document: {
        type: TYPE,
        folio,
        issuer: {
          rut: profile.rut,
          businessName: profile.businessName,
          businessActivity: profile.businessActivity,
          activityCode: profile.activityCode ?? undefined,
          address: profile.address,
          commune: profile.commune,
          city: profile.city,
        },
        receiver: {
          rut: '12345678-5',
          businessName: 'Comercializadora Rapel Ltda',
          businessActivity: 'Comercio',
          address: 'Pasaje Los Olmos 88',
          commune: 'Providencia',
        },
        items: [{ name: 'Plan anual', quantity: 1, unitPrice: 100000 }],
      },
      caf,
      certificate,
    })
    console.log(`invoice issued: folio ${invoice.header.folio}`, JSON.stringify(invoice.header.totals))
  }
} catch (error) {
  if (!isDteError(error)) throw error
  if (error.code === 'SII_PORTAL_REJECTED') {
    // The SII said no, and the SDK says what it means and what to do
    const rejection = error.details.rejection as PortalRejection
    console.error(portalRejectionMessage(rejection))
    console.error('The portal said:', error.details.portalMessage)
  } else {
    console.error(error.code, error.details)
  }
  process.exitCode = 1
} finally {
  await closePortalSession(portal) // gives the session slot back to the SII
}

Walk-through#

1

Create the portal client

createPortalClient returns plain data, like the SII client. It validates the options and uses a Node https transport that presents your certificate and stays on TLS 1.2. There is no login yet: every portal function logs in when it needs to and reuses the session for 90 minutes. The SII allows few sessions per RUT, so a service with several replicas shares the session with the sessionCache and lock ports; the default ones live in the process.

2

Read the profile of the company

fetchIssuerProfile reads the resolution page and the taxpayer data in sequence and combines them: rut, businessName, businessActivity, activityCode (or null), address, commune, city, resolutionDate and resolutionNumber. It is what you need to fill an Issuer and an envelope cover without typing them. The resolution is per environment: certification and production answer different dates and numbers for the same RUT, and a wrong pair gets an envelope rejected with "Error en Carátula". If the company is not authorized as an issuer in the environment, it throws SII_PORTAL_REJECTED with the rejection companyNotAuthorized.

Check the profile before you use it: if the portal has no activity for the company, businessActivity is an empty string and createDocument will refuse it with MISSING_FIELD.

3

Read the quota before you ask

The SII rations folios by your history of stamping and issuing. queryFolioQuota stops at the form that shows the quota: nothing is stamped and the quota does not move. The result has maxAuthorized (the most the SII gives now) and availableFolios (stamped and not yet used), which are null when the SII is not rationing the type (unrestricted: true), and blocked: true when the SII answers "no se autoriza timbraje". A blocked type is lifted by issuing documents with the folios you already have; asking again does not help.

This matters because unused folios count against you: they lower what the SII authorizes next and, in the end, block the stamping. Ask for what you will use.

4

Request the CAF and read what you got

requestCaf walks the five pages of the portal and returns the CAF as obtained.xml (the file exactly as the SII served it), obtained.caf (already parsed), and obtained.granted, the number of folios in the range.

Read granted: it can be less than you asked for. The request is capped at maxAuthorized, and when the SII's limit is exceeded and there is no minQuantity, the SDK asks once more for a single folio. minQuantity is the strict form: below it nothing is stamped and the call throws SII_PORTAL_REJECTED with quotaInsufficient, because a short range would sit unused and lower your next quota. The program asks for 50 and refuses anything under 10. maxAuthorized, availableFolios and recovered (the file came back through the "already authorized" flow or the re-download) are in the result too.

5

Store the CAF as a secret

The program writes the file with mode 0o600 in a directory with mode 0o700, as latin1: the CAF is an ISO-8859-1 file. In your service this is a secret store, not a folder. What you keep must be the file as the SII issued it: the <CAF> block is copied into every stamp, and any change breaks the signature the SII checks. The search for CAF files reads it back in the encoding its XML declaration names, ISO-8859-1 for the SII's file, so an Ñ or an accent in the company's name survives the round trip.

6

Parse it and track the folios

From here the program works from the file, as any later run does: parseCaf reads and validates it (CAF_INVALID if it is not a CAF) and gives you caf.range. The registry comes from dte-sdk/node: reserve(scope) gives the next free folio, markSent records one as sent (as in the invoice example), release gives folios back, markAnnulled records folios annulled at the SII, remaining(scope) counts the free ones and stats totals a company and a type. The scope is the company, the type, the range of the CAF and the environment. cafFingerprint(xml), from dte-sdk/portal, can be added to the scope to tell apart two files for one range.

The registry in a directory is for one process. For several processes, a service with its own database implements the FolioRegistry type with its own transactions, and createFolioRegistry(store) builds one over a store you provide. createMemoryFolioRegistry is for tests. Whatever you use must survive restarts: losing the record repeats folios at the SII.

7

Issue and close the session

issueDocument uses the profile as the issuer. The RUT of the CAF must be the issuer's (CAF_ISSUER_MISMATCH otherwise). The session is closed in finally with closePortalSession, which gives the slot back to the SII and always removes the cached session; if the logout fails it is only logged, because the SII expires the session on its own.

Expected output#

On a successful run in certification, with a company that the SII does not ration and a fresh registry:

Text
issuer: Comercial Andina SpA, resolution 0 of 2026-09-01
quota: the SII is not rationing this type
CAF obtained: folios 1-50 (50 granted)
saved to ./secrets/caf-33-1-50.xml
registry: folio 1 reserved, 49 left
invoice issued: folio 1 {"netAmount":100000,"vatRate":19,"vatAmount":19000,"totalAmount":119000}

The last line is computed by the SDK: 1 × 100000 net, VAT 19000, total 119000. Everything before it comes from the SII and is an example: the name, the resolution, the quota and the range will be yours. With rationing on, the quota line reads quota: at most 30, 0 stamped and unused, and the CAF has at most that many folios.

Pick the CAF of a later run#

A program that runs every day does not request a CAF each time: it picks one it already has. resolveCafFile, from dte-sdk/node, searches a directory (and its folders down to five levels) for the newest CAF file of the type and company that has not expired and still has required free folios in the registry. It returns the file with the scope to reserve from, or null when none has: that is the moment to call requestCaf.

TypeScript
import { createDirectoryFolioRegistry, resolveCafFile } from 'dte-sdk/node'
 
const folios = createDirectoryFolioRegistry({ dir: './data/folios' })
const file = await resolveCafFile({
  dir: './secrets',
  type: 33,
  issuerRut: '76543210-3',
  required: 1,
  registry: folios,
  environment: 'certification',
})
if (file === null) throw new Error('No CAF with free folios: request one')
const folio = await folios.reserve(file.scope!) // file.caf goes to issueDocument

The company is read from the file, never from the folder it lies in, so a CAF of another company in the same directory is not returned. The scope it gives includes the cafFingerprint of the file, and a scope without it is another entry of the registry: if you pick files this way, add cafFingerprint: cafFingerprint(obtained.xml) (from dte-sdk/portal) to the scope of step 5 of the program, so both use the same entry, or build that scope with cafFileScope(obtained, 'certification') from dte-sdk/node, the one resolveCafFile returns. Either matches the saved file because the program saves obtained.xml unchanged; a CAF rewritten before it is saved takes its scope from the text saved. findCafFiles lists every usable file, newest first, and findLatestCafFile gives the first one, with no registry. With includeExpired: true, findCafFiles also lists the expired files, marked expired, so you can say why one is not used; the other two never take one.

A registry kept with resolveCafFile before 0.7.0 may hold entries under an old fingerprint: a file saved with a UTF-8 byte order mark, or declared ISO-8859-1 with an Ñ or an accent, is read differently now, and its range would read as free. Move those entries to the new scope before issuing from such a file again, as CAF files explains.

Other ways to get folios#

requestCaf is the direct path. When the SII rations folios or the quantity does not fit, three flows from dte-sdk/portal cover the cases. They take the portal client of the program above and run inside one session.

TypeScript
import { createSiiClient, folioReceived } from 'dte-sdk'
import { requestCafExact, requestCafInBatches, reobtainFolios } from 'dte-sdk/portal'
 
const sii = createSiiClient({ environment: 'certification', certificate, companyRut: COMPANY_RUT })
 
// one CAF for the whole quantity, or nothing
const exact = await requestCafExact(portal, { type: 33, quantity: 50 })
 
// the same, but annulling unused folios if that lifts the limit (cannot be undone)
const forced = await requestCafExact(portal, {
  type: 33,
  quantity: 50,
  allowAnnul: true,
  exclude: [{ from: 1, to: 100 }], // a CAF still in use is never touched
  knownAnnulled: [], // what a previous run returned in knownAnnulled
})
 
// several CAFs when the SII rations by history
const batches = await requestCafInBatches(portal, { type: 33, quantity: 200, maxBatches: 4 })
 
// ranges the SII already authorized, without spending quota
const back = await reobtainFolios(portal, {
  type: 33,
  quantity: 20,
  isFolioFree: async (folio) => (await folioReceived(sii, { type: 33, folio })) === false, // tell the SII which folios are free
})
 
// a refusal is data here: the CAFs already obtained are not lost
for (const result of [exact, forced, batches, back]) {
  console.log(result.complete, result.cafs.length)
}
if (!exact.complete) console.log(exact.rejection, exact.portalMessage)
// a batch cut short by a failure, not by the SII, says so in failure
if (!batches.complete) console.log(batches.rejection ?? batches.failure)
// so do an exact request after its annulment and a re-obtaining; keep knownAnnulled either way
if (forced.failure !== null) console.log(forced.failure, forced.knownAnnulled)
if (back.failure !== null) console.log(back.failure, back.reason)
FunctionWhat it doesWhen to use it
requestCafExactRequests one CAF that covers the whole quantity; below it nothing is stamped. With allowAnnul: true, a limit caused by unused folios is lifted by annulling ranges (up to 20 per pass and four passes), never those in exclude, and it asks once more.You need all the folios in one file.
requestCafInBatchesCovers a quantity with several CAFs, asking for what maxAuthorized allows each time, up to maxBatches (4 by default). It stops as soon as a batch adds nothing, because every grant raises availableFolios and can lower the next maxAuthorized.The SII rations by history, there is nothing to annul and waiting does not help.
reobtainFoliosGets back ranges the SII already authorized, without spending quota, oldest folio first. isFolioFree confirms each folio at the SII, usually with folioReceived; isIssued skips ranges you already sent. An expired CAF is skipped and reported in expired.The CAF file was lost, or the SII asks you to issue with the folios you have before it stamps more.

They return a result instead of throwing the SII's refusals, so the CAFs already obtained are not lost. requestCafExact and requestCafInBatches return complete, cafs (each with its secret xml), granted, rejection (a PortalRejection or null), portalMessage (what the portal said) and failure; save every CAF in cafs before you read rejection. reobtainFolios returns complete, cafs, ranges, expired, available, reason and failure, and a failure while listing the ranges reads as no ranges, with the failure in reason. It logs in on demand like the others. Session, block and transport failures stay errors, except once a flow holds something you must not lose: then requestCafInBatches ends with the CAFs, rejection: null and failure: { code, operation }, reobtainFolios with the CAFs it downloaded and failure, and requestCafExact, once the SII has answered its annulment, with knownAnnulled, rejection: null and failure, so read failure too. The original error goes to the client's logger, its texts redacted and without what a page said or its address. A failed requestCaf may have been granted all the same: check listReobtainableRanges before asking again.

Annulling cannot be undone. requestCafExact annuls only with allowAnnul: true, and the portal counts annulled folios against your quota for six months. exclude protects a CAF still in use, and knownAnnulled must be persisted, whether failure is set or not, and passed back from one run to the next, because the portal keeps listing annulled ranges forever. Do not turn allowAnnul on until you are sure the range is not in use.

What can go wrong#

An SII refusal is SII_PORTAL_REJECTED, with details.rejection (the reason) and details.portalMessage (what the portal said). portalRejectionMessage(rejection) gives the meaning and what to do, in Spanish, for the operator. The composite flows return the same reasons in rejection instead of throwing.

rejectionWhat it means
stampingBlockedThe SII does not authorize stamping: you have enough unused folios. Issue documents with them. Annulling counts against the quota for six months.
quotaInsufficientThe SII authorizes fewer folios than you required (minQuantity), because there are stamped folios unused. Issue with them, annul them, or ask in batches.
quotaExceededThe quantity exceeds the most the SII authorizes per request for this type.
quotaCapReached, foliosShortThe SII granted fewer folios than required and has no more quota in this pass. quotaCapReached comes only after a grant, when one folio over the maximum was refused; any other refusal of a later batch keeps its own reason.
noAnnullableFoliosStamping is limited and no folios can be annulled: issue with what you have or wait for the SII to recalculate.
stampingUncertainThe SII showed the request form again, with no folios and no reason, so it is not known whether it authorized the range. Nothing was sent twice. Check the ranges that can be re-obtained (listReobtainableRanges) before asking again.
rangeAlreadyAuthorizedThe SII already authorized that range in an earlier request and did not return the file: re-obtain it from the portal. requestCafInBatches reports it on a later batch too.
rangeAnnulledThe range is annulled: the SII would reject any document with those folios.
companyNotAuthorizedThe company is not authorized as an electronic issuer in this environment: certification or enrollment is missing. Permissions of the user do not fix it.
optionNotAuthorizedThe SII says "no está autorizado para ingresar a esta opción" without a reason. Most often the company is not yet authorized in this environment; for invoices, guides and notes also check the Verificación de Actividades.
userWithoutPermissionThe RUT of the certificate has no permissions in the company in this environment: sign, send and get folios have to be assigned to it. The SII also says it as "No está autorizado para solicitar timbraje".
personWithPendingIssuesThe person has pending matters at the SII and cannot do electronic invoice procedures. Retrying does not fix it.
activityVerificationPendingThe RUT has no Verificación de Actividades for this document type. It is done online at sii.cl. It does not apply to receipts.
typeNotEnabledThe SII does not offer this document type for stamping to this RUT: it has to be certified or enabled.
inPersonRequiredThe enrollment has to be completed in person by the legal representative at the SII office of the company's address.

The other codes you can meet:

CodeWhat to do
SII_PORTAL_CERT_NOT_ENABLEDEnable the certificate as a login method at sii.cl.
SII_PORTAL_SESSION_LIMITThe SII reports too many sessions for this RUT. By default the client closes earlier sessions when the SII offers it (closePreviousSessions). Always call closePortalSession.
SII_PORTAL_LOGIN_FAILED, SII_PORTAL_SESSION_EXPIREDThe login gave no session, or the portal keeps asking to authenticate. A login page in answer to a range or its file, when re-obtaining, is SII_PORTAL_SESSION_EXPIRED too.
SII_PORTAL_BLOCKEDThe SII's firewall or a captcha blocked the request. It is not solved by retrying in a loop.
SII_PORTAL_UNEXPECTED_PAGEThe portal answered a page the SDK could not interpret. Since the folio pages of an enrolled company have not been seen yet, this is what you would meet if the real pages differ from the ones the SDK was written for: open an issue on GitHub with a capture.
CAF_INVALIDThe file the portal returned, or the one you read, is not a valid CAF, or its private key is not the pair of its public key.
TRANSPORT_FAILED, TRANSPORT_TLS, REQUEST_TIMEOUT, SII_HTTP_ERRORThe network or the SII failed. The portal client retries transport failures, timeouts, HTTP 502, 503 and 504 and the intermittent TLS failures of the SII; a session limit, a block or a refusal is never retried, because that makes them worse.

To get a trace for the SII or for an issue, give the portal client capture: createDirectoryCapture({ dir }) from dte-sdk/node: it writes every exchange with the secrets redacted.

Next steps#

  • Issue an invoice to send the invoice you just issued.
  • Portal for everything else the portal client does: annulment, the purchase and sales register and the MIPYME backup.
  • Certification and its runner, which gets its folios through these same functions.
  • Security for how to keep a CAF and a certificate.