Saltar al contenido
dte-sdk

Portal del SII y folios

El portal del SII es el sitio web, autenticado con certificado digital, donde una empresa administra su emisión electrónica: su resolución, sus datos de contribuyente y, sobre todo, sus folios. dte-sdk/portal lo maneja por ti. Obtienes una sesión que se reutiliza, los datos de la empresa, la verificación de actividades que el SII exige antes de emitir con IVA, el cupo de timbraje, la solicitud de CAF, la anulación de folios sin usar, la nueva descarga de un CAF que el SII ya autorizó y un registro local que recuerda qué folio de qué CAF ya usaste.

El portal es la parte menos probada del SDK. Todo lo que ves aquí está probado contra respuestas sintéticas del SII, con una exigencia de 100 % de cobertura en el CI. Contra el SII real solo se ha medido esto, para una empresa que no está inscrita como emisor: en producción, el 9 y el 10 de noviembre de 2026, el inicio de sesión con certificado y la sesión reutilizada entre lecturas, y en ejecuciones anteriores cuyo ambiente no quedó anotado, el inicio de sesión, el cierre de sesión, los datos del contribuyente y las páginas de rechazo. La verificación de actividades se leyó para una verificación pendiente y, el 9 de noviembre, para una condicionada, pendiente de acreditación. Las páginas de una empresa inscrita (cupo de folios, descarga del CAF, rangos de folios) aún no se han visto. Revisa el Estado.

La 0.8.0 de dte-sdk está en npm, la versión más reciente. Todavía no se recomienda para producción: la mayoría de las áreas no se ha probado contra el SII real, y hasta la 1.0.0 la API puede cambiar en cualquier versión. Revisa Estado.

Uso básico#

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',
})
 
// Resolución de este ambiente, nombre, dirección y giro de la empresa
const issuer = await fetchIssuerProfile(portal)
 
// Lee el cupo y se detiene: no se timbra nada y el cupo no se mueve
const quota = await queryFolioQuota(portal, { type: 33 })
quota.maxAuthorized // 50, o null mientras el SII no raciona este tipo
 
// Pide 50 folios y descarga el CAF
const { xml, caf, granted } = await requestCaf(portal, { type: 33, quantity: 50 })
granted // léelo: el SII puede otorgar menos folios de los que pediste
 
await closePortalSession(portal)

No hay un login() que llamar. Cada función recibe el cliente como primer argumento e inicia sesión a pedido. closePortalSession es la forma ordenada de devolver la sesión cuando terminas.

Antes de empezar#

Qué es el portal#

El SII tiene dos tipos de superficie. Los web services (SOAP y REST) reciben XML firmado y responden XML: revisa Servicios web del SII. El portal es el sitio web que una persona usa en sii.cl: formularios y páginas que responden HTML. dte-sdk/portal inicia sesión como lo haría un navegador, llena los formularios y lee el HTML que vuelve. Por eso es más lento y más frágil que los web services, y por eso un cambio en las páginas del SII puede romperlo.

Necesita Node#

El portal autentica con tu certificado como certificado de cliente TLS, y fetch no puede presentar uno. El transporte por defecto usa https de Node con el certificado, verifica el servidor y se queda en TLS 1.2. dte-sdk/portal funciona solo en Node. Revisa Puntos de entrada.

Habilita el certificado como método de ingreso#

Es un paso manual en sii.cl, que se hace una vez por persona, y el SDK no puede hacerlo por ti. En el sitio del SII, en Cambiar Clave y datos de autenticación, elige la opción "Utilizando Clave Tributaria o Certificado Digital". Mientras no lo hagas, el inicio de sesión lanza SII_PORTAL_CERT_NOT_ENABLED.

El titular del certificado es el usuario del portal. La empresa se indica en cada llamada (companyRut) o una sola vez en el cliente. Si el titular no tiene permisos en esa empresa en ese ambiente, el SII rechaza con userWithoutPermission.

Crear el cliente#

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

createPortalClient valida las opciones y devuelve un PortalClient congelado: datos planos con todos los campos resueltos. Lanza INVALID_OPTION (con details.field) ante un environment desconocido o un sessionTtlMs o timeoutMs no positivo, MISSING_FIELD si falta el certificate e INVALID_RUT si el companyRut está mal formado.

OpciónPor defectoQué hace
environmentobligatoria'certification' o 'production'. Elige el host del SII de las páginas DTE (maullin o palena).
certificateobligatoriaEl certificado que se presenta al iniciar sesión. Su titular es el usuario del portal.
companyRutningunaLa empresa que usa cada operación, salvo que la llamada indique su propio companyRut. Si no hay ninguno, la llamada lanza MISSING_FIELD.
transporthttps de Node con el certificado de cliente, TLS 1.2El transporte HTTP. Pasa el tuyo para probar o para salir por un proxy.
sessionCacheen memoriaUn TokenCache (get, set, delete de { token, expiresAt }) donde vive la sesión. createDirectoryTokenCache({ dir }) de dte-sdk/node la guarda en disco.
sessionTtlMs90 minutosCuánto tiempo se confía en una sesión en caché. Se renueva en cada uso.
lockexclusión mutua dentro del procesoUn PortalLock: quién puede usar la sesión ahora.
retry4 intentos a los 2, 4 y 8 sUna RetryPolicy parcial que se mezcla sobre la del portal, o false para desactivar los reintentos. Revisa Reintentos.
timeoutMs20000Timeout de cada petición, en milisegundos.
closePreviousSessionstrueCuando el SII informa el límite de sesiones y ofrece cerrar las anteriores, aceptar.
loggersilenciosoUn Logger con debug, info, warn y error. Recibe nombres de operación, intentos, estados y códigos, nunca tokens ni XML en el mensaje.
captureapagadoUn puerto HttpCapture que recibe cada intercambio HTTP, para depurar.
nownew Date()El reloj.
sleepuna espera realLa espera entre reintentos y entre pasos. Inyecta una para que las pruebas sean instantáneas.
randomIdcrypto.randomUUIDFuente de los identificadores de transacción del registro de compras y ventas.

Toda función del portal recibe un último argumento opcional, overrides: { signal, timeoutMs, retry }. Úsalo para cancelar una llamada con un AbortSignal, para cambiar el timeout de una llamada o su política de reintentos.

