Purchase and sales register
The RCV (Registro de Compras y Ventas) is the SII's own record of what you bought and sold. dte-sdk/portal reads it on the same certificate session as the rest of the portal, changes how a purchase is classified, and downloads the XML of your issued or received documents from the MIPYME backup. It also holds, as data, what the register does with each of its document codes. This page covers what each function reads, which SII service it calls, and the traps: a refused month that reads as empty, writes that change the real register, and a known defect in the backup.
Everything here is tested against synthetic SII answers, with a 100 % coverage gate in CI. Against the real SII, only reads have been answered: in production, on 9 and 10 November 2026, fetchRegisterSummary, listPeriodDocuments, fetchPurchaseDocument and listPurchaseTypes answered with the rows of a company with one received invoice, and earlier, read-only requests for a company with no documents. Downloads from the MIPYME backup and writes have not been seen yet. The names of the row fields come from the SII's own client code, and so far they have met that one invoice only. See Status.
dte-sdk 0.8.0 is on npm, the latest release. It is not recommended for production yet: most areas have not met the real SII, and until 1.0.0 the API can change in any release. See Status.
Basic usage#
You need a portal client first: see SII portal and folios.
import {
changePurchaseType,
downloadMipymeBackup,
fetchPurchaseDocument,
fetchRegisterSummary,
listPeriodDocuments,
listPurchaseTypes,
moveVatToCommonUse,
} from 'dte-sdk/portal'
// Every document of October 2026 that the company received, one row each
const { summary, documents } = await listPeriodDocuments(portal, { period: '2026-10', direction: 'received' })
// The official totals of the purchases book, per document type
const { rows, header } = await fetchRegisterSummary(portal, { period: '2026-10', operation: 'purchase' })
// How a purchase is classified, and changing it
const types = await listPurchaseTypes(portal)
const key = { type: 33, folio: 1234, period: '2026-10', counterpartyRut: '12345678-5' }
const purchase = await fetchPurchaseDocument(portal, key) // null when the SII has no data for that key
await changePurchaseType(portal, { ...key, purchaseType: '4' }) // an id from listPurchaseTypes
// The full XML of the documents, in pieces of at most 20
await downloadMipymeBackup(portal, {
from: '2026-10-01',
to: '2026-10-31',
direction: 'received',
onChunk: async ({ from, to, xml }) => saveBackup(`${from}_${to}.xml`, xml),
})saveBackup is yours. The backup holds your real documents: store them with the care you give any tax record.
What do I need?#
The register has three services behind it, plus the backup. Each function reads one of them:
| I need | Function | SII service |
|---|---|---|
| Each document of a period (folio, date, amounts), with no receipts and no XML | listPeriodDocuments | consemitidosinternetui |
| The official totals per document type and accounting status, with receipts aggregated | fetchRegisterSummary | consdcvinternetui |
| How a purchase is classified, the types of purchase, and changing them | fetchPurchaseDocument, listPurchaseTypes and the writes | complementoscvui |
| The full XML of a document | downloadMipymeBackup | MIPYME backup |
The register host depends on the client's environment (www4c.sii.cl for certification, www4.sii.cl for production). The backup does not: see The MIPYME backup.
All of these functions take the same last, optional overrides argument as the rest of the portal ({ signal, timeoutMs, retry }), and the optional companyRut that replaces the client's.
Read the register#
Documents of a period#
const { summary, documents } = await listPeriodDocuments(portal, {
period: '2026-10', // YYYY-MM
direction: 'issued', // the default; or 'received'
})
summary[0].type // 33
summary[0].documentCount
documents[0].folio
documents[0].totalAmountIt asks for the summary per document type, then makes one detail call per row, at the same time. It returns:
summary: onePeriodSummaryRowper document type and section:type,typeDescription,section,documentCount,exemptAmount,netAmount,vatAmount,totalAmount. The section isS1sales,S2purchase invoices,S3settlements,S4exports orS5dispatch guides.documents: every document of the summary's types, as aPeriodDocument:type,typeDescription,folio,issueDate,receivedDate,counterpartyRut,counterpartyName, the four amounts,eventOrder(the last event: 2 acknowledged, 3 accepted, 4 claimed) andeventDescription.
This is not the official register. It lists what the SII holds per document, with no time of day and no XML, and it leaves out what the SII aggregates (receipts, for one). counterpartyRut is the other party, not validated: in received documents it holds the issuer.
The official totals#
const { rows, header } = await fetchRegisterSummary(portal, {
period: '2026-10',
operation: 'purchase', // or 'sale'
accountingStatus: 'registered', // the default; 'pending', 'notIncluded' or 'claimed'
})
rows[0].type
rows[0].documentCount
rows[0].commonUseVat
rows[0].nonRecoverableVat
header?.updatedAt
header?.proportionalityFactorThese are the official totals of the period by document type and accounting status, including what the SII aggregates, such as receipts. rows are RegisterSummaryRow: type, typeName, documentCount, exemptAmount, netAmount, vatAmount, commonUseVat, nonRecoverableVat, totalAmount. header is null or has updatedAt (when the register last changed), proportionalityFactor and raw (the header as the SII sent it). The factor is a number from 0 to 1. The SII reportedly sends it as a text with spaces and a decimal comma (" 1,0000", "0,8532"), and that form is read too. A value outside 0 to 1 is not a factor: proportionalityFactor is null and the value stays in raw.
Document codes and accounting statuses#
Each row of the official totals carries a document type. What the register does with each one is data in the package, since 0.2.0: registerDocumentCode(code) gives one of the register's 49 document codes, or undefined, and REGISTER_DOCUMENT_CODES lists them all. Nothing here calls the SII.
import { REGISTER_ACCOUNTING_STATUSES, fetchRegisterSummary, registerDocumentCode } from 'dte-sdk/portal'
const creditNote = registerDocumentCode(61)
creditNote?.name // 'Nota de Crédito Electrónica'
creditNote?.operations // ['purchase', 'sale']
creditNote?.sign // -1: it subtracts from the totals
registerDocumentCode(71) // undefined: not a code of the register
const { rows } = await fetchRegisterSummary(portal, { period: '2026-10', operation: 'purchase' })
for (const row of rows) {
const code = row.type === null ? undefined : registerDocumentCode(row.type)
if (code?.sign === -1) {
// A credit note or a decrease: it subtracts from the period's totals
}
}
// Every accounting status the purchases side can hold; sales hold only 'registered'
for (const accountingStatus of REGISTER_ACCOUNTING_STATUSES.purchase) {
await fetchRegisterSummary(portal, { period: '2026-10', operation: 'purchase', accountingStatus })
}Each entry says which sides of the register show the code (operations), whether it is a DTE (electronic), whether it reaches the register at all (inRegister, false for the dispatch guides 50 and 52), its sign in the totals (-1 for credit notes and decreases), whether the register lists it document by document (perDocument, false for receipts and the other codes it holds only as a monthly summary line) and whether the buyer withholds the VAT (buyerWithholdsVat: 29, 45 and 46). REGISTER_ACCOUNTING_STATUSES gives all four statuses for purchases and only registered for sales, since no SII source describes other states of a sale.
Where the SII states a value, the catalog follows it. The few it does not state are inferred, and the JSDoc of each says so: that the dispatch guides stay outside the register, the sign of each code (the F29's: credit notes subtract), and that 906 is summary only. The amounts of codes 40, 43 and 103 may arrive negative and carry their own sign. The catalog has not been checked against a real register with documents in it yet. The API reference groups the 49 codes by what the catalog says of them.
Purchases: types and detail#
const types = await listPurchaseTypes(portal)
// [{ id, description, interchangeable }, ...]: the types of purchase the SII lets a document have
const purchase = await fetchPurchaseDocument(portal, {
type: 33,
folio: 1234,
period: '2026-10',
counterpartyRut: '12345678-5', // the RUT of the issuer
})
purchase?.purchaseType // the current type of purchaselistPurchaseTypes is measured against the real SII. Its interchangeable flag says that the SII's own client offers moving VAT between recoverable and common use for that type. It does not mean the type can be changed.
fetchPurchaseDocument returns the document as the register holds it, including its current purchaseType, commonUseVat and nonRecoverableVat, or null when the SII has no data for that key. The key is type, folio, period (YYYY-MM) and counterpartyRut, the issuer of the purchase.
Traps when reading#
- A refused month reads as empty. The SDK does not look at the response codes of these services. So
listPeriodDocumentsandfetchRegisterSummarycannot tell a month the SII refuses to show from a month with no movement: both come back with no rows. Do not treat an empty answer as proof that nothing happened. rawholds each row exactly as the SII sent it. The field names of the typed fields come from the SII's own client code and have not been seen with real documents. If one is absent or unreadable, the typed field isnull, and whatever the SII sent is still inraw. Readrawwhen anullsurprises you.- Numbers may be
null. Every amount can benull. Do not do arithmetic on an amount without checking it. - No retries by default. A read makes one attempt per call. Pass
{ retry: { ... } }as theoverridesto repeat each call. A read that meets an expired session or a page it cannot read starts over once with a fresh session.
listPeriodDocuments and fetchRegisterSummary throw INVALID_DATE for a period that is not YYYY-MM, INVALID_OPTION for an unknown direction, operation or accountingStatus, MISSING_FIELD without a company, INVALID_RUT, and SII_PORTAL_UNEXPECTED_PAGE for an answer that is not JSON.
Change the register#
Three functions change how a purchase is recorded.
import { changePurchaseType, fetchPurchaseDocument, moveVatToCommonUse, moveVatToRecoverable } from 'dte-sdk/portal'
const key = { type: 33, folio: 1234, period: '2026-10', counterpartyRut: '12345678-5' }
// Change the type of purchase: an id from listPurchaseTypes
await changePurchaseType(portal, { ...key, purchaseType: '4' })
// Move VAT from recoverable to common use: pass the FINAL amount of each bucket
await moveVatToCommonUse(portal, { ...key, commonUseVat: 19000 })
// The inverse
await moveVatToRecoverable(portal, { ...key, commonUseVat: 0, recoverableVat: 19000 })On a production client, these change the real register. There is no preview and no undo in the SDK.
- One attempt, and only the SII's
OKconfirms. Each write is a single attempt: the SDK cannot tell whether a lost answer means the change was applied, so it never repeats it, andoverrides.retrydoes not reach a write. Anything other than the SII'sOKthrowsSII_PORTAL_REJECTEDwithdetails.rejectionequal topurchaseChangeNotConfirmed, anddetails.portalMessagewhen the SII said something. - After
purchaseChangeNotConfirmed, read before you retry. The change may have applied. CallfetchPurchaseDocumentand comparepurchaseTypeor the VAT amounts with what you wanted. Only retry if it did not change. - VAT amounts are final amounts, not deltas.
commonUseVatandrecoverableVatare the value of each bucket after the change. To move a document's whole VAT to common use, pass the total ascommonUseVatand leaverecoverableVatout. To move it all back, passcommonUseVat: 0and the total asrecoverableVat. An amount you leave out goes empty. - A write throws
INVALID_DATE,MISSING_FIELDorINVALID_RUTfor a bad key, andSII_PORTAL_UNEXPECTED_PAGEfor an answer it cannot read.
The MIPYME backup#
The SII keeps a backup of the XML of the documents a company issued and received, in its MIPYME service. downloadMipymeBackup lists a date range, splits it, downloads each piece and gives you the XML. Use it when you need the full document, which the register does not hold.
import { downloadMipymeBackup } from 'dte-sdk/portal'
const backup = await downloadMipymeBackup(portal, {
from: '2026-10-01', // YYYY-MM-DD
to: '2026-10-31',
direction: 'issued', // the default; or 'received'
type: 33, // optional: only this document type
attempts: 3, // tries per listing and per download; 3 by default
onChunk: async ({ from, to, total, xml }) => {
await saveBackup(`${from}_${to}.xml`, xml)
},
})
backup.total // documents counted
backup.chunks // [{ from, to, total, type? }], newest first| Option | Default | What it does |
|---|---|---|
from, to | required | First and last day, YYYY-MM-DD. INVALID_DATE otherwise. |
direction | 'issued' | Issued or received documents. |
type | all | Only this document type. |
attempts | 3 | Tries per listing and per download, with waits of 1 s doubling between them. The backup has its own retry loop: overrides.retry reaches only the login. |
onChunk | none | Receives each piece (from, to, total, xml) as it arrives. Then the result holds no xml. |
companyRut | the client's | The company, when it is not the client's. |
The result is a MipymeBackup: total is the sum of the pieces' totals (the documents the listings counted), and chunks are the pieces, newest first. Each MipymeBackupChunk has from, to, total, type (only when a day had to be split by document type) and xml. With onChunk, the xml is absent from chunks, so a long history does not stay in memory. Use onChunk for long histories.
How the range is split#
The SII allows at most 20 documents per download: 20 answer XML, 21 answer an error page. So the SDK plans first and downloads after.
- It asks the SII how many documents the whole range holds. Up to 20, it is one piece.
- Above 20, it splits the range in halves, by day, and plans each half the same way.
- When a stretch cannot be split by day any more and still holds more than 20, it splits that day by document type, asking for the count of each type in a fixed list of types.
The whole plan comes first. If a day cannot be downloaded, the call throws before any download, so you never get half a backup without knowing it:
backupDayOverLimit: one day and one document type exceed 20 documents. It cannot be split further.detailshas theday, thetypeand the numberlisted.backupDayUnknownTypes: one day exceeds 20 documents and has documents of types the SDK cannot separate, so the day cannot be covered.detailshas theday, the numberlistedand the numbercovered.
Both are SII_PORTAL_REJECTED, with the key in details.rejection.
Known defect. A two-day range with more than 20 documents downloads only its first day, split by type, while total still counts both days. The same can happen to a two-day piece of a longer range, because the plan splits ranges in halves until it reaches two days. Until it is fixed, ask for one day at a time when a stretch may hold more than 20 documents, and compare what you downloaded with total.
What the backup reads#
- It reads production from any client. The service exists only in production, so
downloadMipymeBackupreads real documents even from a client created withenvironment: 'certification'. If a development or test environment stores what it downloads, that environment needs its own gate: the SDK will not stop it. - It needs only the certificate. It does not require the company to be enrolled as an issuer.
Refusals#
The backup answers with pages, and the SDK reads them into SII_PORTAL_REJECTED refusals:
details.rejection | Meaning | What to do |
|---|---|---|
backupNoData | The SII says it has no information in MIPYME for this RUT. | Definitive: there is nothing to download. |
backupCaptcha | The SII turned the captcha on. | It cannot be automated while it is on. Try later, never in a loop. |
backupIndeterminate | The portal returned several of its template's messages with no single verdict. | Nothing can be concluded, not even that there is no data. Try later. |
backupRefused | The portal refused. | The reason is the SII's message, in details.portalMessage. |
backupNoCompany | The portal returned its "Seleccionar empresa" page with no message. | The cause could not be determined. |
backupDayOverLimit, backupDayUnknownTypes | One day cannot be downloaded within the 20 documents limit. | See above. Nothing was downloaded. |
backupNoData is final. backupCaptcha and backupIndeterminate mean "try later": do not retry them in a loop, because that makes a captcha worse. The portal's session errors (SII_PORTAL_SESSION_LIMIT, SII_PORTAL_BLOCKED and the rest) are described in SII portal and folios, and portalRejectionMessage(rejection) gives the Spanish explanation of any of these keys.
Next steps#
- SII portal and folios: the session, the lock and the cache these functions share.
- Books: the purchase and sales book (IECV), which the RCV replaced in production from period 2017-08.
- Errors:
DteErrorand its codes. - API reference for
dte-sdk/portal: every signature and type.