Saltar al contenido
dte-sdk

Timbre

Todo documento tributario electrónico lleva un timbre electrónico, el TED. El SII autoriza un rango de folios con un CAF (Código de Autorización de Folios), un archivo XML que contiene el rango, el emisor autorizado y la llave privada que firma cada timbre. parseCaf lee ese archivo, stampDocument arma el TED de un documento y lo firma con la llave del CAF, y verifyTed comprueba un timbre contra la clave pública del CAF.

Todo lo de esta página vive en la entrada dte-sdk.

El XML del CAF contiene una llave privada (RSASK). Trata el archivo como un secreto, igual que una contraseña o un certificado: no lo subas a un repositorio, no lo imprimas en logs, no lo dejes en un bucket que cualquiera pueda leer. Quien lo tenga puede timbrar documentos con tus folios. Caf.privateKeyPem y Caf.xml contienen el secreto una vez parseado.

El timbre está probado contra datos sintéticos, pero ningún documento timbrado se ha enviado todavía al SII real. Mira Estado.

Uso#

TypeScript
import { readFileSync } from 'node:fs'
import { createDocument, parseCaf, stampDocument, verifyTed } from 'dte-sdk'
 
const caf = parseCaf(readFileSync('caf-33.xml', 'utf8'))
 
const document = createDocument(input) // un tipo 33 con un folio dentro del rango del CAF
const stamped = stampDocument(document, caf)
 
stamped.ted.folio // 7
stamped.ted.totalAmount // 119000
stamped.ted.stampedAt // '2026-11-15T15:30:00', hora de Chile
stamped.ted.xml // '<TED version="1.0"><DD>…</DD><FRMT algoritmo="SHA1withRSA">…</FRMT></TED>'
 
verifyTed(stamped.ted.xml, caf) // true

stampDocument devuelve un StampedDocument: tu Document más un ted. No modifica el documento: stamped.header es igual a document.header. Para obtener el XML final todavía debes firmar el documento timbrado (mira Firma), o dejar que issueDocument ejecute todos los pasos.

Parsear un CAF#

parseCaf(xml) recibe el texto del archivo CAF tal como lo emitió el SII y devuelve un Caf. Valida la estructura, el tipo de documento, el rango y la llave privada, y lanza CAF_INVALID cuando falta algo o está mal formado. Desde la 0.4.0 también lanza CAF_INVALID para un DOCTYPE, o cuando el texto tiene más de un <CAF o </CAF en cualquier parte, también en un comentario: el bloque <CAF> que se copia en cada TED debe ser el CAF del que se leen sus campos. Un CAF tal como lo emite el SII se lee igual que antes.