Sesiones#

TypeScript
import { closePortalSession, openPortalSession } from 'dte-sdk/portal'
 
const { userRut, reused } = await openPortalSession(portal)
// userRut: el RUT al que pertenece la sesión (el titular del certificado), si las cookies lo dicen
// reused: true si una sesión en caché seguía viva
 
await closePortalSession(portal)

No necesitas openPortalSession: cada operación inicia sesión cuando el portal lo pide. Llámala cuando quieras fallar temprano, por ejemplo al arrancar (lanza SII_PORTAL_CERT_NOT_ENABLED si olvidaste habilitar el certificado).

Cómo se comportan las sesiones:

  • Se reutilizan. Las cookies de la sesión se guardan en sessionCache después de cada operación, con una vigencia de sessionTtlMs (90 minutos por defecto, renovada en cada uso; el SII tolera cerca de dos horas de inactividad). La operación siguiente las carga en vez de iniciar sesión otra vez. openPortalSession comprueba una sesión en caché leyendo la página de empresa (ad_empresa1) del ambiente del cliente, e inicia sesión de nuevo si el SII ya no responde por ella; la comprobación de una sesión guardada antes de los portales GWT y de la certificación de boletas lee la misma página. Una sesión sirve para ambos ambientes. Antes de la 0.7.0 la comprobación leía la página del servidor de certificación fuera cual fuera el ambiente del cliente, así que un cliente de producción dependía de que maullin estuviera arriba.
  • Una sesión en caché es una credencial. La caché guarda las cookies de la sesión. Si reemplazas la caché en memoria por Redis o un archivo, protégela como protegerías el certificado.
  • En memoria por defecto, en disco si lo pides. Con el sessionCache por defecto, un reinicio vuelve a iniciar sesión. createDirectoryTokenCache({ dir }) de dte-sdk/node guarda la sesión en disco, un archivo por certificado que solo puede leer su dueño, y un reinicio la reutiliza. Dale un directorio propio. Nunca lanza: una entrada que no puede leer o escribir cuenta como ausente, y solo se informa a su logger.
  • Una sesión por certificado. La clave de la caché y la del candado se derivan del certificado. El SII raciona las sesiones autenticadas por RUT, y sus cookies de balanceador se rompen cuando dos peticiones de una misma sesión se cruzan. El candado por defecto es una exclusión mutua dentro de un proceso, así que dos operaciones con el mismo certificado corren una después de la otra. Solo vale dentro de un proceso: dos procesos que comparten el directorio de una caché en disco y corren a la vez pueden enviar peticiones de una misma sesión al mismo tiempo o pisarse la sesión. Dales también un lock compartido, o deja un solo proceso por certificado.
  • Varias réplicas necesitan puertos compartidos. Si más de un proceso usa el mismo certificado, dales un sessionCache compartido (Redis, una base de datos) y un lock compartido (un candado distribuido), para que se turnen una sola sesión en vez de abrir una cada uno y chocar con el límite de sesiones. PortalLock es (key, run) => Promise: run parte solo cuando el candado está tomado, y el candado debe liberarse al terminar, aunque falle. createMemoryPortalLock() es la versión dentro del proceso.
  • El límite de sesiones. Si el SII informa demasiadas sesiones para el RUT, el SDK cierra las anteriores cuando el SII lo ofrece (salvo que closePreviousSessions sea false) e intenta una vez más. Si eso no ayuda, lanza SII_PORTAL_SESSION_LIMIT. Nunca repite este error en un ciclo.
  • closePortalSession cierra la sesión para devolver el cupo y siempre borra la sesión en caché. Un cierre fallido solo se registra: el SII vence la sesión por su cuenta.
TypeScript
import { createDirectoryTokenCache } from 'dte-sdk/node'
 
// La sesión en disco: un reinicio la reutiliza
const portal = createPortalClient({
  environment: 'production',
  certificate,
  companyRut: '76543210-3',
  sessionCache: createDirectoryTokenCache({ dir: './portal-sessions' }),
})
TypeScript
// Un candado y una caché compartidos, esbozados con tu propia infraestructura
const portal = createPortalClient({
  environment: 'production',
  certificate,
  companyRut: '76543210-3',
  sessionCache: myRedisTokenCache, // { get, set, delete }
  lock: (key, run) => myDistributedLock(key, run),
})

Datos de la empresa#

TypeScript
import {
  fetchCompanyResolution,
  fetchIssuerProfile,
  fetchTaxpayerData,
  queryCertificationSets,
} from 'dte-sdk/portal'
 
const resolution = await fetchCompanyResolution(portal, { environment: 'production' })
resolution.resolutionNumber // el número de la resolución que autoriza a la empresa
resolution.resolutionDate // 'AAAA-MM-DD'
 
const taxpayer = await fetchTaxpayerData(portal)
taxpayer.businessName
taxpayer.activities // [{ code, description, vatAffected }]
taxpayer.branches // [{ code, kind, address }]
 
const profile = await fetchIssuerProfile(portal)
FunciónDevuelveNotas
fetchCompanyResolution(portal, { companyRut?, environment? })CompanyResolution: rut, businessName, resolutionDate, resolutionNumber, authorizationDate (cualquiera puede ser null)Es por ambiente. Certificación y producción responden fechas y números distintos para un mismo RUT, y el par equivocado hace que se rechace un envío con "Error en Carátula". Lanza SII_PORTAL_REJECTED con companyNotAuthorized cuando el SII no reconoce a la empresa como emisor en ese ambiente.
fetchTaxpayerData(portal, { companyRut? })TaxpayerData: nombre, domicilio en sus partes, activities, branches, dirección regional, glosaEsta página responde solo en el host de certificación, sea cual sea el ambiente del cliente.
fetchIssuerProfile(portal, { companyRut?, environment? })IssuerProfile: rut, businessName, businessActivity, activityCode, address, commune, city, resolutionDate, resolutionNumberLas dos lecturas anteriores, en secuencia y combinadas, con lo que un Issuer y una carátula de envío necesitan. Sin empresa en la llamada ni en el cliente, usa el RUT de la sesión. Una lectura fallida del contribuyente se registra y se tolera mientras haya llegado la fecha de resolución.
queryCertificationSets(portal, { companyRut? }){ sets, allSetsApproved, allBooksApproved }Lee el estado de cada set de pruebas y libro de la postulación, en el host de certificación. Solo lee. Revisa Certificación.

