Quickstart
From a new account to a boleta issued in the sandbox, with the full queued → accepted cycle and without touching the SII.
Everything on this page happens in test mode: no traffic to the SII (Chile's tax authority), no
certificate, no CAF and no cost. Swap sk_test_ for sk_live_ and the same call hits the real SII.
1. Get your first key
A freshly created account holds no key yet. The shortest path is a single call: register and get the sandbox key back in the same response.
curl -sS https://api.facturia.cl/account/register \
-H "Content-Type: application/json" \
-d '{
"email": "[email protected]",
"password": "una-contraseña-larga",
"bootstrap": {
"sandbox_key": { "name": "primera llave" },
"empresa": { "rut": "76000002-7", "legal_name": "Ejemplo SpA" }
}
}'{
"status": 201,
"data": {
"token": "eyJhbGciOi…",
"api_key": { "object": "api_key", "id": "key_01K2R7…", "secret": "sk_test_9lQpV3fA2mKcRt7wZxYb1Nde" },
"empresa": { "object": "empresa", "id": "emp_01K2R7…", "rut": "76000002-7", "mode": "test" },
"next": {
"action": "emit_sandbox_document",
"method": "POST",
"url": "/v1/documents",
"headers": { "Authorization": "Bearer <data.api_key.secret>", "Facturia-Empresa": "76000002-7" }
}
}
}data.api_key.secret is the key, and it is returned exactly once. It is always sk_test_: there
is no live variant of this block and there never will be, because minting a live key is precisely the
point where email verification has to bite. data.next names the call that follows, headers and all;
a value inside <…> is a field of this same response that you substitute in.
bootstrap.empresa saves you step 2. If the RUT (the Chilean tax id) is already taken on another
account, the account and the key are still created and data.empresa_error says why there was no
empresa — reported, not thrown.
export FACTURIA_KEY=sk_test_9lQpV3fA2mKcRt7wZxYb1Nde
export EMPRESA=76000002-7Registering does not require confirming your email
Verification closes exactly one door: minting a live key. Test mode, and everything inside it, is
open immediately — an agent can get from this call to an issued sandbox DTE (documento tributario
electrónico, Chile's electronic tax document) without anyone opening a mailbox. When the moment for
the live key arrives, a 403 email_verification_required tells you what is missing.
The two-call form
It still exists, and it is the one you need if the account already exists or if you want to choose the
scopes. The way through is the account session: the same one-hour JWT that POST /account/login
returns, presented to the dashboard surface.
# Iniciar sesión (o registrarte sin `bootstrap`). No necesitas un navegador.
TOKEN=$(curl -sS https://api.facturia.cl/account/login \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","password":"..."}' | jq -r .data.token)
# Mintear la llave con la sesión, no con una llave que todavía no tienes.
curl -sS https://api.facturia.cl/dashboard/api_keys \
-H "Authorization: Bearer $TOKEN" \
-H "Facturia-Mode: test" \
-H "Content-Type: application/json" \
-d '{"name":"primera llave","scopes":["documents:read","documents:write","empresas:read","empresas:write","webhooks:read","webhooks:write"],"empresas":["*"]}'These six scopes are the cold-start set, and they are the minimum that carries you through this
entire page. They are also the default value of bootstrap.sandbox_key.scopes, so the one-call form
mints exactly this. Step 2 creates an empresa and POST /v1/empresas wants empresas:write; step 6
subscribes a webhook and POST /v1/webhook_endpoints wants webhooks:write. A key minted with only
the two documents: scopes emits documents perfectly well and answers 403 insufficient_scope to the
first call of this quickstart. This site's build cross-checks the scopes of that call against the
requireScope of every endpoint the quickstart invokes, so the two can never drift apart again.
What is not in there: keys:write —the bootstrap refuses it with 400 and
errors: ["invalid_bootstrap"], because minting is a root-key power and a sandbox key must not be a
minting authority— nor the newer go-live scopes (mandato:write, mandato:attest,
onboarding:write, onboarding:attest), which you ask for separately and on purpose. All of it is in
Scopes.
/dashboard/* is the account-session surface. It serves the same resources as /v1 — the same
serializers, the same filters, the same error envelope — authenticated with an account JWT instead of
an sk_ key, because a browser must never hold an sk_ key.
On a session, the mode travels in a header
A session has no prefix to carry its mode, so Facturia-Mode: test|live carries it, defaulting to
live. An unrecognized value is a 400, never a silent fall back to live.
Save the secret: it is returned exactly once. And mind the cold-start limits: /account/register and
/account/login are capped at 10 per minute per IP and 5 per minute per email address, with
429 and errors: ["auth_throttled"]. They are in Limits.
2. Create a sandbox empresa
If you used bootstrap.empresa in step 1 this is already done — skip to step 3. If not, it is one
call. In test mode the empresa is born ready to emit: no certificate, no mandato (the power of
attorney that lets us act before the SII on its behalf) and no certification.
curl -sS https://api.facturia.cl/v1/empresas \
-H "Authorization: Bearer $FACTURIA_KEY" \
-H "Content-Type: application/json" \
-d '{"rut":"76000002-7","legal_name":"Ejemplo SpA"}'Only rut and legal_name are required. We read the rest from the SII's taxpayer registry once the
certificate is uploaded, and you can overwrite any field afterwards.
3. Issue a boleta electrónica
Two fields.
curl -sS https://api.facturia.cl/v1/documents \
-H "Authorization: Bearer $FACTURIA_KEY" \
-H "Facturia-Empresa: $EMPRESA" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"tipo_dte": 39,
"items": [{ "description": "Servicio de mantención mensual", "quantity": 1, "unit_price": 25000 }]
}'If you would rather hold a typed client than requests or fetch, the contract generates one in a
single line: Generate a typed client.
The response is a 201 and it arrives immediately:
{
"id": "doc_01K2R7Q4XW9M3B8ZC5YHTVJD6N",
"object": "document",
"mode": "test",
"status": "queued",
"tipo_dte": 39,
"folio": null,
"issue_date": "2026-08-14",
"totals": { "net": 21008, "iva": 3992, "exempt": 0, "total": 25000 },
"pdf_url": "https://api.facturia.cl/v1/documents/doc_01K2R7.../pdf",
"xml_url": "https://api.facturia.cl/v1/documents/doc_01K2R7.../xml",
"sii": { "environment": "sandbox", "track_id": null, "estado": null },
"created_at": "2026-08-14T13:04:11Z"
}Notice three things, and the first is the one that surprises: the folio comes back null. The
folio is the pre-authorized sequential number a DTE has to carry, and the POST does not assign one.
It writes the row, enqueues the work and answers; a couple of seconds later the worker draws the folio
from the counter — in test mode they start at 1 for each (empresa, tipo_dte) — and only then does
the document have a number. Second, the totals are computed immediately, because they are our own
arithmetic and do not depend on the SII: a boleta, Chile's consumer receipt, carries gross prices, so
the 25.000 breaks down into net and IVA (Chile's VAT). And third, the status is queued, not
accepted.
Trap
Do not store the folio from the POST response
It will always be null there. The folio shows up in the document.folio_allocated event, in the
next read of GET /v1/documents/{document_id} and in the object carried inside the lifecycle
webhooks — never in the response that created the document.
4. The honest lifecycle: queued → sent → accepted
The POST is synchronous. The round trip to the SII is not, and we do not pretend otherwise.
┌───────────► rejected (terminal)
│
queued ──► sent ──────┼───────────► reparo (terminal-ish; válido pero observado)
│ │
│ └───────────► accepted ──► annulled (una NC lo anuló)
│
└──► failed ──(retry)──► queuedIn the sandbox the document settles to accepted in roughly a second, firing every webhook a real
document would fire. In production the typical trip to a terminal state is 30 seconds to a few
minutes, and the SII can take hours at its peaks or during maintenance.
curl https://api.facturia.cl/v1/documents/doc_01K2R7Q4XW9M3B8ZC5YHTVJD6N \
-H "Authorization: Bearer $FACTURIA_KEY" -H "Facturia-Empresa: $EMPRESA"# Los eventos del documento, del más antiguo al más nuevo
curl https://api.facturia.cl/v1/documents/doc_01K2R7.../events \
-H "Authorization: Bearer $FACTURIA_KEY" -H "Facturia-Empresa: $EMPRESA"{
"object": "list",
"data": [
{ "type": "document.folio_allocated", "at": "2026-08-14T13:04:13Z", "detail": { "folio": 1, "sandbox": true } },
{ "type": "document.signed", "at": "2026-08-14T13:04:14Z", "detail": { "status": "signed" } },
{ "type": "document.submitted", "at": "2026-08-14T13:04:19Z", "detail": { "status": "submitted", "track_id": "9000000001" } },
{ "type": "document.accepted", "at": "2026-08-14T13:04:20Z", "detail": { "status": "accepted", "sii_estado": "DOK" } }
]
}This listing is the engine's journal, and its vocabulary is finer-grained than that of the four
public statuses: folio_allocated and signed are the two steps that read as queued from the
outside, and submitted is the step that reads as sent. There, in one place and with timestamps, is
the rest of the sequence the 201 could not tell you about: folio assigned → signed → transmitted →
accepted. The journal starts at the folio rather than at creation because you already hold the
creation — it is the response you have just read, with its created_at.
That is a different vocabulary from the one the webhooks use, and the two are worth keeping apart.
A subscribed endpoint receives document.created, document.sent and document.accepted — the
catalog in Webhooks — because a webhook announces a change you can act on, and
"it has a folio now" and "I signed it" change nothing a subscriber can observe: both are still
queued. The journal keeps them anyway, for when you are debugging and it does matter which of the
two it stopped at.
Do not build on tight polling
Webhooks are the intended integration. If you need to poll anyway: do not read
GET /v1/documents/{document_id} more often than every 10 seconds for the first 2 minutes, and
every 60 seconds after that. Better still, read the list with status=sent&created_at[gte]=… once a
minute and reconcile in bulk. The Facturia-Poll-After header on the document response tells you the
next useful moment, computed by our own scheduler.
That header disappears once the document reaches a terminal state (accepted, reparo,
rejected, annulled): there is nothing left to watch, and telling you to "come back in 60 seconds"
would be sending you to poll something that will never change again. Treat its absence as the signal
that you are finished, not as a lost header.
In the sandbox that means you will almost never see it: the document settles in ~1 second, so by the
time you read it it is already terminal and the response comes back without Facturia-Poll-After. It
is the correct behavior and it is exactly the same code that runs in production — do not read it as
the header not existing. Note as well that it only travels on GET /v1/documents/{document_id}: the
list does not carry it, because a page of a hundred documents has no single next useful moment.
5. Download the PDF
curl -L https://api.facturia.cl/v1/documents/doc_01K2R7.../pdf \
-H "Authorization: Bearer $FACTURIA_KEY" -H "Facturia-Empresa: $EMPRESA" -o boleta.pdfIn test mode the PDF carries the real layout with a SIN VALOR TRIBUTARIO watermark.
6. Receive the webhook instead of asking
curl -sS https://api.facturia.cl/v1/webhook_endpoints \
-H "Authorization: Bearer $FACTURIA_KEY" -H "Content-Type: application/json" \
-d '{
"url": "https://api.miempresa.cl/hooks/facturia",
"enabled_events": ["document.accepted","document.rejected","document.reparo"]
}'The secret (whsec_…) is returned once only. How to verify the signature is in
Webhooks · Signature.
And now, for real
Swapping sk_test_ for sk_live_ changes what happens on the wire, not your code. What does change
is that the empresa has to walk a real go-live: mandato, certificate or centralized password, the
steps a person performs at the SII, the month approval and the folios. The full path, numbered and
with both lanes side by side, is in From sandbox to production; a
single endpoint drives it, and you can render it as a wizard: GET /v1/empresas/{empresa_id}/onboarding.