Skip to content

SII portal and folios

The SII portal is the certificate-authenticated website where a company manages its electronic issuing: its resolution, its taxpayer data and, above all, its folios. dte-sdk/portal drives it for you. You get a session that is reused, company data, the activity verification the SII requires before you issue with VAT, the stamping quota, CAF requests, annulment of unused folios, the re-download of a CAF the SII already authorized, and a local registry that remembers which folio of which CAF you have used.

The portal is the least proven part of the SDK. Everything here is tested against synthetic SII answers, with a 100 % coverage gate in CI. Against the real SII, only this has been measured, for a company that is not enrolled as an issuer: in production, on 9 and 10 November 2026, the certificate login and the session reused between reads, and in earlier runs whose environment wasn't written down, the login, the logout, the taxpayer data and the refusal pages. The activity verification was read for a pending verification and, on 9 November, for a conditioned one pending accreditation. The pages of an enrolled company (folio quota, CAF download, folio ranges) have not been seen yet. See Status.

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. See Status.

Basic usage#

TypeScript
import { readFileSync } from 'node:fs'
import { loadCertificateFromPkcs12 } from 'dte-sdk'
import {
  closePortalSession,
  createPortalClient,
  fetchIssuerProfile,
  queryFolioQuota,
  requestCaf,
} from 'dte-sdk/portal'
 
const certificate = loadCertificateFromPkcs12(readFileSync('firma.pfx'), process.env.PFX_PASSWORD!)
 
const portal = createPortalClient({
  environment: 'certification',
  certificate,
  companyRut: '76543210-3',
})
 
// Resolution of this environment, name, address and activity of the company
const issuer = await fetchIssuerProfile(portal)
 
// Reads the quota and stops: nothing is stamped and the quota does not move
const quota = await queryFolioQuota(portal, { type: 33 })
quota.maxAuthorized // 50, or null while the SII is not rationing this type
 
// Asks for 50 folios and downloads the CAF
const { xml, caf, granted } = await requestCaf(portal, { type: 33, quantity: 50 })
granted // read it: the SII may grant fewer folios than you asked for
 
await closePortalSession(portal)

There is no login() to call. Every function takes the client as its first argument and logs in on demand. closePortalSession is the polite way to give the session back when you are done.

Before you start#

What the portal is#

The SII has two kinds of surfaces. The web services (SOAP and REST) take signed XML and answer XML: see SII web services. The portal is the website a person uses at sii.cl: forms and pages that answer HTML. dte-sdk/portal logs in the way a browser does, fills the forms and reads the HTML that comes back. That is why it is slower and more fragile than the web services, and why a change in the SII's pages can break it.

It needs Node#

The portal authenticates with your certificate as a TLS client certificate, and fetch cannot present one. The default transport uses Node https with the certificate, verifies the server and stays on TLS 1.2. dte-sdk/portal runs on Node only. See Entry points.

Enable the certificate as a login method#

This is a manual step at sii.cl, done once per person, and the SDK cannot do it for you. In the SII's site, under Cambiar Clave y datos de autenticación, choose the option "Utilizando Clave Tributaria o Certificado Digital". Until you do, the login throws SII_PORTAL_CERT_NOT_ENABLED.

The certificate's holder is the portal user. The company is named per call (companyRut) or once in the client. If the holder has no permission in that company in that environment, the SII refuses with userWithoutPermission.

Create the client#

TypeScript
const portal = createPortalClient({
  environment: 'production',
  certificate,
  companyRut: '76543210-3',
  retry: { maxAttempts: 3 },
  logger: console,
})

createPortalClient validates the options and returns a frozen PortalClient: plain data with every field resolved. It throws INVALID_OPTION (with details.field) for an unknown environment or a non-positive sessionTtlMs or timeoutMs, MISSING_FIELD without a certificate, and INVALID_RUT for a malformed companyRut.

OptionDefaultWhat it does
environmentrequired'certification' or 'production'. Picks the SII host of the DTE pages (maullin or palena).
certificaterequiredThe certificate presented at login. Its holder is the portal user.
companyRutnoneThe company every operation uses unless the call passes its own companyRut. Without either, the call throws MISSING_FIELD.
transportNode https with the client certificate, TLS 1.2The HTTP transport. Pass your own to test or to route through a proxy.
sessionCachein memoryA TokenCache (get, set, delete of { token, expiresAt }) where the session lives. createDirectoryTokenCache({ dir }) from dte-sdk/node keeps it on disk.
sessionTtlMs90 minutesHow long a cached session is trusted. Refreshed on each use.
lockin-process mutexA PortalLock: who may use the session now.
retry4 attempts at 2, 4 and 8 sA partial RetryPolicy merged over the portal default, or false to turn retries off. See Retries.
timeoutMs20000Timeout of each request, in milliseconds.
closePreviousSessionstrueWhen the SII reports the session limit and offers to close the earlier sessions, accept.
loggersilentA Logger with debug, info, warn and error. It receives operation names, attempts, statuses and codes, never tokens or XML in the message.
captureoffAn HttpCapture port that receives every HTTP exchange, for debugging.
nownew Date()The clock.
sleepa real waitThe wait between retries and between steps. Inject one to make tests instant.
randomIdcrypto.randomUUIDSource of the transaction ids of the purchase and sales register.