fetchIssuerProfile hace sus dos lecturas una tras otra a propósito: en paralelo, una respuesta pisa la cookie del balanceador que la otra necesita.

Verificación de actividades#

El SII deja emitir documentos con IVA solo a una empresa cuyas actividades verificó en terreno con resultado positivo, o que tiene esa verificación en proceso (los requisitos del SII). Si falta la verificación, una solicitud de CAF para un tipo que la necesita se rechaza con activityVerificationPending. Desde la 0.3.0 la sesión del portal también la lee, desde la "Consulta estado de verificación y acreditación" del SII:

TypeScript
import { listAccreditationDocuments, unstable_fetchActivityVerification } from 'dte-sdk/portal'
 
const { requests, messages } = await unstable_fetchActivityVerification(portal)
for (const request of requests) {
  request.status // un código como 'PAC'
  request.statusDescription // 'PENDIENTE DE ACREDITACIÓN'
  request.accreditationDeadline // 'AAAA-MM-DD', o null si no hay
}
const blocking = messages.filter((message) => message.blocking)
 
// Los documentos que el SII acepta para acreditar una actividad, generales y específicos
const documents = await listAccreditationDocuments(portal)

unstable_fetchActivityVerification es inestable: su forma puede cambiar en cualquier versión, incluso un patch. Se ha leído contra el SII real para una verificación pendiente y, el 9 de noviembre de 2026, para una condicionada, pendiente de acreditación, y aún no se conocen los códigos de una aprobada, así que nada en el resultado dice por sí solo si la empresa puede emitir con IVA. Cuando se lea una verificación aprobada, pasa a llamarse fetchActivityVerification.

  • Pide la empresa, no la sesión. La página del propio SII muestra la verificación de quien inició sesión. Esta función envía el RUT de la empresa, y una solicitud de otro RUT lanza SII_PORTAL_UNEXPECTED_PAGE con step: 'otherTaxpayer' en vez de pasar como de la empresa.
  • Sin solicitudes, la empresa nunca pidió la verificación. Un código de error del SII lanza SII_PORTAL_REJECTED con activityVerificationRefused, con sus palabras en details.portalMessage.
  • Los funcionarios, los comentarios y el domicilio nunca salen, y no hay raw.
  • Ambas leen producción desde cualquier cliente, porque la app existe solo ahí.

Cada campo, y lo que lanza cada una: referencia de la API.

Folios#

Un folio es un número que el SII te deja usar en un documento. Un CAF (Código de Autorización de Folios) es el archivo XML que autoriza un rango de folios para un tipo de documento y trae la llave privada con la que se firma el timbre de cada documento. Los CAF se solicitan en el portal.

El XML del CAF contiene una llave privada. requestCaf y reobtainCaf lo devuelven en xml. Trátalo como un secreto: no lo registres en logs, no lo subas a un repositorio, no lo pongas en un mensaje de error ni en un ticket. Dónde lo guardas es cosa tuya. Revisa Seguridad.

Cupo y tipos#

TypeScript
import { listStampableTypes, queryFolioQuota } from 'dte-sdk/portal'
 
const types = await listStampableTypes(portal)
// [{ type: 33, name: '...' }, ...]: el código del tipo y su nombre como lo muestra el portal
 
const quota = await queryFolioQuota(portal, { type: 33 })
// { maxAuthorized: 50, availableFolios: 20, unrestricted: false, blocked: false }
  • listStampableTypes lista los tipos de documento que el portal ofrece para timbraje con esta empresa. Una lista vacía es una respuesta válida; una página de error no lo es, y lanza SII_PORTAL_UNEXPECTED_PAGE en vez de leerse como "sin tipos".
  • queryFolioQuota lee cuántos folios autoriza el SII para un tipo y se detiene en el formulario que lo muestra. No se timbra nada.
    • El formulario que lee es el que tiene el tipo elegido en su selector. La página de la empresa, sin tipo elegido, no es ese formulario: primero se elige el tipo. Una última página que no es ese formulario nunca se lee como un tipo sin racionar: lanza SII_PORTAL_SESSION_EXPIRED si pide iniciar sesión y SII_PORTAL_UNEXPECTED_PAGE en cualquier otro caso.
    • maxAuthorized (el MAX_AUTOR del SII) es el máximo de folios que autoriza ahora. availableFolios (FOLIOS_DISP) es cuántos folios tienes timbrados y sin usar. Ambos aparecen solo mientras el SII raciona el tipo. Cuando no lo hace, los dos son null y unrestricted es true.
    • blocked es true cuando el SII responde "NO SE AUTORIZA TIMBRAJE". Ese caso vuelve como dato, no como error.
    • Cualquier otro rechazo lanza SII_PORTAL_REJECTED, por ejemplo typeNotEnabled cuando el SII no ofrece el tipo para este RUT.

Solicitar un CAF#

TypeScript
import { requestCaf } from 'dte-sdk/portal'
 
const obtained = await requestCaf(portal, {
  type: 33,
  quantity: 100,
  minQuantity: 100, // bajo este valor, no se timbra nada
})
 
obtained.xml // el archivo CAF tal como lo entregó el SII: un secreto
obtained.caf // el mismo archivo parseado (revisa parseCaf)
obtained.granted // cuántos folios cubre
obtained.maxAuthorized // el MAX_AUTOR visto, o null
obtained.availableFolios // el FOLIOS_DISP visto, o null
obtained.recovered // true si volvió por "ya fue autorizado" o por la reobtención

requestCaf recorre los pasos del portal (la solicitud, la confirmación del primer folio, la generación y la descarga del archivo, que repite unas veces si el portal falla) y devuelve un ObtainedCaf. Un rechazo puede llegar en cualquier paso.

