Skip to content

Issue an Invoice

A complete server flow that issues a type 33 electronic invoice with a taxable and an exempt line, sends it to the SII, and follows it until the SII accepts it.

The invoice we'll issue:

LineCalculationAmount
Licencia anual1 × 120000120000 net
Curso de capacitación (exempt)2 × 4500090000 exempt
IVA19% of 12000022800
Total120000 + 22800 + 90000232800

Setup#

The flow reads its secrets and settings from the environment:

VariablePurpose
DTE_CAF_33_PATHPath to the CAF XML for invoices (type 33)
DTE_CERT_PATHPath to the .pfx digital certificate
DTE_CERT_PASSWORDThe certificate's password
DTE_SENDER_RUTRUT of the person who owns the certificate
SII_ENVIRONMENTcertification or production
SII_RESOLUTION_NUMBERNumber of the SII resolution that authorizes you (0 in certification)
SII_RESOLUTION_DATEDate of that resolution, YYYY-MM-DD

db in the snippets stands for your own persistence layer.

1. Load the CAF and Certificate Once#

Parsing the CAF and opening the certificate are the slow parts. Do them once per process and share the result:

TypeScript
// lib/dte.ts
import { readFile } from 'node:fs/promises'
import { loadCertificate, parseCaf, type Caf, type Certificate } from 'dte-sdk'
 
export const ISSUER = {
  rut: '76.123.456-0',
  businessName: 'Arrow Software SpA',
  activity: 'Desarrollo de software',
  acteco: 620200,
  address: 'Av. Providencia 1234',
  commune: 'Providencia',
}
 
export const SII = {
  environment: process.env.SII_ENVIRONMENT as 'certification' | 'production',
  senderRut: process.env.DTE_SENDER_RUT!,
  resolution: {
    number: Number(process.env.SII_RESOLUTION_NUMBER),
    date: process.env.SII_RESOLUTION_DATE!,
  },
}
 
type Credentials = { caf: Caf; certificate: Certificate }
let credentials: Promise<Credentials> | undefined
 
async function load(): Promise<Credentials> {
  const [cafXml, pfx] = await Promise.all([
    readFile(process.env.DTE_CAF_33_PATH!, 'latin1'), // CAF files are ISO-8859-1
    readFile(process.env.DTE_CERT_PATH!),
  ])
  return {
    caf: parseCaf(cafXml),
    certificate: await loadCertificate(pfx, process.env.DTE_CERT_PASSWORD!),
  }
}
 
export function getCredentials(): Promise<Credentials> {
  credentials ??= load().catch((error) => {
    credentials = undefined // try again on the next call
    throw error
  })
  return credentials
}

2. Build, Stamp and Sign#

One function turns an order into a signed invoice. The job in step 5 reuses it to rebuild an invoice that couldn't be uploaded, so it accepts an optional issuedAt:

TypeScript
// lib/invoice.ts
import { createDte, signDte, stampDte, type Dte, type LineItem } from 'dte-sdk'
import { ISSUER, getCredentials } from './dte'
 
export type InvoiceInput = {
  folio: number
  issuedAt?: string // defaults to today in America/Santiago
  receiver: Dte['receiver']
  items: LineItem[]
}
 
export async function buildInvoice({ folio, issuedAt, receiver, items }: InvoiceInput) {
  const { caf, certificate } = await getCredentials()
 
  const dte = createDte({
    type: 33,
    folio,
    issuedAt,
    issuer: ISSUER,
    receiver,
    items,
    paymentMethod: 'credit',
  })
 
  return signDte(stampDte(dte, caf), certificate)
}

3. Handle the Request#

The handler allocates a folio, stores the signed invoice, and then sends it. Two details matter:

  • The folio is allocated inside a transaction with the insert. If createDte() or stampDte() throws, the transaction rolls back and the folio isn't burned.
  • The signed XML is stored before sending. Once signed, it's the legal document; if the SII is down, it stays as signed and the job in step 5 uploads it later.
TypeScript
// app/invoices/route.ts
import { DteError, sendToSii } from 'dte-sdk'
import { SII, getCredentials } from '@/lib/dte'
import { buildInvoice, type InvoiceInput } from '@/lib/invoice'
import { toErrorResponse } from '@/lib/errors'
import { db } from '@/lib/db'
 
export async function POST(request: Request): Promise<Response> {
  const { receiver, items } = (await request.json()) as Pick<InvoiceInput, 'receiver' | 'items'>
 
  try {
    const signed = await db.transaction(async (tx) => {
      const folio = await tx.folios.next(33)
      const signed = await buildInvoice({ folio, receiver, items })
      await tx.invoices.insert({
        folio,
        issuedAt: signed.issuedAt,
        receiver,
        items,
        totals: signed.totals,
        xml: signed.xml,
        state: 'signed',
      })
      return signed
    })
 
    try {
      const { certificate } = await getCredentials()
      const { trackId } = await sendToSii(signed, { certificate, ...SII })
 
      // Persist the track id: it's the only way to ask for the result later
      await db.invoices.update(signed.folio, { trackId, state: 'sent' })
 
      return Response.json({ folio: signed.folio, trackId, totals: signed.totals }, { status: 201 })
    } catch (error) {
      if (error instanceof DteError && error.code === 'SII_UNAVAILABLE') {
        // Stored as 'signed': the status job uploads it when the SII is back
        return Response.json({ folio: signed.folio, state: 'pending_upload' }, { status: 202 })
      }
      throw error
    }
  } catch (error) {
    return toErrorResponse(error)
  }
}