Desde la 0.7.0 chequea que la llave privada RSASK sea el par de la clave pública RSAPK que firmó el SII en el DA, como el SDK ya chequea la llave de un certificado. Antes, un CAF con otra llave se parseaba, y cada timbre firmado con él fallaba el chequeo del SII. Llaves que no son par lanzan CAF_INVALID con el motivo RSASK is not the pair of RSAPK. Se comparan como números, así que un módulo partido en varias líneas o con un byte cero al inicio sigue siendo par, y un CAF que emitió el SII siempre lo es. Sus campos también leen las referencias numéricas de carácter (&#209;, &#xD1;) como las leen el DOM de verifyCafSignature y el SII: un CAF cuyo escritor convirtió una Ñ en &#209; da issuerName con la Ñ, donde antes guardaba la referencia, y uno con una referencia en RE o TD ya no falla al parsearse. Una referencia a un carácter que XML no permite queda tal cual, y no se expande ninguna otra entidad. El bloque <CAF> que se guarda para el TED sigue siendo el texto tal como lo emitió el SII.

CampoEtiqueta SIISignificado
issuerRutDA/RERUT del emisor autorizado, validado con rut.ts (INVALID_RUT cuando no es válido).
issuerNameDA/RSRazón social del emisor, como la escribió el SII (vacía cuando falta).
documentTypeDA/TDEl tipo de documento del rango. Uno de los nueve tipos soportados, o CAF_INVALID.
rangeDA/RNG{ from, to }, inclusivo.
authorizationDateDA/FAAAAA-MM-DD, o null cuando el archivo no la trae.
publicKeyDA/RSAPK{ modulus, exponent }, la clave que verifica las firmas del TED.
keyIdDA/IDKIdentificador de llave. 100 (CERTIFICATION_KEY_ID) marca un CAF del ambiente de certificación, y 300 uno de producción.
siiSignatureFRMALa firma del SII sobre los datos de autorización, sin espacios.
privateKeyPemRSASKLa llave privada que firma cada TED. Secreto.
xmlCAFEl bloque <CAF>…</CAF> tal como lo emitió el SII, sin los espacios entre etiquetas. Se copia íntegro en cada TED.
TypeScript
const caf = parseCaf(readFileSync('caf-33.xml', 'utf8'))
 
caf.issuerRut // '76543210-3'
caf.documentType // 33
caf.range // { from: 1, to: 100 }
caf.authorizationDate // '2026-10-31'
caf.keyId === CERTIFICATION_KEY_ID // true para un CAF de certificación

parseCaf necesita el texto completo del archivo, incluido RSASK. No verifica la firma del SII (FRMA) del CAF, la prueba de que el SII lo emitió: eso lo hace verifyCafSignature(caf.xml), con las llaves con que el SII firma cada CAF (revisa Las llaves del SII y el ambiente).

CAFs y vencimiento#

Un CAF de facturas y notas (33, 46, 56 y 61) vence seis meses después de su fecha de autorización. El SII rechaza todo documento timbrado con un CAF vencido (CAF-3-517), incluso si el sobre llega después de la fecha en que se timbró. Los otros tipos (34, 39, 41, 43, 52) no vencen.

TypeScript
import { cafExpiresAt, isCafExpired } from 'dte-sdk'
 
cafExpiresAt(caf) // Date, o null para los tipos que no vencen o un CAF sin fecha
isCafExpired(caf) // true cuando venció o vence dentro de un día
isCafExpired(caf, { now: new Date('2026-10-07T18:35:00Z'), marginMs: 0 })
FunciónResultado
cafExpiresAt(caf)El instante de vencimiento: seis meses después de authorizationDate, a medianoche de Chile continental (UTC-4). Un día que el sexto mes no tiene es su último día: un CAF autorizado el 31-08-2026 vence el 28-02-2027, no en marzo. null para los tipos que no vencen, o cuando el CAF no trae fecha de autorización. Para un CAF autorizado el 7 de abril de 2026 es 2026-10-07T04:00:00.000Z.
isCafExpired(caf, options?)true cuando el CAF venció o vence dentro de marginMs. options.now es el reloj; options.marginMs es por defecto un día (24 horas).

El margen de un día es a propósito: el sobre puede llegar al SII después de que timbraste el documento, y un CAF que vence esta noche no es seguro de usar. stampDocument ejecuta este chequeo por ti (mira más abajo).

Rango de folios#

TypeScript
import { assertFolioInRange, availableFolios, isFolioInRange } from 'dte-sdk'
 
availableFolios(caf) // 100, el tamaño del rango
isFolioInRange(caf, 100) // true
isFolioInRange(caf, 101) // false
assertFolioInRange(caf, 101) // lanza FOLIO_OUT_OF_RANGE

isFolioInRange(caf, folio) es true para un entero dentro del rango inclusivo. assertFolioInRange(caf, folio) lanza FOLIO_OUT_OF_RANGE en caso contrario, con details { folio, from, to, type }. availableFolios(caf) es la cantidad de folios que autoriza el CAF (to - from + 1): cuenta el rango completo, no los folios que aún no has usado.

Estas funciones no recuerdan nada. Qué folios ya usaste es tu contabilidad: no timbres dos veces el mismo folio y no saltes folios sin motivo. El registro de folios de dte-sdk/portal (createFolioRegistry) lleva ese libro; mira Portal.

Timbrar un documento#

stampDocument(document, caf, options?) revisa el documento contra el CAF, arma el string DD exacto, lo firma con la llave privada del CAF y devuelve el documento con su ted.

OpciónSignificado
nowReloj para TSTED y para el chequeo de vencimiento.
checkExpiryChequear el vencimiento del CAF (por defecto true). Ponlo en false solo cuando sepas por qué.
stampedAtTSTED exactamente como se entrega, en vez de la hora de Chile de now.

Los chequeos, y el error que lanza cada uno:

ErrorCuándo
CAF_TYPE_MISMATCHEl CAF es de otro tipo de documento que el documento (details: type, cafType).
CAF_ISSUER_MISMATCHEl RUT del emisor del CAF no es el RUT del emisor del documento.
FOLIO_OUT_OF_RANGEEl folio está fuera del rango del CAF.
CAF_EXPIREDEl CAF venció o vence dentro de un día (isCafExpired), salvo que checkExpiry sea false.

CAF_INVALID viene de parseCaf, no de stampDocument.

TypeScript
import { isDteError, stampDocument } from 'dte-sdk'
 
try {
  stampDocument(document, caf)
} catch (error) {
  if (isDteError(error) && error.code === 'FOLIO_OUT_OF_RANGE') {
    error.details // { folio: 101, from: 1, to: 100, type: 33 }
    // pide otro CAF
  }
}

El TED#

El Ted tiene los campos del timbre, el bloque CAF, la hora del timbre y la firma:

CampoEtiqueta SIISignificado
issuerRutRERUT del emisor.
documentTypeTDTipo de documento.
folioFFolio.
issueDateFEFecha de emisión.
receiverRutRRRUT del receptor (66666666-6 en una boleta sin receptor).
receiverNameRSRRazón social del receptor, plegada a ASCII, 40 caracteres como máximo.
totalAmountMNTEl total del documento (totalAmount).
firstItemNameIT1Nombre del primer ítem, plegado a ASCII, 40 caracteres como máximo.
cafCAFEl bloque <CAF>…</CAF> exacto.
stampedAtTSTEDHora del timbre en Chile, AAAA-MM-DDTHH:MM:SS.
signatureFRMTFirma SHA1withRSA del <DD>, en base64.
xmlTEDEl XML completo <TED version="1.0">…</TED>.

Los dos campos de texto se pliegan a ASCII puro con tedText antes de cortarlos a 40 caracteres: se quitan las tildes (á pasa a a, ñ pasa a n), se expanden las ligaduras y letras especiales (ß pasa a ss) y se elimina lo que siga fuera de ASCII. La razón es que el lector PDF417 del SII pierde los bytes iguales o mayores a 128 y verifica la firma sobre lo que leyó, así que una sola tilde dentro del timbre termina en "TED - Firma invalida". Primero se pliega y después se corta, porque el plegado puede alargar el texto.

TypeScript
import { tedText } from 'dte-sdk'
 
tedText('Cajón de peras') // 'Cajon de peras'
tedText('x'.repeat(39) + 'ß') // 'x'.repeat(39) + 's' (ß se pliega a 'ss' y luego se corta a 40)
tedText('x'.repeat(50)) // 'x'.repeat(40)

Una boleta sin receptor timbra al consumidor final: ted.receiverRut es 66666666-6 y ted.receiverName es Consumidor Final.

Qué se firma#

La firma cubre el string <DD>…</DD> exacto: RE, TD, F, FE, RR, RSR, MNT, IT1, el bloque CAF y TSTED, en ese orden, con RSR e IT1 escapados como texto XML. Los bytes son los bytes ISO-8859-1 de ese string, firmados con RSA y SHA-1 (SHA1withRSA, PKCS#1 v1.5). Es el mismo string que lleva el código de barras PDF417 del documento impreso. La firma es determinista: el mismo documento, CAF y TSTED dan la misma firma.

Funciones de bajo nivel#

Rara vez las necesitarás, pero están exportadas:

FunciónQué hace
buildDdString(fields)El string <DD>…</DD> exacto, desde los campos de un Ted sin signature ni xml. Un Ted completo también sirve.
buildTedXml(dd, signature)Envuelve un DD y su firma en base64 en <TED version="1.0">…<FRMT algoritmo="SHA1withRSA">…</FRMT></TED>.
tedText(text)Pliega el texto a ASCII y lo corta a 40 caracteres, como necesitan RSR e IT1.
signWithCaf(caf, data)Firma SHA1withRSA (base64) de data con la llave privada del CAF, sobre sus bytes ISO-8859-1. Lanza ENCODING_UNSUPPORTED_CHAR si data tiene un carácter sobre U+00FF.
verifyWithCaf(caf, data, signature)true cuando signature (base64) coincide con data bajo la clave pública del CAF (RSAPK). Nunca lanza: una entrada mal formada da false.

signWithCaf solo necesita caf.privateKeyPem y verifyWithCaf solo caf.publicKey, así que puedes verificar con un CAF que no lleve el secreto.

Verificar un timbre#

verifyTed(tedXml, caf) devuelve true solo cuando los bytes del <DD> del TED coinciden con su firma FRMT bajo la clave pública del CAF. Da false para un DD alterado, una firma alterada, un TED sin DD o sin FRMT, o un TED timbrado con otro CAF:

TypeScript
verifyTed(stamped.ted.xml, caf) // true
verifyTed(stamped.ted.xml.replace('<MNT>119000</MNT>', '<MNT>1</MNT>'), caf) // false

No lanza y no revisa que los montos del TED coincidan con el documento que lo rodea. Úsalo con tus propios documentos (signed.ted.xml) o con documentos recibidos, con el CAF que corresponda. Para un documento recibido, verifyReceivedDocuments hace más: toma el CAF del propio timbre, compara el DD con el encabezado y revisa la firma del SII sobre ese CAF (revisa Verificar documentos recibidos).

Siguientes pasos#

  • Firma: carga el certificado y firma el documento timbrado.
  • Documentos: issueDocument ejecuta crear, timbrar y firmar en una sola llamada.
  • Portal: solicita un CAF y lleva el registro de folios.
  • Seguridad: dónde guardar el CAF y el certificado.