El otorgamiento puede ser menor que la solicitud. Lee granted, no quantity:

  • Mientras el SII raciona el tipo, requestCaf limita la solicitud a maxAuthorized.
  • Si el SII responde que se excedió el máximo efectivo y no indicaste minQuantity, requestCaf pide una vez más, esta vez un folio.
  • Si el SII responde que el rango ya fue autorizado (una solicitud anterior obtuvo el rango pero se perdió el archivo), requestCaf intenta descargarlo de nuevo y lo devuelve con recovered: true.
  • Cada CAF descargado se parsea con parseCaf, así que uno cuya llave privada no es el par de su clave pública llega a la llamada como CAF_INVALID, como uno mal formado. Un CAF que emitió el SII siempre es par.
  • minQuantity es la forma estricta. Si el SII autorizara menos que minQuantity, no se timbra nada y la llamada lanza SII_PORTAL_REJECTED con quotaInsufficient. Un rango corto queda sin usar, y los folios sin usar bajan el próximo máximo (revisa Racionamiento).
  • Si el SII responde una solicitud con el formulario de solicitud otra vez, sin folios y sin motivo, no se envía nada por segunda vez: requestCaf lanza SII_PORTAL_REJECTED con stampingUncertain, porque los folios pudieron otorgarse. Revisa los rangos que se pueden reobtener (listReobtainableRanges) antes de pedir de nuevo. Un rechazo que la página sí declara se informa como ese rechazo.
  • Un captcha en el paso de confirmación lanza SII_PORTAL_BLOCKED.

type debe ser un tipo de documento soportado (INVALID_DOCUMENT_TYPE), y quantity y minQuantity enteros positivos (INVALID_OPTION). requestCaf espera cerca de un segundo después de cada petición, para que las peticiones de un mismo certificado queden separadas.

Listar y anular folios sin usar#

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 recorre las páginas del listado de anulación del SII y no cambia nada. Devuelve todos los rangos con folios sin usar, incluidos los rangos anulados: el SII no los distingue y los sigue listando para siempre.

TypeScript
const result = await annulFolios(portal, {
  type: 33,
  range: { from: 41, to: 50 }, // se exige uno entre range, withinDays o all
  exclude: [{ from: 1, to: 40 }], // nunca toques el CAF que sigue en uso
  knownAnnulled: previousRun.knownAnnulled, // de la llamada anterior
})
 
result.annulled // [{ from, to, count }]
result.rejected // [{ from, to, count, reason }]
result.totalAnnulled
result.totalRejected
result.knownAnnulled // guárdalo y devuélvelo la próxima vez
result.failure // null, o lo que cortó el recorrido

Anular no se puede deshacer, y los folios anulados pesan contra el cupo de timbraje durante seis meses. Para levantar un bloqueo de timbraje, emite documentos con los folios que ya tienes. Anular empeora el bloqueo.

Por eso annulFolios se niega a correr sin un alcance explícito (INVALID_OPTION con field: 'range'). Elige uno:

AlcanceEfecto
range: { from, to }Solo los folios sin usar dentro de ese rango. Un rango listado que lo cruza se recorta.
withinDays: nSolo los rangos timbrados en los últimos n días. Un rango sin fecha legible se omite.
all: trueTodos los rangos sin usar, hasta maxRanges por pasada.

Cómo recorre una llamada el listado:

  • Cada pasada lee el listado, descarta los rangos fuera del alcance, los de exclude (un rango que se cruza con cualquier tramo excluido se omite completo) y aquellos cuyos folios ya cubren las claves de knownAnnulled, en una o en varias, y luego intenta los últimos maxRanges rangos que aún no ha intentado (50 por defecto).
  • Hay hasta cuatro pasadas, con una pausa corta entre ellas, así que maxRanges limita una pasada y no la llamada: una llamada puede anular hasta cuatro veces maxRanges rangos (200 por defecto).
  • Una pasada se detiene tras dos negativas reales seguidas. alreadyAnnulled y received no cuentan como negativas: son resultados esperados.
  • Un rango de varios folios que el SII rechaza porque parte de él ya se anuló o se recibió se anula folio por folio. Un folio solo conserva la respuesta del SII en vez de enviarse de nuevo.
  • Un rango respondido folio por folio vuelve en tan pocas piezas como lo permitan sus respuestas. Los folios contiguos con la misma respuesta son una sola entrada y una sola clave en annulled, rejected y knownAnnulled, como cuando un paso reintentado tras perder su respuesta oye "ya fue anulado" por cada folio que anuló el primer intento. Antes de la 0.7.0 cada folio era una entrada y una clave propias. Un recorrido cortado en un folio (un bloqueo, una sesión vencida) igual devuelve los folios respondidos antes, con failure.
  • Cada consulta del listado y cada paso se intenta hasta tres veces, con 1,5 s entre intentos, sea cual sea el error, encima de los reintentos del cliente. Un paso que cambió algo en un intento fallido se recupera con la respuesta "ya anulado" del siguiente.
  • Una falla de transporte en un rango se registra como reason: 'transport' y el recorrido sigue. Un bloqueo o un límite de sesiones termina toda la llamada: antes de que el SII responda por algún folio se lanza, y después la llamada devuelve lo que hizo, con failure.

rejected[].reason es uno de alreadyAnnulled, received (el SII informa un folio del rango como ya recepcionado), siiError, transport o unknown. unknown es una respuesta sin palabras conocidas: esos folios pueden estar anulados o no, así que no es definitiva y nunca entra en knownAnnulled, y una corrida posterior los vuelve a intentar. Una respuesta cuenta solo por una frase concreta ("ha autorizado la anulación" o el encabezado SOLICITUD ANULACION DE FOLIOS, recepcionado, anulado antes, error 500); las frases que trae toda página del trámite, como "folios que no han sido recepcionados por el SII", no se leen como respuestas. Cuando el SII informa un rango como anulado antes, alreadyAnnulled es solo para los folios que esa página lista ("Folio Inicial Anulado", "Folio Final Anulado") y el resto del rango es unknown.

Devuelve knownAnnulled. Contiene las claves desde-hasta de los rangos cuyo resultado es definitivo (anulado, alreadyAnnulled o received). Guárdalo entre corridas, porque el portal lista los rangos anulados para siempre y, sin él, la siguiente llamada los vuelve a intentar. Lee sus claves como tramos de folios, no como los rangos del listado: un rango respondido folio por folio puede quedar en varias claves, y un rango listado se omite cuando las claves cubren todos sus folios, donde antes de la 0.7.0 solo lo omitía su clave exacta.