Calling it with our two lines:

TypeScript
const response = await fetch('/invoices', {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify({
    receiver: {
      rut: '77.654.321-7',
      businessName: 'Comercial Los Andes Ltda.',
      activity: 'Comercio al por menor',
      address: 'Huérfanos 1055',
      commune: 'Santiago',
    },
    items: [
      { name: 'Licencia anual', quantity: 1, unitPrice: 120000 },
      { name: 'Curso de capacitación', quantity: 2, unitPrice: 45000, exempt: true },
    ],
  }),
})
 
await response.json()
// {
//   folio: 1024,
//   trackId: '0234567890',
//   totals: { net: 120000, exempt: 90000, vatRate: 19, vat: 22800, total: 232800 }
// }

4. Map Errors to Responses#

Every failure is a DteError with a stable code. Group the codes by who can fix the problem:

TypeScript
// lib/errors.ts
import { DteError } from 'dte-sdk'
import { alertOps } from './ops' // your alerting
 
export function toErrorResponse(error: unknown): Response {
  if (!(error instanceof DteError)) throw error
 
  switch (error.code) {
    // Bad input: the client can fix it
    case 'MISSING_FIELD':
    case 'INVALID_RUT':
    case 'INVALID_AMOUNT':
      return Response.json({ error: error.code }, { status: 422 })
 
    // No usable CAF: someone has to load a new one
    case 'FOLIO_OUT_OF_RANGE':
    case 'CAF_EXPIRED':
    case 'CAF_MISMATCH':
      alertOps(error.code)
      return Response.json({ error: 'FOLIOS_UNAVAILABLE' }, { status: 503 })
 
    // Wrong certificate file or password
    case 'CERTIFICATE_INVALID':
      alertOps(error.code)
      return Response.json({ error: 'CONFIGURATION' }, { status: 500 })
 
    // The SII refused the upload: check sender RUT, resolution and certificate
    case 'SII_REJECTED':
      alertOps(error.code)
      return Response.json({ error: error.code }, { status: 502 })
 
    case 'SII_UNAVAILABLE':
      return Response.json({ error: error.code }, { status: 503 })
  }
}

DteError messages never include RUTs, amounts, CAF keys or passwords, so they are safe to log as they are.

5. Follow the Status From a Job#

The SII validates documents asynchronously. Instead of holding the request open, run a job every few minutes that uploads anything still signed and asks about anything sent:

TypeScript
// jobs/sync-invoices.ts
import { DteError, getStatus, sendToSii } from 'dte-sdk'
import { ISSUER, SII, getCredentials } from '@/lib/dte'
import { buildInvoice } from '@/lib/invoice'
import { db } from '@/lib/db'
 
export async function syncInvoices() {
  const { certificate } = await getCredentials()
 
  try {
    // Upload invoices that couldn't be sent: same folio, date, receiver and lines
    for (const invoice of await db.invoices.findMany({ state: 'signed' })) {
      const signed = await buildInvoice(invoice)
      const { trackId } = await sendToSii(signed, { certificate, ...SII })
      await db.invoices.update(invoice.folio, { trackId, xml: signed.xml, state: 'sent' })
    }
 
    // Ask the SII about invoices in flight
    for (const invoice of await db.invoices.findMany({ state: 'sent' })) {
      const status = await getStatus(invoice.trackId, {
        certificate,
        environment: SII.environment,
        issuerRut: ISSUER.rut,
      })
      if (status.state === 'received') continue // still validating
 
      const result = status.documents.find((doc) => doc.type === 33 && doc.folio === invoice.folio)
      await db.invoices.update(invoice.folio, {
        state: result?.state ?? status.state,
        siiReason: result?.reason,
      })
    }
  } catch (error) {
    if (error instanceof DteError && error.code === 'SII_UNAVAILABLE') return // next run
    throw error
  }
}

An invoice moves through these states:

StateSet byNext
signedThe request handlerThe job uploads it
sentThe handler or the job, with a trackIdThe job polls getStatus()
acceptedThe jobDeliver the invoice to the customer
accepted_with_objectionsThe jobValid; review siiReason
rejectedThe jobReview siiReason, fix the cause, issue again

See Acceptance States for what each SII state means.

Moving to Production#

When the SII authorizes you:

  1. Set SII_ENVIRONMENT to production.
  2. Set SII_RESOLUTION_NUMBER and SII_RESOLUTION_DATE to your authorization resolution.
  3. Point DTE_CAF_33_PATH to a CAF issued for production.

The code doesn't change.