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#
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#
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ón | Por defecto | Qué hace |
|---|---|---|
environment | obligatoria | 'certification' o 'production'. Elige el host del SII de las páginas DTE (maullin o palena). |
certificate | obligatoria | El certificado que se presenta al iniciar sesión. Su titular es el usuario del portal. |
companyRut | ninguna | La empresa que usa cada operación, salvo que la llamada indique su propio companyRut. Si no hay ninguno, la llamada lanza MISSING_FIELD. |
transport | https de Node con el certificado de cliente, TLS 1.2 | El transporte HTTP. Pasa el tuyo para probar o para salir por un proxy. |
sessionCache | en memoria | Un TokenCache (get, set, delete de { token, expiresAt }) donde vive la sesión. createDirectoryTokenCache({ dir }) de dte-sdk/node la guarda en disco. |
sessionTtlMs | 90 minutos | Cuánto tiempo se confía en una sesión en caché. Se renueva en cada uso. |
lock | exclusión mutua dentro del proceso | Un PortalLock: quién puede usar la sesión ahora. |
retry | 4 intentos a los 2, 4 y 8 s | Una RetryPolicy parcial que se mezcla sobre la del portal, o false para desactivar los reintentos. Revisa Reintentos. |
timeoutMs | 20000 | Timeout de cada petición, en milisegundos. |
closePreviousSessions | true | Cuando el SII informa el límite de sesiones y ofrece cerrar las anteriores, aceptar. |
logger | silencioso | Un Logger con debug, info, warn y error. Recibe nombres de operación, intentos, estados y códigos, nunca tokens ni XML en el mensaje. |
capture | apagado | Un puerto HttpCapture que recibe cada intercambio HTTP, para depurar. |
now | new Date() | El reloj. |
sleep | una espera real | La espera entre reintentos y entre pasos. Inyecta una para que las pruebas sean instantáneas. |
randomId | crypto.randomUUID | Fuente 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#
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
sessionCachedespués de cada operación, con una vigencia desessionTtlMs(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.openPortalSessioncomprueba 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
sessionCachepor defecto, un reinicio vuelve a iniciar sesión.createDirectoryTokenCache({ dir })dedte-sdk/nodeguarda 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 sulogger. - 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
lockcompartido, 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
sessionCachecompartido (Redis, una base de datos) y unlockcompartido (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.PortalLockes(key, run) => Promise:runparte 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
closePreviousSessionsseafalse) e intenta una vez más. Si eso no ayuda, lanzaSII_PORTAL_SESSION_LIMIT. Nunca repite este error en un ciclo. closePortalSessioncierra 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.
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' }),
})// 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#
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ón | Devuelve | Notas |
|---|---|---|
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, glosa | Esta 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, resolutionNumber | Las 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:
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_PAGEconstep: '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_REJECTEDconactivityVerificationRefused, con sus palabras endetails.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#
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 }listStampableTypeslista 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 lanzaSII_PORTAL_UNEXPECTED_PAGEen vez de leerse como "sin tipos".queryFolioQuotalee 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_EXPIREDsi pide iniciar sesión ySII_PORTAL_UNEXPECTED_PAGEen cualquier otro caso. maxAuthorized(elMAX_AUTORdel 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 sonnullyunrestrictedestrue.blockedestruecuando el SII responde "NO SE AUTORIZA TIMBRAJE". Ese caso vuelve como dato, no como error.- Cualquier otro rechazo lanza
SII_PORTAL_REJECTED, por ejemplotypeNotEnabledcuando el SII no ofrece el tipo para este RUT.
- 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
Solicitar un CAF#
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ónrequestCaf 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,
requestCaflimita la solicitud amaxAuthorized. - Si el SII responde que se excedió el máximo efectivo y no indicaste
minQuantity,requestCafpide 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),
requestCafintenta descargarlo de nuevo y lo devuelve conrecovered: 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 comoCAF_INVALID, como uno mal formado. Un CAF que emitió el SII siempre es par. minQuantityes la forma estricta. Si el SII autorizara menos queminQuantity, no se timbra nada y la llamada lanzaSII_PORTAL_REJECTEDconquotaInsufficient. 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:
requestCaflanzaSII_PORTAL_REJECTEDconstampingUncertain, 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#
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.
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 recorridoAnular 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:
| Alcance | Efecto |
|---|---|
range: { from, to } | Solo los folios sin usar dentro de ese rango. Un rango listado que lo cruza se recorta. |
withinDays: n | Solo los rangos timbrados en los últimos n días. Un rango sin fecha legible se omite. |
all: true | Todos 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 deknownAnnulled, en una o en varias, y luego intenta los últimosmaxRangesrangos que aún no ha intentado (50 por defecto). - Hay hasta cuatro pasadas, con una pausa corta entre ellas, así que
maxRangeslimita una pasada y no la llamada: una llamada puede anular hasta cuatro vecesmaxRangesrangos (200 por defecto). - Una pasada se detiene tras dos negativas reales seguidas.
alreadyAnnulledyreceivedno 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,rejectedyknownAnnulled, 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, confailure. - 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, confailure.
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.
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.
listReobtainableRangeslo averigua con una petición extra por rango y llenaannulled. listReobtainableRangesentra 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 lanzaSII_PORTAL_REJECTED, una de ingresoSII_PORTAL_SESSION_EXPIREDy cualquier otraSII_PORTAL_UNEXPECTED_PAGE.reobtainCafentra primero al trámite, como lo hacelistReobtainableRanges, 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 unGETmás, derf_reobtencion1_folios. Una página que declara un rechazo en la entrada lanzaSII_PORTAL_REJECTED, y cualquier otra que no es el trámiteSII_PORTAL_UNEXPECTED_PAGE, antes de enviar nada.reobtainCaflanzaSII_PORTAL_REJECTEDconrangeAnnulled,reobtainFormMissing,reobtainDownloadMissingoreobtainCafMissing. Una página de ingreso en respuesta al rango o al archivo esSII_PORTAL_SESSION_EXPIRED, nunca uno de esos rechazos: antes de la 0.7.0 una sesión vencida se leía comoreobtainDownloadMissing.
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:
| Campo | Significado |
|---|---|
complete | Se cubrió la cantidad pedida. |
cafs | Todos los CAF obtenidos, aunque el total no alcance. Guárdalos antes que cualquier otra cosa. |
granted | Folios cubiertos por cafs. |
maxAuthorized, availableFolios | El último MAX_AUTOR y FOLIOS_DISP vistos, o null. |
rejection | Por qué no alcanzó (una clave de PortalRejection), o null. |
portalMessage | Lo que dijo el portal, con sus secretos tachados, o null. |
failure | Lo 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#
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
availableFolioses 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 nofailure. - Una anulación cortada conserva su memoria. Una vez que el SII respondió la anulación, un
DteErrorque la corta, o uno de la solicitud que se repite después, termina el flujo concomplete: false,rejection: null, el cupo de la primera solicitud,knownAnnulledcon lo anulado yfailure: el de la anulación (listFolioRangesoannulFolios) o el de la solicitud (requestCaf). Un listado que el SII rechaza durante la anulación guarda su tipo enfailure.rejection, porquerejectionhabla 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.0failuresiempre eranullaquí: léelo como enrequestCafInBatches.
allowAnnul está apagado por defecto por la irreversibilidad descrita arriba.
Varios CAF: requestCafInBatches#
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.
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
failureademás derejection. Un resultado corto conrejection: nulles una tanda cortada, no una entrega menor. - Un
requestCafque falló pudo otorgarse igual. RevisalistReobtainableRangesantes de pedir de nuevo. quantitydebe ser un entero de al menos 1, o la llamada lanzaINVALID_OPTIONconfield: 'quantity'antes de que salga nada.
Recuperar CAF perdidos: reobtainFolios#
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 recorridoCubre 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
isIssueddice 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. ConisFolioFree(normalmente una llamada afolioReceiveddel cliente de web services, revisa Servicios web del SII), cada folio se verifica, hastamaxChecksPerRangepor 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
availablecon unreason. - Un CAF que ya venció (o vence dentro de un día) no cuenta: va a
expiredy 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 enreasonyfailure: { 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 unDteErrorsiempre 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 yfailure, 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.
reobtainFoliosrecupera 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.
requestCafInBatcheses para unMAX_AUTORchico sin nada sin usar que anular o recuperar.requestCafExactquiere 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.
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étodo | Qué 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 })(dedte-sdk/node) guarda unfolios.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 enextra: el directorio se crea con modo0700(uno que ya existe conserva sus permisos) y el archivo con0600. 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 unFolioLedgerStoretuyo (loadysavede unFolioLedger, 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 traenenvironmentycafFingerprintdesde la 0.7.0) con sus propias transacciones: reservar un folio debe ser atómico, o dos procesos emitirán el mismo número. RespetalowestFreesi 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ódigo | Cuándo |
|---|---|
SII_PORTAL_CERT_NOT_ENABLED | El certificado no está habilitado como método de ingreso en sii.cl. |
SII_PORTAL_LOGIN_FAILED | El inicio de sesión no entregó una sesión. |
SII_PORTAL_SESSION_LIMIT | El SII informa demasiadas sesiones autenticadas para este RUT, y cerrar las anteriores no ayudó. |
SII_PORTAL_SESSION_EXPIRED | El 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_BLOCKED | El firewall del SII o un captcha bloqueó la petición. No lo repitas en un ciclo. |
SII_PORTAL_REJECTED | El SII rechazó una solicitud de negocio. details.rejection dice por qué. |
SII_PORTAL_UNEXPECTED_PAGE | El 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.
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.rejection | Significado |
|---|---|
stampingBlocked | El SII no autoriza timbraje: tienes suficientes folios sin usar. Emite con ellos. |
inPersonRequired | El representante legal debe completar la inscripción en persona en la oficina del SII. |
companyNotAuthorized | La empresa no es emisor electrónico en este ambiente. Los permisos de usuario no lo arreglan. |
optionNotAuthorized | El 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. |
userWithoutPermission | El 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". |
personWithPendingIssues | La persona autenticada tiene situaciones pendientes con el SII. Reintentar no lo arregla. |
activityVerificationPending | No hay Verificación de Actividades para este tipo de documento. Se hace en línea en sii.cl y no aplica a boletas. |
typeNotEnabled | El SII no ofrece este tipo de documento para timbraje con este RUT. |
quotaInsufficient | El SII autoriza menos folios de los que exigiste (minQuantity). |
quotaExceeded | La cantidad excede el máximo por solicitud. |
quotaCapReached | Un 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. |
foliosShort | El SII otorgó menos folios de los requeridos. Solo en resultados de flujos. |
noAnnullableFolios | El timbraje está limitado y no quedan folios anulables. Solo en resultados de flujos. |
stampingUncertain | El 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. |
rangeAlreadyAuthorized | El SII ya autorizó ese rango y no devolvió el archivo. Se puede reobtener. |
rangeAnnulled | El rango está anulado: todo documento con esos folios sería rechazado. |
reobtainFormMissing, reobtainDownloadMissing, reobtainCafMissing | El 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#
- Registro de compras y ventas: lee el RCV y descarga el respaldo MIPYME en la misma sesión.
- Certificación: el runner usa el portal, el asignador de folios y el registro de folios.
- Muestras impresas: los PDF que pide la certificación.
- Ejemplos de folios: un flujo completo, del cupo al registro.
- Referencia de la API de
dte-sdk/portal: cada firma y cada tipo.