Una anulación cortada conserva lo que hizo. Cuando el SII ya respondió por algunos folios, un DteError que termina el recorrido (una sesión vencida, un bloqueo, un aborto que pediste, un listado posterior rechazado o ilegible) ya no bota lo hecho: la llamada devuelve annulled, rejected y knownAnnulled como están, con failure: { code, operation }. operation es listFolioRanges o annulFolios, y failure.rejection trae el tipo de rechazo cuando el SII rechazó un listado posterior. Antes de la 0.6.0 esa falla se lanzaba, los folios ya anulados nunca llegaban a knownAnnulled, y la siguiente corrida volvía a pedir su anulación solo para oír "ya fue anulado". Un rango que nunca llegó, anotado como transport, no es respuesta, así que antes de cualquier respuesta el error sigue lanzándose, y lo que no es un DteError siempre se lanza, porque es un error del programa. failure es null en un recorrido que termina. Guarda knownAnnulled venga o no failure.

Reobtener un CAF ya autorizado#

Si el SII autorizó un rango y perdiste el archivo, puedes descargarlo de nuevo sin gastar cupo.

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 y caf.availableFolios son null
}
  • El listado trae rangos que ya emitiste, y el SII no marca ninguno. Reutilizar un rango ya emitido hace que cada documento se rechace con DTE-3-101. Lleva tu propio registro (el registro de folios) y filtra con él.
  • El listado tampoco distingue los rangos anulados. listReobtainableRanges lo averigua con una petición extra por rango y llena annulled.
  • listReobtainableRanges entra al trámite como las demás funciones: inicia sesión a pedido y pasa por la pantalla de representación. Solo un listado da una lista, que está vacía cuando no hay rangos. Una página que declara un rechazo lanza SII_PORTAL_REJECTED, una de ingreso SII_PORTAL_SESSION_EXPIRED y cualquier otra SII_PORTAL_UNEXPECTED_PAGE.
  • reobtainCaf entra primero al trámite, como lo hace listReobtainableRanges, iniciando sesión a pedido y pasando la pantalla de representación, así que una sesión en caché que venció después del listado inicia sesión de nuevo en vez de enviar el rango con ella. Es un GET más, de rf_reobtencion1_folios. Una página que declara un rechazo en la entrada lanza SII_PORTAL_REJECTED, y cualquier otra que no es el trámite SII_PORTAL_UNEXPECTED_PAGE, antes de enviar nada.
  • reobtainCaf lanza SII_PORTAL_REJECTED con rangeAnnulled, reobtainFormMissing, reobtainDownloadMissing o reobtainCafMissing. Una página de ingreso en respuesta al rango o al archivo es SII_PORTAL_SESSION_EXPIRED, nunca uno de esos rechazos: antes de la 0.7.0 una sesión vencida se leía como reobtainDownloadMissing.

Flujos compuestos#

Tres funciones combinan los pasos anteriores. Mantienen una sola sesión durante todo el flujo y devuelven los rechazos como datos, no como errores lanzados. La razón: un flujo puede tener ya CAF en la mano cuando el paso siguiente es rechazado. Esos CAF deben llegar a ti, y un CAF no puede viajar dentro de los details de un error, que se registran. Las fallas de transporte, los bloqueos y una sesión vencida siguen lanzándose, salvo cuando un flujo ya tiene algo que no puedes perder (CAF, o folios que el SII anuló): desde ahí también vuelven como datos, en failure.

requestCafExact y requestCafInBatches devuelven un FolioFlowResult; reobtainFolios tiene su propio resultado, más abajo:

CampoSignificado
completeSe cubrió la cantidad pedida.
cafsTodos los CAF obtenidos, aunque el total no alcance. Guárdalos antes que cualquier otra cosa.
grantedFolios cubiertos por cafs.
maxAuthorized, availableFoliosEl último MAX_AUTOR y FOLIOS_DISP vistos, o null.
rejectionPor qué no alcanzó (una clave de PortalRejection), o null.
portalMessageLo que dijo el portal, con sus secretos tachados, o null.
failureLo que detuvo el flujo mientras tenía algo que no puedes perder ({ code, operation, rejection? }), o null; rejection es entonces null. requestCafInBatches lo llena con CAF en la mano, requestCafExact después de una anulación que el SII respondió.

Un flujo cortado registra el error del que solo guarda el código. failure guarda el código del error, y el tipo de un rechazo en failure.rejection cuando code es SII_PORTAL_REJECTED, porque los otros detalles pueden traer lo que dijo una página. El original va antes al logger del cliente, como la advertencia sii portal flow stopped, keeping what it holds, con los detalles del error (su operation nombra el paso que falló), flow con el nombre del flujo, su code y stoppedAt. Cada detalle de texto pasa antes por redactText, así un token, un RSASK o una cookie de sesión llegan al registro como [redacted]. requestCafInBatches, requestCafExact, reobtainFolios y annulFolios lo registran todos, así que el reason del original (ECONNRESET, un tiempo agotado), su httpStatus y sus attempts ya no se pierden. La advertencia deja fuera lo que dijo una página (portalMessage, body) y la url de donde vino, porque pueden nombrar a la empresa y su RUT; los errores lanzados los conservan. Antes de la 0.7.0 esos tres también llegaban al registro, con solo sus secretos tachados.

Un CAF para toda la cantidad: requestCafExact#

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

Pide un CAF que cubra toda la cantidad, con minQuantity igual a quantity: bajo ella no se timbra nada. Con allowAnnul: true, un límite causado por folios sin usar (quotaInsufficient o stampingBlocked) se levanta anulando y pidiendo una vez más:

  • Anula hasta 20 rangos en cada una de hasta cuatro pasadas (80 rangos en total), nunca los de exclude.
  • Si availableFolios es 0, nada es anulable y ese intento se omite. Con el tipo bloqueado el campo no viene, y ese caso sí lo intenta.
  • Si la anulación no libera nada, devuelve rejection: 'noAnnullableFolios'.
  • El resultado trae knownAnnulled: guárdalo y devuélvelo, venga o no failure.
  • Una anulación cortada conserva su memoria. Una vez que el SII respondió la anulación, un DteError que la corta, o uno de la solicitud que se repite después, termina el flujo con complete: false, rejection: null, el cupo de la primera solicitud, knownAnnulled con lo anulado y failure: el de la anulación (listFolioRanges o annulFolios) o el de la solicitud (requestCaf). Un listado que el SII rechaza durante la anulación guarda su tipo en failure.rejection, porque rejection habla de la solicitud. Tras una anulación cortada no se vuelve a pedir. Antes de la primera respuesta de la anulación sigue lanzándose. Antes de la 0.6.0 failure siempre era null aquí: léelo como en requestCafInBatches.

