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:
| Line | Calculation | Amount |
|---|---|---|
| Licencia anual | 1 × 120000 | 120000 net |
| Curso de capacitación (exempt) | 2 × 45000 | 90000 exempt |
| IVA | 19% of 120000 | 22800 |
| Total | 120000 + 22800 + 90000 | 232800 |
Setup#
The flow reads its secrets and settings from the environment:
| Variable | Purpose |
|---|---|
DTE_CAF_33_PATH | Path to the CAF XML for invoices (type 33) |
DTE_CERT_PATH | Path to the .pfx digital certificate |
DTE_CERT_PASSWORD | The certificate's password |
DTE_SENDER_RUT | RUT of the person who owns the certificate |
SII_ENVIRONMENT | certification or production |
SII_RESOLUTION_NUMBER | Number of the SII resolution that authorizes you (0 in certification) |
SII_RESOLUTION_DATE | Date 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:
// 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:
// 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()orstampDte()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
signedand the job in step 5 uploads it later.
// 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:
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:
// 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:
// 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:
| State | Set by | Next |
|---|---|---|
signed | The request handler | The job uploads it |
sent | The handler or the job, with a trackId | The job polls getStatus() |
accepted | The job | Deliver the invoice to the customer |
accepted_with_objections | The job | Valid; review siiReason |
rejected | The job | Review siiReason, fix the cause, issue again |
See Acceptance States for what each SII state means.
Moving to Production#
When the SII authorizes you:
- Set
SII_ENVIRONMENTtoproduction. - Set
SII_RESOLUTION_NUMBERandSII_RESOLUTION_DATEto your authorization resolution. - Point
DTE_CAF_33_PATHto a CAF issued for production.
The code doesn't change.
Related#
- Issue a Credit Note - Void or correct this invoice
createDte()- All the fields a document acceptssendToSii()- Upload options, retries andgetStatus()- Security - Storing the CAF and certificate safely