Every portal function takes a last, optional overrides argument: { signal, timeoutMs, retry }. Use it to cancel a call with an AbortSignal, to change one call's timeout, or to change its retry policy.

Sessions#

TypeScript
import { closePortalSession, openPortalSession } from 'dte-sdk/portal'
 
const { userRut, reused } = await openPortalSession(portal)
// userRut: the RUT the session belongs to (the certificate's holder), when the cookies say
// reused: true when a cached session was still alive
 
await closePortalSession(portal)

You do not need openPortalSession: every operation logs in when the portal asks for it. Call it when you want to fail early, for example at start-up (it throws SII_PORTAL_CERT_NOT_ENABLED if you forgot to enable the certificate).

How sessions behave:

  • They are reused. The session's cookies are saved in sessionCache after each operation, with an expiry of sessionTtlMs (90 minutes by default, refreshed on each use; the SII tolerates about two hours of inactivity). The next operation loads them instead of logging in again. openPortalSession checks a cached session with a read of the company page (ad_empresa1) of the client's environment and logs in again if the SII no longer answers for it; the check of a saved session before the GWT portals and the receipts' certification reads the same page. One session serves both environments. Before 0.7.0 the check read the page of the certification host whatever the client's environment, so a production client depended on maullin being up.
  • A cached session is a credential. The cache holds the session cookies. If you replace the in-memory cache with Redis or a file, protect it as you would the certificate.
  • Memory by default, disk on request. With the default sessionCache a restart logs in again. createDirectoryTokenCache({ dir }) from dte-sdk/node keeps the session on disk, one owner-only file per certificate, and a restart reuses it. Give it a directory of its own. It never throws: an entry it cannot read or write is a miss, reported only to its logger.
  • One session per certificate. The cache key and the lock key are derived from the certificate. The SII rations authenticated sessions per RUT, and its balancer cookies break when two requests of one session overlap. The default lock is a mutex inside one process, so two operations on the same certificate run one after the other. It holds within one process only: two processes that share the directory of a disk cache and run at the same time can send requests of one session together or replace each other's session. Give them a shared lock too, or keep one process per certificate.
  • Several replicas need shared ports. If more than one process uses the same certificate, give them a shared sessionCache (Redis, a database) and a shared lock (a distributed lock), so they take turns on one session instead of opening one each and running into the session limit. PortalLock is (key, run) => Promise: run starts only when the lock is held, and the lock must be released when it settles, even if it fails. createMemoryPortalLock() is the in-process version.
  • The session limit. If the SII reports too many sessions for the RUT, the SDK closes the earlier ones when the SII offers it (unless closePreviousSessions is false) and tries once more. If that does not help, it throws SII_PORTAL_SESSION_LIMIT. It never repeats this error in a loop.
  • closePortalSession logs out to give the slot back and always removes the cached session. A failed logout is only logged: the SII expires the session on its own.
TypeScript
import { createDirectoryTokenCache } from 'dte-sdk/node'
 
// The session on disk: a restart reuses it
const portal = createPortalClient({
  environment: 'production',
  certificate,
  companyRut: '76543210-3',
  sessionCache: createDirectoryTokenCache({ dir: './portal-sessions' }),
})
TypeScript
// A shared lock and cache, sketched with your own infrastructure
const portal = createPortalClient({
  environment: 'production',
  certificate,
  companyRut: '76543210-3',
  sessionCache: myRedisTokenCache, // { get, set, delete }
  lock: (key, run) => myDistributedLock(key, run),
})

Company data#

TypeScript
import {
  fetchCompanyResolution,
  fetchIssuerProfile,
  fetchTaxpayerData,
  queryCertificationSets,
} from 'dte-sdk/portal'
 
const resolution = await fetchCompanyResolution(portal, { environment: 'production' })
resolution.resolutionNumber // the number of the resolution that authorizes the company
resolution.resolutionDate // 'YYYY-MM-DD'
 
const taxpayer = await fetchTaxpayerData(portal)
taxpayer.businessName
taxpayer.activities // [{ code, description, vatAffected }]
taxpayer.branches // [{ code, kind, address }]
 
const profile = await fetchIssuerProfile(portal)
FunctionReturnsNotes
fetchCompanyResolution(portal, { companyRut?, environment? })CompanyResolution: rut, businessName, resolutionDate, resolutionNumber, authorizationDate (any can be null)Per environment. Certification and production answer different dates and numbers for the same RUT, and the wrong pair gets an envelope rejected with "Error en Carátula". Throws SII_PORTAL_REJECTED with companyNotAuthorized when the SII does not recognize the company as an issuer in that environment.
fetchTaxpayerData(portal, { companyRut? })TaxpayerData: name, address in its parts, activities, branches, regional office, descriptionThis page answers only on the certification host, whatever the client's environment.
fetchIssuerProfile(portal, { companyRut?, environment? })IssuerProfile: rut, businessName, businessActivity, activityCode, address, commune, city, resolutionDate, resolutionNumberThe two reads above, in sequence, combined into what an Issuer and an envelope cover need. Without a company on the call or the client, it uses the RUT of the session. A failing taxpayer read is logged and tolerated as long as the resolution date arrived.
queryCertificationSets(portal, { companyRut? }){ sets, allSetsApproved, allBooksApproved }Reads the state of each test set and book of the certification application, on the certification host. It only reads. See Certification.