allowAnnul está apagado por defecto por la irreversibilidad descrita arriba.

Varios CAF: requestCafInBatches#

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

Para cuando FOLIOS_DISP es 0 y MAX_AUTOR es chico: el SII raciona por historial, no hay nada que anular ni reobtener, y esperar no lo mueve. Cada tanda lee el cupo, pide el menor entre maxAuthorized y lo que falta, y suma el CAF. Se detiene apenas una tanda no aporta, porque cada entrega sube FOLIOS_DISP y puede bajar el próximo MAX_AUTOR. maxBatches (4 por defecto) limita los viajes al portal, y initialQuota te deja pasar un cupo que ya leíste para omitir la primera lectura. Cuando se queda corto, rejection dice por qué, en el primer viaje o en uno posterior. Después de una entrega, un folio por sobre el máximo (quotaExceeded) es quotaCapReached: el SII no tiene más que dar en esta pasada. Cualquier otro rechazo vuelve con su propio tipo y portalMessage: un rango que el SII autorizó sin entregar su archivo es rangeAlreadyAuthorized, que listReobtainableRanges y reobtainCaf vuelven a descargar, y un rechazo de la empresa, la opción o la persona es ese rechazo. Si el SII responde una solicitud con el formulario de solicitud otra vez y sin folios, es stampingUncertain. Los CAF ya obtenidos quedan en cafs en todos los casos. Antes de la 0.7.0 todo rechazo de un viaje posterior salvo stampingUncertain se leía como quotaCapReached: si lo lees como "deja de pedir", ahora te llegan también los otros tipos después de una entrega.

Una tanda cortada conserva lo que obtuvo. Con un CAF ya en la mano, un viaje posterior que falla con algo que no es un rechazo (una falla de transporte, un tiempo agotado, una sesión vencida, un bloqueo, CAF_INVALID, un aborto que pediste) termina el flujo con los CAF, complete: false, el granted hasta ahí, el último cupo visto, rejection: null y failure: { code, operation }. operation es queryFolioQuota o requestCaf, y solo se guarda el código del error, porque sus detalles pueden traer lo que dijo una página. Antes de la 0.5.0 esa falla se lanzaba, y los CAF que habían traído los viajes anteriores se perdían. Sin un CAF en la mano sigue lanzándose, también un aborto que pediste, y lo que no es un DteError siempre se lanza, porque es un error del programa.

TypeScript
if (!result.complete && result.failure !== null) {
  // cortada con CAF en la mano: guarda primero result.cafs
  console.error(result.failure.code, result.failure.operation)
}
  • Lee failure además de rejection. Un resultado corto con rejection: null es una tanda cortada, no una entrega menor.
  • Un requestCaf que falló pudo otorgarse igual. Revisa listReobtainableRanges antes de pedir de nuevo.
  • quantity debe ser un entero de al menos 1, o la llamada lanza INVALID_OPTION con field: 'quantity' antes de que salga nada.

Recuperar CAF perdidos: reobtainFolios#

TypeScript
import { reobtainFolios } from 'dte-sdk/portal'
 
const result = await reobtainFolios(portal, {
  type: 33,
  quantity: 50,
  isIssued: (range) => registryHas(range), // tu registro de rangos ya enviados
  isFolioFree: async (folio) => !(await folioWasReceived(folio)), // por ejemplo con folioReceived
})
 
result.cafs // los CAF descargados
result.ranges // [{ from, to, freeFolios }] cada rango usado
result.expired // rangos cuyo CAF ya venció: no cuentan, anúlalos
result.available // folios libres cubiertos
result.reason // por qué no alcanzó, en palabras, o null
result.failure // null, o la descarga que cortó el recorrido

Cubre una cantidad con rangos que el SII ya autorizó, partiendo por el folio más antiguo, sin gastar cupo. Es lo que levanta un bloqueo de timbraje, porque lo que el SII pide es emitir documentos con los folios que tienes. En detalle:

  • Omite los rangos anulados y los que isIssued dice que ya enviaste.
  • Folios libres. Sin isFolioFree, todo folio de un rango cuenta como libre. Eso es peligroso, porque el portal no marca los folios emitidos. Con isFolioFree (normalmente una llamada a folioReceived del cliente de web services, revisa Servicios web del SII), cada folio se verifica, hasta maxChecksPerRange por rango (30 por defecto). Un rango con algunos folios recibidos no se descarta: se usan sus folios libres.
  • Si los rangos usables no alcanzan la cantidad, no descarga nada y devuelve available con un reason.
  • Un CAF que ya venció (o vence dentro de un día) no cuenta: va a expired y el recorrido sigue. El SII rechaza todo documento timbrado con un CAF vencido.
  • Inicia sesión a pedido, como las demás funciones, al listar los rangos.
  • No lanza ante un listado fallido. Cualquier cosa que falle al listar (una página de rechazo, una falla de transporte, una sesión vencida, una página que no es el listado) se lee como "sin rangos", con el mensaje de la falla en reason. Solo se lanza un aborto que tú pediste (REQUEST_ABORTED). Una descarga rechazada se registra y se omite.
  • Un recorrido cortado conserva lo que descargó. Con un CAF ya en la mano, una descarga que falla con algo que no es un rechazo (una falla de transporte, un tiempo agotado, una sesión vencida, un bloqueo, un aborto que pediste) termina el recorrido con complete: false, los CAF, sus rangos, el mensaje de la falla en reason y failure: { code, operation: 'reobtainCaf' }. Antes de la 0.6.0 se lanzaba, y los CAF que habían traído las descargas anteriores había que descargarlos de nuevo. Sin un CAF en la mano sigue lanzándose, y lo que no es un DteError siempre se lanza, porque es un error del programa.
  • Una página de ingreso es una sesión vencida, no una descarga rechazada. Una página de ingreso en respuesta a un rango o a su archivo es SII_PORTAL_SESSION_EXPIRED: el recorrido termina con ella, con los CAF ya en la mano y failure, o la lanza si no tiene ninguno. Antes de la 0.7.0 se leía como una descarga rechazada y el recorrido se saltaba el rango.