fetchIssuerProfile runs its two reads one after the other on purpose: in parallel, one answer overwrites the balancer cookie the other needs.

Activity verification#

The SII lets a company issue documents with VAT only once it has verified the company's activities on site with a positive result, or while that verification is in progress (the SII's requirements). When the verification is missing, a CAF request for a type that needs it is refused with activityVerificationPending. Since 0.3.0 the portal session reads it too, from the SII's "Consulta estado de verificación y acreditación":

TypeScript
import { listAccreditationDocuments, unstable_fetchActivityVerification } from 'dte-sdk/portal'
 
const { requests, messages } = await unstable_fetchActivityVerification(portal)
for (const request of requests) {
  request.status // a code such as 'PAC'
  request.statusDescription // 'PENDIENTE DE ACREDITACIÓN'
  request.accreditationDeadline // 'YYYY-MM-DD', or null when there is none
}
const blocking = messages.filter((message) => message.blocking)
 
// The documents the SII takes to accredit an activity, general and specific
const documents = await listAccreditationDocuments(portal)

unstable_fetchActivityVerification is unstable: its shape may change in any release, a patch included. It has been read against the real SII for a pending verification and, on 9 November 2026, for a conditioned one pending accreditation, and the codes of an approved one are not known yet, so nothing in the result says by itself whether the company may issue with VAT. Once an approved verification has been read it becomes fetchActivityVerification.

  • It asks for the company, not for the session. The SII's own page shows the verification of whoever logged in. This sends the company's RUT, and a request of another RUT throws SII_PORTAL_UNEXPECTED_PAGE with step: 'otherTaxpayer' instead of passing as the company's.
  • No requests means the company never asked for the verification. An error code from the SII throws SII_PORTAL_REJECTED with activityVerificationRefused, its words in details.portalMessage.
  • Officials, comments and the address never come out, and there is no raw.
  • Both read production from any client, because the app exists only there.

Every field, and what each one throws: API reference.

Folios#

A folio is a number the SII lets you use on a document. A CAF (Código de Autorización de Folios) is the XML file that authorizes a range of folios for a document type and carries the private key that signs the stamp of each document. You request CAFs from the portal.

The CAF XML holds a private key. requestCaf and reobtainCaf return it in xml. Treat it as a secret: do not log it, do not commit it, do not put it in an error message or a ticket. Where you keep it is up to you. See Security.

Quota and types#

TypeScript
import { listStampableTypes, queryFolioQuota } from 'dte-sdk/portal'
 
const types = await listStampableTypes(portal)
// [{ type: 33, name: '...' }, ...]: the type code and its name as the portal shows it
 