Cuando se queda corto, pide solo la diferencia: quantity menos available.

Racionamiento, y por qué emitir con lo que tienes#

El SII no entrega folios sin límite. Fija MAX_AUTOR por tipo de documento según tu historial de timbraje y emisión, y cuenta FOLIOS_DISP: los folios que timbraste y no has usado. Los folios sin usar bajan lo que el SII autoriza, y si son suficientes bloquean por completo el timbraje ("NO SE AUTORIZA TIMBRAJE").

Qué se desprende de eso:

  • Para levantar un bloqueo, emite con los folios que tienes. Eso es lo que pide el SII. reobtainFolios recupera los CAF que perdiste sin gastar cupo.
  • No timbres más de lo que vas a emitir pronto. Un rango corto o excesivo que queda sin usar perjudica el cupo siguiente.
  • No anules para "ordenar". Anular no se puede deshacer y los folios anulados pesan contra el cupo durante seis meses. Úsalo a propósito, con un alcance que tú elegiste.
  • requestCafInBatches es para un MAX_AUTOR chico sin nada sin usar que anular o recuperar. requestCafExact quiere un solo CAF para toda la cantidad.

El registro local de folios#

El portal te dice qué autorizó el SII. No te dice qué folio de qué CAF ya pusiste en un documento. Eso lo hace el registro de folios: un libro local de folios reservados, enviados y anulados, por CAF.

TypeScript
import { cafFingerprint, createMemoryFolioRegistry } from 'dte-sdk/portal'
import { createDirectoryFolioRegistry } from 'dte-sdk/node'
 
const registry = createDirectoryFolioRegistry({ dir: './folios' })
// o, para pruebas y corridas cortas: createMemoryFolioRegistry()
 
const scope = {
  companyRut: '76543210-3',
  type: 33,
  range: obtained.caf.range, // { from, to } del CAF
  environment: 'certification' as const,
  cafFingerprint: cafFingerprint(obtained.xml), // distingue dos archivos de un mismo rango
}
 
const folio = await registry.reserve(scope) // el siguiente folio libre
// ... emite el documento con este folio y envíalo ...
await registry.markSent(scope, folio, { trackId: '0234567890' })
 
await registry.remaining(scope) // folios del rango aún sin usar
await registry.stats({ companyRut: '76543210-3', type: 33 })
// { totalEntries, totalUsed, totalSent,
//   ranges: [{ from, to, environment, cafFingerprint, used, sent, remaining }] }

Un FolioScope es un CAF de una empresa en un ambiente: companyRut, type, range, environment y, opcionalmente, cafFingerprint. cafFingerprint(xml) son los primeros 12 caracteres hexadecimales del SHA-256 del archivo CAF. Pásalo para que un rango que reobtuviste no comparta entrada con el primer archivo de ese rango. Para un archivo CAF en disco, cafFileScope(file, environment) de dte-sdk/node arma este alcance desde el texto del archivo y su CAF, y es el alcance que devuelve resolveCafFile (revisa Archivos CAF). Coincide con el archivo guardado solo si recibe el texto que guarda el archivo: si reescribes un CAF descargado antes de guardarlo, su declaración por ejemplo, toma el alcance del texto que guardaste.

MétodoQué hace
reserve(scope, options?)Entrega el siguiente folio libre y lo registra como usado. Lanza FOLIO_OUT_OF_RANGE pasado el fin del rango.
markSent(scope, folio, { trackId?, sentAt?, extra? })Registra un folio como enviado. Cuenta como usado aunque nunca lo hayas reservado aquí. Lanza FOLIO_OUT_OF_RANGE para un folio fuera del rango.
markAnnulled(scope, folios)Registra folios como anulados en el SII. Ninguna entrada de la misma empresa, tipo y ambiente los volverá a reservar.
release(scope, folios)Devuelve folios: salen de los usados y de cualquier registro de envío. Para un folio que reservaste y nunca enviaste.
remaining(scope)Los folios del rango menos los usados.
stats({ companyRut, type, environment? })Totales por CAF de una empresa y tipo. Cada rango trae el environment y la cafFingerprint de su entrada (null si su alcance no la tiene), así que dos archivos de un mismo rango, o un rango en ambos ambientes, se distinguen.

Cómo elige reserve. Parte en el menor folio reservado y aún no enviado, o después del último folio reservado si no hay ninguno, y avanza saltando los usados y los anulados. Nunca entrega un folio usado o anulado, y nunca vuelve a un folio libre bajo su punto de partida. Así, un folio reservado y no enviado (por ejemplo tras una caída) vuelve primero, y un hueco bajo un folio enviado no se rellena.

{ lowestFree: true } parte en cambio en el primer folio de todo el rango, así que un folio que devolviste, o que quedó libre bajo uno marcado como enviado, vuelve a salir. La corrida de certificación lo pide, porque marca como usados los folios que el SII no mostró libres. Úsalo solo con un registro que anote todo folio que el rango ha gastado. Revisa Certificación.

Cuál usar:

  • createMemoryFolioRegistry() vive en memoria. Para pruebas y corridas cortas.
  • createDirectoryFolioRegistry({ dir }) (de dte-sdk/node) guarda un folios.json, escrito de forma atómica (un archivo temporal propio, creado en exclusiva, y un renombre), así un lector nunca ve medio libro. Lo guarda solo para su dueño, como la caché de tokens, porque el libro tiene los RUT de las empresas, sus track ids y lo que guardes en extra: el directorio se crea con modo 0700 (uno que ya existe conserva sus permisos) y el archivo con 0600. Es para un solo proceso. El directorio debe sobrevivir a los reinicios: si lo pierdes, repites folios ante el SII.
  • createFolioRegistry(store) arma un registro sobre un FolioLedgerStore tuyo (load y save de un FolioLedger, un valor apto para JSON). Las llamadas corren una tras otra dentro de este proceso, cada una leyendo el libro de nuevo. Varios procesos necesitan un almacén o un registro propio que se haga cargo.
  • Tu propia base de datos. Un servicio con base de datos implementa el tipo FolioRegistry (reserve, markSent, markAnnulled, release, remaining, stats, cuyos rangos traen environment y cafFingerprint desde la 0.7.0) con sus propias transacciones: reservar un folio debe ser atómico, o dos procesos emitirán el mismo número. Respeta lowestFree si el registro va a respaldar una corrida de certificación.