const quota = await queryFolioQuota(portal, { type: 33 })
// { maxAuthorized: 50, availableFolios: 20, unrestricted: false, blocked: false }
  • listStampableTypes lists the document types the portal offers for stamping with this company. An empty list is a valid answer; an error page is not, and throws SII_PORTAL_UNEXPECTED_PAGE instead of reading as "no types".
  • queryFolioQuota reads how many folios the SII authorizes for a type and stops at the form that shows it. Nothing is stamped.
    • The form it reads is the one whose selector has the type chosen. The company's page, with no type chosen, is not that form: the type is chosen first. A last page that is not that form is never read as an unrationed type: it throws SII_PORTAL_SESSION_EXPIRED if it asks to log in and SII_PORTAL_UNEXPECTED_PAGE otherwise.
    • maxAuthorized (the SII's MAX_AUTOR) is the most folios it authorizes now. availableFolios (FOLIOS_DISP) is how many folios you have stamped and not used. Both appear only while the SII is rationing the type. When it is not, both are null and unrestricted is true.
    • blocked is true when the SII answers "NO SE AUTORIZA TIMBRAJE". That one comes back as data, not as an error.
    • Any other refusal throws SII_PORTAL_REJECTED, for example typeNotEnabled when the SII does not offer the type for this RUT.

Request a CAF#

TypeScript
import { requestCaf } from 'dte-sdk/portal'
 
const obtained = await requestCaf(portal, {
  type: 33,
  quantity: 100,
  minQuantity: 100, // below this, stamp nothing
})
 
obtained.xml // the CAF file as the SII served it: a secret
obtained.caf // the same file parsed (see parseCaf)
obtained.granted // how many folios it covers
obtained.maxAuthorized // the MAX_AUTOR seen, or null
obtained.availableFolios // the FOLIOS_DISP seen, or null
obtained.recovered // true when it came back through "ya fue autorizado" or the re-download flow

requestCaf goes through the portal's steps (the request, the confirmation of the first folio, the generation and the download of the file, which it retries a few times if the portal fails) and returns an ObtainedCaf. A refusal can arrive at any step.

The grant can be smaller than the request. Read granted, not quantity:

  • While the SII rations the type, requestCaf caps the request at maxAuthorized.
  • If the SII answers that the effective maximum was exceeded and you gave no minQuantity, requestCaf asks once more for one folio.
  • If the SII answers that the range was already authorized (an earlier request got the range but the file was lost), requestCaf tries to download it again and returns it with recovered: true.
  • Every CAF downloaded is parsed with parseCaf, so one whose private key is not the pair of its public key reaches the call as CAF_INVALID, as a malformed one does. A CAF the SII issued always pairs.
  • minQuantity is the strict form. If the SII would authorize less than minQuantity, nothing is stamped and the call throws SII_PORTAL_REJECTED with quotaInsufficient. A short range goes unused, and unused folios lower the next maximum (see Rationing).
  • If the SII answers a request with the request form again, with no folios and no reason, nothing is sent a second time: requestCaf throws SII_PORTAL_REJECTED with stampingUncertain, because the folios may have been granted. Check the ranges that can be re-obtained (listReobtainableRanges) before asking again. A refusal the page does state is reported as that refusal.
  • A captcha on the confirmation step throws SII_PORTAL_BLOCKED.

type must be a supported document type (INVALID_DOCUMENT_TYPE), and quantity and minQuantity positive integers (INVALID_OPTION). requestCaf waits about a second after each request, so that requests on one certificate stay apart.

List and annul unused folios#

TypeScript
import { annulFolios, listFolioRanges } from 'dte-sdk/portal'
 
const { ranges, lastFolio } = await listFolioRanges(portal, { type: 33 })
// ranges: [{ from: 1, to: 50, quantity: 50, date: '2026-10-05' }, ...]

listFolioRanges follows the pages of the SII's annulment listing and changes nothing. It returns every range with unused folios, annulled ranges included: the SII does not tell them apart and keeps listing them forever.

TypeScript
const result = await annulFolios(portal, {
  type: 33,
  range: { from: 41, to: 50 }, // one of range, withinDays or all is required
  exclude: [{ from: 1, to: 40 }], // never touch the CAF still in use
  knownAnnulled: previousRun.knownAnnulled, // from the last call
})
 
result.annulled // [{ from, to, count }]
result.rejected // [{ from, to, count, reason }]
result.totalAnnulled
result.totalRejected
result.knownAnnulled // persist this and pass it back next time
result.failure // null, or what cut the walk short

Annulling cannot be undone, and annulled folios count against the stamping quota for six months. To lift a stamping block, issue documents with the folios you already have. Annulling makes the block worse.

Because of that, annulFolios refuses to run without an explicit scope (INVALID_OPTION with field: 'range'). Pick one:

ScopeEffect
range: { from, to }Only the unused folios inside that range. A listed range that straddles it is clipped.
withinDays: nOnly ranges stamped in the last n days. A range with no readable date is skipped.
all: trueEvery unused range, up to maxRanges per pass.

How a call walks the listing:

  • Each pass reads the listing, drops the ranges out of scope, the ones in exclude (a range that overlaps any excluded span is skipped whole) and the ones whose folios the keys of knownAnnulled already cover, in one key or several, then tries the last maxRanges ranges it has not tried yet (50 by default).
  • There are up to four passes, with a short pause between them, so maxRanges limits a pass and not the call: one call can annul up to four times maxRanges ranges (200 by default).
  • A pass stops after two real refusals in a row. alreadyAnnulled and received do not count as refusals: they are expected results.
  • A range of several folios that the SII refuses because part of it was annulled or received before is annulled folio by folio. A single folio keeps the SII's answer instead of being sent again.
  • A range answered folio by folio comes back in as few pieces as its answers allow. Folios next to each other with the same answer are one entry and one key in annulled, rejected and knownAnnulled, as when a step retried after its answer was lost hears "ya fue anulado" for each folio the first try annulled. Before 0.7.0 each folio was its own entry and its own key. A walk cut short at one folio (a block, an expired session) still returns the folios answered before it, with failure.
  • Every query of the listing and every step is tried up to three times, 1.5 s apart, whatever the error, on top of the retries of the client. A step that changed something on a try that failed is recovered by the "already annulled" answer of the next one.
  • A transport failure on one range is recorded as reason: 'transport' and the walk goes on. A block or a session limit ends the whole call: before the SII has answered for any folio it throws, and after that the call returns what it did, with failure.

rejected[].reason is one of alreadyAnnulled, received (the SII reports a folio of the range as already received), siiError, transport or unknown. unknown is an answer in no known words: those folios may be annulled or not, so it is not final and never enters knownAnnulled, and a later run tries them again. An answer counts only by a concrete sentence ("ha autorizado la anulación" or the heading SOLICITUD ANULACION DE FOLIOS, received, annulled before, error 500); the sentences every page of the procedure carries, such as "folios que no han sido recepcionados por el SII", are not read as answers. When the SII reports a range as annulled before, alreadyAnnulled is only for the folios that page lists ("Folio Inicial Anulado", "Folio Final Anulado") and the rest of the range is unknown.

Pass knownAnnulled back. It holds the from-to keys of ranges whose outcome is final (annulled, alreadyAnnulled or received). Persist it between runs, because the portal lists annulled ranges forever and without it the next call tries them again. Read its keys as spans of folios, not as the listing's ranges: a range answered folio by folio may be kept in several keys, and a listed range is skipped when the keys cover all its folios, where before 0.7.0 only its exact key did.

An annulment cut short keeps what it did. Once the SII has answered for some folios, a DteError that ends the walk (an expired session, a block, an abort you requested, a later listing refused or not understood) no longer throws away what was done: the call returns annulled, rejected and knownAnnulled as they stand, with failure: { code, operation }. operation is listFolioRanges or annulFolios, and failure.rejection holds the kind of refusal when the SII refused a later listing. Before 0.6.0 such a failure threw, the folios already annulled never reached knownAnnulled, and the next run posted their annulment again only to hear "ya fue anulado". A range that never got through, recorded as transport, is no answer, so before any answer the error is still thrown, and anything that is not a DteError always throws, because it is a bug. failure is null on a walk that finishes. Persist knownAnnulled whether failure is set or not.

Re-obtain a CAF that is already authorized#

If the SII authorized a range and you lost the file, you can download it again without spending quota.

TypeScript
import { listReobtainableRanges, reobtainCaf } from 'dte-sdk/portal'
 
const ranges = await listReobtainableRanges(portal, { type: 33 })
// [{ from, to, quantity, annulled, formFields, downloadFields }, ...]
 
const free = ranges.find((range) => !range.annulled && range.from === 101)
if (free) {
  const caf = await reobtainCaf(portal, { type: 33, range: free })
  caf.recovered // true; caf.maxAuthorized and caf.availableFolios are null
}
  • The listing holds ranges you have already issued, and the SII marks none of them. Re-using a range you already issued gets every document rejected with DTE-3-101. Keep your own record (the folio registry) and filter by it.
  • The listing does not tell annulled ranges apart either. listReobtainableRanges finds out with one extra request per range and sets annulled.
  • listReobtainableRanges enters the procedure like the other functions: it logs in on demand and goes through the representation screen. Only a listing gives a list, which is empty when there are no ranges. A page that states a refusal throws SII_PORTAL_REJECTED, a login page SII_PORTAL_SESSION_EXPIRED and any other page SII_PORTAL_UNEXPECTED_PAGE.
  • reobtainCaf enters the procedure first, as listReobtainableRanges does, logging in on demand and passing the representation screen, so a cached session that expired since the listing logs in again instead of posting the range with it. That is one GET more, of rf_reobtencion1_folios. A page that states a refusal at the entry throws SII_PORTAL_REJECTED, and any other page that is not the procedure SII_PORTAL_UNEXPECTED_PAGE, before anything is posted.
  • reobtainCaf throws SII_PORTAL_REJECTED with rangeAnnulled, reobtainFormMissing, reobtainDownloadMissing or reobtainCafMissing. A login page in answer to the range or to the file is SII_PORTAL_SESSION_EXPIRED, never one of those refusals: before 0.7.0 an expired session read as reobtainDownloadMissing.

Composite flows#

Three functions combine the steps above. They hold one session for the whole flow, and they return refusals as data, not as thrown errors. The reason: a flow can already hold CAFs when the next step is refused. Those CAFs must reach you, and a CAF cannot travel inside an error's details, which get logged. Transport failures, blocks and an expired session still throw, except once a flow holds something you must not lose (CAFs, or folios the SII annulled): from then on they come back as data too, in failure.

requestCafExact and requestCafInBatches return a FolioFlowResult; reobtainFolios has its own result, below:

FieldMeaning
completeThe quantity asked for was covered.
cafsEvery CAF obtained, even when the total falls short. Save them before anything else.
grantedFolios covered by cafs.
maxAuthorized, availableFoliosThe last MAX_AUTOR and FOLIOS_DISP seen, or null.
rejectionWhy it fell short (a PortalRejection key), or null.
portalMessageWhat the portal said, its secrets redacted, or null.
failureWhat stopped the flow while it held something you must not lose ({ code, operation, rejection? }), or null; rejection is then null. requestCafInBatches sets it with CAFs in hand, requestCafExact after an annulment the SII has answered.

A flow cut short logs the error it keeps only the code of. failure keeps the error's code, and the kind of a refusal in failure.rejection when code is SII_PORTAL_REJECTED, because the other details may carry what a page said. The original goes first to the client's logger, as the warning sii portal flow stopped, keeping what it holds, with the error's details (their operation names the step that failed), flow with the flow's name, its code and stoppedAt. Every text detail goes through redactText first, so a token, an RSASK or a session cookie reaches the log as [redacted]. requestCafInBatches, requestCafExact, reobtainFolios and annulFolios all log it, so the original's reason (ECONNRESET, a timeout), httpStatus and attempts are no longer lost. The warning leaves out what a page said (portalMessage, body) and the url it came from, since they can name the company and its RUT; the thrown errors keep them. Before 0.7.0 those three reached the log too, with only their secrets redacted.

One CAF for the whole quantity: requestCafExact#

TypeScript
import { requestCafExact } from 'dte-sdk/portal'
 
const result = await requestCafExact(portal, {
  type: 33,
  quantity: 200,
  allowAnnul: false, // the default
})
 
if (!result.complete) console.error(result.rejection, result.portalMessage)

It asks for one CAF that covers the whole quantity, with minQuantity equal to quantity: below it, nothing is stamped. With allowAnnul: true, a limit caused by unused folios (quotaInsufficient or stampingBlocked) is lifted by annulling and asking once more:

  • It annuls up to 20 ranges in each of up to four passes (80 ranges in all), never the ones in exclude.
  • If availableFolios is 0, nothing is annullable and the attempt is skipped. While the type is blocked the field is absent, and that case does try.
  • If the annulment frees nothing, it returns rejection: 'noAnnullableFolios'.
  • The result carries knownAnnulled: persist it and pass it back, whether failure is set or not.
  • An annulment cut short keeps its memory. Once the SII has answered the annulment, a DteError that cuts it short, or one of the request asked once more after it, ends the flow with complete: false, rejection: null, the first request's quota, knownAnnulled with what was annulled and failure: the annulment's (listFolioRanges or annulFolios) or the request's (requestCaf). A listing the SII refuses during the annulment keeps its kind in failure.rejection, since rejection speaks of the request. A cut-short annulment is not followed by another request. Before the annulment's first answer it still throws. Before 0.6.0 failure was always null here: read it as on requestCafInBatches.

allowAnnul is off by default because of the irreversibility above.

Several CAFs: requestCafInBatches#

TypeScript
import { requestCafInBatches } from 'dte-sdk/portal'
 
const result = await requestCafInBatches(portal, { type: 39, quantity: 500, maxBatches: 4 })

For when FOLIOS_DISP is 0 and MAX_AUTOR is small: the SII rations by history, there is nothing to annul or download again, and waiting does not move it. Each batch reads the quota, asks for the smaller of maxAuthorized and what is missing, and adds the CAF. It stops as soon as a batch adds nothing, because every grant raises FOLIOS_DISP and can lower the next MAX_AUTOR. maxBatches (default 4) limits the trips to the portal, and initialQuota lets you pass a quota you already read to skip the first reading. When it stops short, rejection says why, on the first trip or a later one. After a grant, one folio over the maximum (quotaExceeded) is quotaCapReached: the SII has no more to give in this pass. Any other refusal comes back with its own kind and portalMessage: a range the SII authorized without handing its file over is rangeAlreadyAuthorized, which listReobtainableRanges and reobtainCaf download again, and a refusal of the company, the option or the person is that refusal. If the SII answers a request with the request form again and no folios, it is stampingUncertain. The CAFs already obtained stay in cafs in every case. Before 0.7.0 every refusal of a later trip but stampingUncertain read as quotaCapReached: if you read that as "stop asking", the other kinds now reach you after a grant too.

A batch cut short keeps what it got. Once it holds a CAF, a later trip that fails with anything but a refusal (a transport failure, a timeout, an expired session, a block, CAF_INVALID, an abort you requested) ends the flow with the CAFs, complete: false, granted so far, the last quota seen, rejection: null and failure: { code, operation }. operation is queryFolioQuota or requestCaf, and only the error's code is kept, because its details may carry what a page said. Before 0.5.0 such a failure threw, and the CAFs earlier trips had brought were lost. With no CAF in hand it still throws, an abort you requested too, and anything that is not a DteError always throws, because it is a bug.

TypeScript
if (!result.complete && result.failure !== null) {
  // cut short with CAFs in hand: save result.cafs first
  console.error(result.failure.code, result.failure.operation)
}
  • Read failure as well as rejection. A short result with rejection: null is a batch cut short, not a smaller grant.
  • A failed requestCaf may have been granted all the same. Check listReobtainableRanges before asking again.
  • quantity must be a whole number of at least 1, or the call throws INVALID_OPTION with field: 'quantity' before anything goes out.

Get lost CAFs back: reobtainFolios#

TypeScript
import { reobtainFolios } from 'dte-sdk/portal'
 
const result = await reobtainFolios(portal, {
  type: 33,
  quantity: 50,
  isIssued: (range) => registryHas(range), // your record of ranges already sent
  isFolioFree: async (folio) => !(await folioWasReceived(folio)), // for example with folioReceived
})
 
result.cafs // the CAFs downloaded
result.ranges // [{ from, to, freeFolios }] each range used
result.expired // ranges whose CAF already expired: they do not count, annul them
result.available // free folios covered
result.reason // why it fell short, in words, or null
result.failure // null, or the download that cut the walk short

It covers a quantity from ranges the SII already authorized, oldest folio first, without spending quota. This is what lifts a stamping block, because the SII's own advice is to issue documents with the folios you have. In detail:

  • It skips annulled ranges and the ones isIssued says you already sent.
  • Free folios. Without isFolioFree, every folio of a range counts as free. That is dangerous, because the portal does not mark issued folios. With isFolioFree (typically a call to folioReceived of the web services client, see SII web services), each folio is checked, up to maxChecksPerRange per range (30 by default). A range with some received folios is not discarded: its free folios are used.
  • If the usable ranges do not reach the quantity, it downloads nothing and returns available with a reason.
  • A CAF that has already expired (or expires within a day) does not count: it goes to expired and the walk moves on. The SII rejects every document stamped with an expired CAF.
  • It logs in on demand, like the other functions, when it lists the ranges.
  • It does not throw on a failed listing. Whatever fails while listing (a refusal page, a transport failure, an expired session, a page that is not the listing) reads as "no ranges", with the failure's message in reason. Only an abort you requested (REQUEST_ABORTED) is thrown. A refused download is logged and skipped.
  • A walk cut short keeps what it downloaded. Once a CAF is in hand, a download that fails with anything but a refusal (a transport failure, a timeout, an expired session, a block, an abort you requested) ends the walk with complete: false, the CAFs, their ranges, the failure's message in reason and failure: { code, operation: 'reobtainCaf' }. Before 0.6.0 it threw, and the CAFs earlier downloads had brought had to be downloaded again. With no CAF in hand it still throws, and anything that is not a DteError always throws, because it is a bug.
  • A login page is an expired session, not a refused download. A login page in answer to a range or to its file is SII_PORTAL_SESSION_EXPIRED: the walk ends on it with the CAFs already in hand and failure, or throws it with none. Before 0.7.0 it read as a refused download and the walk skipped the range.

When it falls short, ask only for the difference: quantity minus available.

Rationing, and why to issue with what you have#

The SII does not hand out folios without limit. It sets MAX_AUTOR per document type from your history of stamping and issuing, and it counts FOLIOS_DISP: the folios you stamped and have not used. Unused folios lower what it authorizes, and enough of them block stamping altogether ("NO SE AUTORIZA TIMBRAJE").

What follows from that:

  • To lift a block, issue with the folios you have. That is what the SII asks for. reobtainFolios gets back the CAFs you lost without spending quota.
  • Do not stamp more than you will issue soon. A short or oversized range left unused hurts the next quota.
  • Do not annul to "tidy up". Annulling cannot be undone and the annulled folios count against the quota for six months. Use it on purpose, with a scope you chose.
  • requestCafInBatches is for a small MAX_AUTOR with nothing unused to annul or recover. requestCafExact wants one CAF for the whole quantity.

The local folio registry#

The portal tells you what the SII has authorized. It does not tell you which folio of which CAF you have already put on a document. The folio registry does: a local ledger of reserved, sent and annulled folios, per CAF.

TypeScript
import { cafFingerprint, createMemoryFolioRegistry } from 'dte-sdk/portal'
import { createDirectoryFolioRegistry } from 'dte-sdk/node'
 
const registry = createDirectoryFolioRegistry({ dir: './folios' })
// or, for tests and short runs: createMemoryFolioRegistry()
 
const scope = {
  companyRut: '76543210-3',
  type: 33,
  range: obtained.caf.range, // { from, to } of the CAF
  environment: 'certification' as const,
  cafFingerprint: cafFingerprint(obtained.xml), // tells two files of one range apart
}
 
const folio = await registry.reserve(scope) // the next free folio
// ... issue the document with this folio and send it ...
await registry.markSent(scope, folio, { trackId: '0234567890' })
 
await registry.remaining(scope) // folios of the range still unused
await registry.stats({ companyRut: '76543210-3', type: 33 })
// { totalEntries, totalUsed, totalSent,
//   ranges: [{ from, to, environment, cafFingerprint, used, sent, remaining }] }

A FolioScope is one CAF for one company in one environment: companyRut, type, range, environment and, optionally, cafFingerprint. cafFingerprint(xml) is the first 12 hex characters of the SHA-256 of the CAF file. Pass it so that a range you re-obtained does not share an entry with the first file of that range. For a CAF file on disk, cafFileScope(file, environment) from dte-sdk/node builds this scope from the file's text and its CAF, and it is the scope resolveCafFile returns (see CAF files). It matches the saved file only when given the text the file holds: if you rewrite a downloaded CAF before saving it, its declaration say, take the scope from the text you saved.

MethodWhat it does
reserve(scope, options?)Hands out the next free folio and records it as used. Throws FOLIO_OUT_OF_RANGE past the end of the range.
markSent(scope, folio, { trackId?, sentAt?, extra? })Records a folio as sent. It counts as used even if you never reserved it here. Throws FOLIO_OUT_OF_RANGE for a folio outside the range.
markAnnulled(scope, folios)Records folios as annulled at the SII. No entry of the same company, type and environment will reserve them again.
release(scope, folios)Gives folios back: they leave the used folios and any sent record. For a folio you reserved and never sent.
remaining(scope)The folios of the range minus the used ones.
stats({ companyRut, type, environment? })Totals per CAF for a company and type. Each range carries the environment and cafFingerprint of its entry (null when its scope has none), so two files of one range, or one range in both environments, are told apart.

How reserve chooses. It starts at the lowest folio reserved and not yet sent, or after the last folio reserved if there is none, and walks forward past used and annulled folios. It never hands out a used or annulled folio, and it never goes back to a free folio below its starting point. So a reserved-but-unsent folio (for example after a crash) comes back first, and a gap left below a sent folio is not filled.

{ lowestFree: true } starts at the first folio of the whole range instead, so a folio you gave back, or left free below one marked sent, goes out again. The certification run asks for this, because it marks as used the folios the SII did not show free. Use it only with a registry that records every folio the range has spent. See Certification.

Which one to use:

  • createMemoryFolioRegistry() lives in memory. For tests and short runs.
  • createDirectoryFolioRegistry({ dir }) (from dte-sdk/node) keeps one folios.json, written atomically (a temporary file of its own, created exclusively, and a rename), so a reader never sees half a ledger. It keeps it for its owner only, as the token cache does, because the ledger holds the companies' RUTs, their track ids and what you keep in extra: the directory is created with mode 0700 (one that exists keeps its permissions) and the file with 0600. It is for a single process. The directory must survive restarts: if you lose it, you repeat folios at the SII.
  • createFolioRegistry(store) builds a registry over a FolioLedgerStore of your own (load and save of one JSON-friendly FolioLedger). Calls run one after another inside this process, each reading the ledger fresh. Several processes need a store or a registry of their own that takes care of it.
  • Your own database. A service with a database implements the FolioRegistry type (reserve, markSent, markAnnulled, release, remaining, stats, whose ranges carry environment and cafFingerprint since 0.7.0) with its own transactions: reserving a folio must be atomic, or two workers will issue the same number. Honour lowestFree if the registry is to back a certification run.

Errors and refusals#

Everything the portal throws is a DteError (see Errors). The portal's own codes:

CodeWhen
SII_PORTAL_CERT_NOT_ENABLEDThe certificate is not enabled as a login method at sii.cl.
SII_PORTAL_LOGIN_FAILEDThe login gave no session.
SII_PORTAL_SESSION_LIMITThe SII reports too many authenticated sessions for this RUT, and closing the earlier ones did not help.
SII_PORTAL_SESSION_EXPIREDThe portal kept asking to authenticate in an operation that had already logged in, or a login page came where a step needed another page.
SII_PORTAL_BLOCKEDThe SII's firewall or a captcha blocked the request. Do not loop.
SII_PORTAL_REJECTEDThe SII refused a business request. details.rejection says why.
SII_PORTAL_UNEXPECTED_PAGEThe portal answered a page the SDK cannot interpret. details has the operation, the step, the portalMessage and an excerpt of the page.

Transport problems arrive as TRANSPORT_FAILED, REQUEST_TIMEOUT, TRANSPORT_TLS or SII_HTTP_ERROR.

A refusal is SII_PORTAL_REJECTED, and details.rejection is a key of PORTAL_REJECTIONS. portalRejectionMessage(rejection) gives the operator-facing explanation, in Spanish, of what it means and what to do.

TypeScript
import { isDteError } from 'dte-sdk'
import { portalRejectionMessage, requestCaf, type PortalRejection } from 'dte-sdk/portal'
 
try {
  await requestCaf(portal, { type: 33, quantity: 50 })
} catch (error) {
  if (isDteError(error) && error.code === 'SII_PORTAL_REJECTED') {
    const rejection = error.details.rejection as PortalRejection
    console.error(rejection, portalRejectionMessage(rejection))
    console.error(error.details.portalMessage) // what the SII itself said
  } else {
    throw error
  }
}

details.portalMessage is the page's text, decoded as the page declares itself: a charset in the header decides, and a page with none in the header that declares UTF-8 in a <meta> is read as UTF-8, so its accents arrive intact. The page is redacted before its text is read, on every error and result that carries portalMessage, so a token or an RSASK a page showed arrives as [redacted]. Before 0.6.0 that was done only on a block page.

The refusals of the company and folio functions:

details.rejectionMeaning
stampingBlockedThe SII authorizes no stamping: you have enough unused folios. Issue with them.
inPersonRequiredThe legal representative must complete the registration in person at the SII office.
companyNotAuthorizedThe company is not an electronic issuer in this environment. User permissions do not fix it.
optionNotAuthorizedThe SII says "not authorized to enter this option", without a cause. Most often the company is not yet authorized in this environment.
userWithoutPermissionThe certificate's RUT has no permissions in the company: it needs the permissions to sign, send and obtain folios. The SII also says it as "No está autorizado para solicitar timbraje".
personWithPendingIssuesThe authenticated person has pending matters with the SII. Retrying does not fix it.
activityVerificationPendingThere is no Verificación de Actividades for this document type. It is done online at sii.cl and does not apply to receipts.
typeNotEnabledThe SII does not offer this document type for stamping with this RUT.
quotaInsufficientThe SII authorizes fewer folios than you required (minQuantity).
quotaExceededThe quantity exceeds the maximum per request.
quotaCapReachedA flow stopped: after a grant, the SII refused one folio over the maximum (quotaExceeded), so there is no more quota in this pass. Only in flow results.
foliosShortThe SII granted fewer folios than required. Only in flow results.
noAnnullableFoliosStamping is limited and no annullable folios remain. Only in flow results.
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 before asking again.
rangeAlreadyAuthorizedThe SII already authorized that range and did not return the file. It can be re-obtained.
rangeAnnulledThe range is annulled: every document with those folios would be rejected.
reobtainFormMissing, reobtainDownloadMissing, reobtainCafMissingThe re-obtain flow did not get the form, the download or the CAF XML from the SII. A login page in their place is SII_PORTAL_SESSION_EXPIRED.

PORTAL_REJECTIONS also holds the refusals of the activity verification, of the certification portals, of the purchase and sales register and of the MIPYME backup. They are explained where those features are: Activity verification, Certification and Purchase and sales register.

Retries#

isRetryablePortalError(error) is true for what is worth repeating at the portal: TRANSPORT_FAILED, REQUEST_TIMEOUT, HTTP 502, 503 and 504, and the intermittent TLS failures the SII produces with a client certificate (EPROTO, which are not a sign of a bad certificate). A session limit, a block or a rejection is never repeated, because that makes them worse.

DEFAULT_PORTAL_RETRY_POLICY is 4 attempts, waiting 2, 4 and 8 seconds between them, with no jitter, retrying what isRetryablePortalError accepts. Change it per client with retry: { maxAttempts: 2 }, turn it off with retry: false, or override one call with the overrides argument.

Retries work differently in three places. Annulment tries each step up to three times on its own, as described above. The writes of the purchase and sales register are always a single attempt, and its reads, like those of the activity verification, make one attempt per call unless you opt in. The MIPYME backup has its own attempts option. See Purchase and sales register.

Next steps#