Errores y rechazos#

Todo lo que el portal lanza es un DteError (revisa Errores). Los códigos propios del portal:

CódigoCuándo
SII_PORTAL_CERT_NOT_ENABLEDEl certificado no está habilitado como método de ingreso en sii.cl.
SII_PORTAL_LOGIN_FAILEDEl inicio de sesión no entregó una sesión.
SII_PORTAL_SESSION_LIMITEl SII informa demasiadas sesiones autenticadas para este RUT, y cerrar las anteriores no ayudó.
SII_PORTAL_SESSION_EXPIREDEl portal siguió pidiendo autenticación en una operación que ya había iniciado sesión, o llegó una página de ingreso donde un paso necesitaba otra.
SII_PORTAL_BLOCKEDEl firewall del SII o un captcha bloqueó la petición. No lo repitas en un ciclo.
SII_PORTAL_REJECTEDEl SII rechazó una solicitud de negocio. details.rejection dice por qué.
SII_PORTAL_UNEXPECTED_PAGEEl portal respondió una página que el SDK no puede interpretar. details trae la operation, el step, el portalMessage y un extracto de la página.

Los problemas de transporte llegan como TRANSPORT_FAILED, REQUEST_TIMEOUT, TRANSPORT_TLS o SII_HTTP_ERROR.

Un rechazo es SII_PORTAL_REJECTED, y details.rejection es una clave de PORTAL_REJECTIONS. portalRejectionMessage(rejection) entrega la explicación para el operador, en español, de qué significa y qué hacer.

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) // lo que dijo el propio SII
  } else {
    throw error
  }
}

details.portalMessage es el texto de la página, decodificado como la página se declara: manda el charset del encabezado, y una página sin charset en el encabezado que declara UTF-8 en un <meta> se lee como UTF-8, así que sus tildes llegan intactas. La página se tacha antes de leer su texto, en cada error y resultado que trae portalMessage, así un token o un RSASK que mostró una página llega como [redacted]. Antes de la 0.6.0 eso se hacía solo en una página de bloqueo.

Los rechazos de las funciones de empresa y de folios:

details.rejectionSignificado
stampingBlockedEl SII no autoriza timbraje: tienes suficientes folios sin usar. Emite con ellos.
inPersonRequiredEl representante legal debe completar la inscripción en persona en la oficina del SII.
companyNotAuthorizedLa empresa no es emisor electrónico en este ambiente. Los permisos de usuario no lo arreglan.
optionNotAuthorizedEl SII dice "no está autorizado para ingresar a esta opción", sin indicar la causa. Lo más frecuente es que la empresa aún no esté autorizada en este ambiente.
userWithoutPermissionEl RUT del certificado no tiene permisos en la empresa: necesita los permisos de firmar, enviar y obtener folios. El SII también lo dice como "No está autorizado para solicitar timbraje".
personWithPendingIssuesLa persona autenticada tiene situaciones pendientes con el SII. Reintentar no lo arregla.
activityVerificationPendingNo hay Verificación de Actividades para este tipo de documento. Se hace en línea en sii.cl y no aplica a boletas.
typeNotEnabledEl SII no ofrece este tipo de documento para timbraje con este RUT.
quotaInsufficientEl SII autoriza menos folios de los que exigiste (minQuantity).
quotaExceededLa cantidad excede el máximo por solicitud.
quotaCapReachedUn flujo se detuvo: después de una entrega, el SII rechazó un folio por sobre el máximo (quotaExceeded), así que no hay más cupo en esta pasada. Solo en resultados de flujos.
foliosShortEl SII otorgó menos folios de los requeridos. Solo en resultados de flujos.
noAnnullableFoliosEl timbraje está limitado y no quedan folios anulables. Solo en resultados de flujos.
stampingUncertainEl SII volvió a mostrar el formulario de solicitud, sin folios y sin motivo, así que no se sabe si autorizó el rango. No se envió nada dos veces. Revisa los rangos que se pueden reobtener antes de pedir de nuevo.
rangeAlreadyAuthorizedEl SII ya autorizó ese rango y no devolvió el archivo. Se puede reobtener.
rangeAnnulledEl rango está anulado: todo documento con esos folios sería rechazado.
reobtainFormMissing, reobtainDownloadMissing, reobtainCafMissingEl flujo de reobtención no obtuvo del SII el formulario, la descarga o el XML del CAF. Una página de ingreso en su lugar es SII_PORTAL_SESSION_EXPIRED.

PORTAL_REJECTIONS también contiene los rechazos de la verificación de actividades, de los portales de certificación, del registro de compras y ventas y del respaldo MIPYME. Se explican donde viven esas funciones: Verificación de actividades, Certificación y Registro de compras y ventas.

Reintentos#

isRetryablePortalError(error) es true para lo que conviene repetir en el portal: TRANSPORT_FAILED, REQUEST_TIMEOUT, HTTP 502, 503 y 504, y las fallas TLS intermitentes que el SII produce con un certificado de cliente (EPROTO, que no indican un certificado malo). Un límite de sesiones, un bloqueo o un rechazo nunca se repite, porque eso los empeora.

DEFAULT_PORTAL_RETRY_POLICY es de 4 intentos, con esperas de 2, 4 y 8 segundos entre ellos, sin jitter, repitiendo lo que acepta isRetryablePortalError. Cámbiala por cliente con retry: { maxAttempts: 2 }, desactívala con retry: false, o ajusta una llamada con el argumento overrides.

Los reintentos funcionan distinto en tres lugares. La anulación intenta cada paso hasta tres veces por su cuenta, como se describió arriba. Las escrituras del registro de compras y ventas son siempre un solo intento, y sus lecturas, como las de la verificación de actividades, hacen un intento por llamada salvo que lo actives. El respaldo MIPYME tiene su propia opción attempts. Revisa Registro de compras y ventas.

Próximos pasos#