Emission
Emitting a DTE with the Simplo API — the POST /api/v1/billing/dte endpoint, the mandatory Idempotency-Key, and the asynchronous flow to the SII.
The endpoint
Section titled “The endpoint”| Method | Path | Description |
|---|---|---|
POST |
/api/v1/billing/dte |
Emit a DTE (Idempotency-Key required). Responds 201 with estado: firmado; submission to the SII is asynchronous. |
GET |
/api/v1/billing/dte |
List DTEs with cursor pagination and filters by type, state, date and recipient. |
GET |
/api/v1/billing/dte/{id} |
Detail and current state of a DTE (useful for polling). |
GET |
/api/v1/billing/dte/{id}/pdf |
Printable representation (PDF with PDF417 stamp). |
GET |
/api/v1/billing/dte/{id}/xml |
The document’s signed XML. |
POST |
/api/v1/billing/dte/send-boletas |
Send a set of boletas to the SII in one EnvioBOLETA envelope (Idempotency-Key required with an API key). |
These six routes are also the entire surface a billing:dte API key
can call — the rest of /api/v1/billing/* is session-only (see
the quickstart’s table).
With the SDK (not yet published), all of this will be simplo.dtes.emit, .list,
.retrieve, .pdf and .xml — see the quickstart.
Rules that make an emission correct
Section titled “Rules that make an emission correct”-
Amounts are integer Chilean pesos (CLP). On invoices
preciois net and the API adds the 19% VAT; on boletas (39/41)preciois gross (VAT included) and the engine breaks it out internally. No cents, no floats for money. The API computes VAT (19%). -
emitreturns before the SII’s verdict. Persistdte.id, treatfirmadoas “in flight”, and resolve the final state by pollingGET /api/v1/billing/dte/{id}until a terminal state (aceptado,rechazado,con_reparosorerror_envio). (Webhook delivery isn’t live yet.) -
Boletas (types 39 and 41) require
ind_servicio— use3(sales and services boleta). Omitting it responds400 IND_SERVICIO_REQUERIDO, withaction: "set_ind_servicio"andcontext.field. Invoices (33) don’t carry it. -
Save
dte.idanddte.folioon your side (e.g. on the order row) immediately after emitting. -
The API validates amounts for you — and names the field. A negative
precio,cantidad: 0or a zero total respond400 MONTO_INVALIDOwithcontext.field(detalle[0].precio) andreceived_value; nothing is emitted and no folio is burned. Measured 2026-09-04. Validating on your side is still worth a saved round-trip. -
Don’t send
metadata— we now tell you with an error. The field is still not persisted, so it would not come back onGET /api/v1/billing/dte/{id}. It used to be accepted and silently dropped; since 2026-09-10 ametadatawith content responds422 BILLING_METADATA_NOT_SUPPORTEDand nothing is emitted. We’d rather cost you a round-trip today than lose your correlation id without telling you.metadata: {}andmetadata: nullstill pass (they lose nothing). Your correlation lives in your database, keyed by thedte.idthe emission returns.If you get this 422 while retrying an emission that may have already succeeded before this change: don’t mint a fresh key — that would emit the document twice (and dropping
metadatawhile reusing the same key givesIDEMPOTENCY_BODY_MISMATCH). Look the document up inGET /api/v1/billing/dteby folio/amount/date first; there is no lookup byIdempotency-Key, so this is a manual check, not an automatic recovery. The window closes on its own: completed keys are retained for 24 hours. -
High-value boletas must identify the payer (SII Res. Ex. 44/2025). A high-value boleta must identify whoever pays; the generic end-consumer RUT
66666666-6is not enough. Simplo does not validate this rule: the emission is accepted and the tax responsibility stays with the issuer. We don’t validate it because it would mean resolving the threshold in pesos, and a stale or unavailable value would block legitimate emissions — refusing to sell is a worse failure than the one being prevented.Where you identify the payer in our API: in
receptor, which is already mandatory on every emission — you send the payer’s real RUT andrazon_socialthere instead of66666666-6. No new field needed.What we do not claim: neither the exact peso threshold nor its valuation date, nor that identifying the recipient is on its own enough to comply with the resolution (there are additional requirements for market software that our contract does not expose today). Confirm the scope with your tax advisor or against the text of the resolution: we don’t publish a compliance guarantee we cannot verify.
-
You can emit in parallel. The folio-allocation race that produced a spurious
400 CAF_NO_DISPONIBLEis fixed: the allocator now waits for the lock instead of skipping it. Measured 2026-09-04 against production: 20 simultaneous emissions → 20 unique folios, zero spurious errors. The 60 req/min limit is still per company. -
Don’t block the user’s flow waiting for SII acceptance — it can take a while. Emit, respond, and reconcile asynchronously.
Boletas: the set travels in ONE envelope
Section titled “Boletas: the set travels in ONE envelope”Emitting with auto_send sends one envelope per document. When you need a
set of boletas to travel to the SII in a single EnvioBOLETA — the
end-of-shift case, and the one the certification Set de Pruebas requires —
emission and sending are two separate steps:
POST /api/v1/billing/dtefor each boleta, withauto_send: false. Each responds201with itsidandfolio, in statefirmado.POST /api/v1/billing/dte/send-boletasonce, with everyidfrom the previous step. That is the envelope.
Full example: three boletas, one envelope
Section titled “Full example: three boletas, one envelope”set -euo pipefailIDS=()
# Use YOUR OWN business identifiers, identical on every retry of the SAME# shift. A new key re-emits; the same key with a different payload is a 422.RUN_ID="caja-01-2026-09-10-turno-tarde"
# Step 1 — emit each boleta WITHOUT sending it (auto_send: false)for N in 1 2 3; do RESP=$(curl -sX POST https://api.simplo.cl/api/v1/billing/dte \ -H "X-Api-Key: $SIMPLO_API_KEY" \ -H "Idempotency-Key: $RUN_ID-boleta-$N" \ -H 'Content-Type: application/json' \ -d '{ "tipo_dte": 39, "ind_servicio": 3, "auto_send": false, "receptor": {"rut":"66666666-6","razon_social":"Consumidor final", "giro":"Particular","direccion":"Sin direccion", "comuna":"Santiago","ciudad":"Santiago"}, "detalle": [{"nombre":"Cafe","cantidad":1,"precio":2500}] }') ID=$(jq -r '.id // empty' <<<"$RESP") # Abort HERE if a boleta was not emitted. Carrying on puts an empty id in # the list and you end up sending an incomplete envelope without noticing. [ -n "$ID" ] || { echo "boleta $N failed: $RESP" >&2; exit 1; } IDS+=("$ID")done
# Step 2 — a SINGLE envelope with all threeENVIO=$(curl -sX POST https://api.simplo.cl/api/v1/billing/dte/send-boletas \ -H "X-Api-Key: $SIMPLO_API_KEY" \ -H "Idempotency-Key: $RUN_ID-envio" \ -H 'Content-Type: application/json' \ -d "$(jq -nc '{dte_ids: $ARGS.positional}' --args "${IDS[@]}")")
# Step 3 — check that ALL of them travelled (see "What will bite you")[ "$(jq -r '.num_dtes' <<<"$ENVIO")" = "${#IDS[@]}" ] \ || { echo "we sent FEWER boletas than requested: $ENVIO" >&2; exit 1; }echo "$ENVIO"Response:
{ "envio_id": "6f1c0a2e-...", "track_id": "918273645", "estado": "enviado", "num_dtes": 3}The track_id belongs to the whole envelope, not to each boleta. Keep it,
but be clear that no public route accepts it as a query: it’s for support
and for talking to the SII. Each boleta’s verdict is still polled with
GET /api/v1/billing/dte/{id}.
What will bite you at 3 AM
Section titled “What will bite you at 3 AM”- Between 1 and 500 boletas per envelope. With 501 the entire send fails: split the shift into batches before calling, not after the error.
- Send distinct
dte_idsand comparenum_dtesagainst the length of your list. With200you may have sent fewer than you asked for: ids that don’t exist in your company are skipped (if all are missing, then it is a400), and duplicate ids collapse even when the documents exist. Step 3 of the example does exactly that comparison. - Only boletas are accepted (types 39 and 41). An invoice
id(33) in the list aborts the entire send with400. - Only boletas in state
firmadoorerror_envioare accepted. An already-sent boleta aborts the entire send with400. - The
track_idbelongs to the envelope, and no public route accepts it as a query. Keep it for support and for talking to the SII. Each document’s verdict is polled withGET /api/v1/billing/dte/{id}(tracking/sii_statusfields). This response’sestado: "enviado"is the result of the upload, not the SII’s final verdict.
Retries: read this before retrying
Section titled “Retries: read this before retrying”This is the part we’d rather tell you uncomfortably than reassuringly. The send is not atomic. The engine reads the states, uploads to the SII, and only then persists the result and moves the documents; failures updating each document only reach the log. Three facts follow:
- A
400does not prove nothing was uploaded. A failure after the upload answers400with the boletas still infirmado. - Boletas still sitting in
firmadodon’t prove it either, for the same reason. - Two overlapping calls can both read
firmadoand both upload. Nothing locks against it, and theIdempotency-Key— mandatory with an API key — has no replay on this route: the same key executes again.
So on any error or timeout: do not retry blindly. Check
GET /api/v1/billing/dte/{id} for the boletas involved and, if the state
doesn’t settle it, escalate to support with your dte_ids, the envio_id and
the Idempotency-Key you used, instead of sending again. A boleta sent twice
is a tax problem; a support call is not.
estado: "ambiguo"means the SII answered that it had already received that envelope (code 99). The boletas stay as they were. Periodic reconciliation may resolve the envelope, but it does not guarantee propagating a terminal state to each document, and without atrack_idit only exhausts retries: don’t assume it fixes itself. Keepenvio_id,track_idand yourdte_ids, check the documents’ state, and escalate if it doesn’t move.- This route emits nothing: it creates no documents and burns no folios. It only packages and sends boletas your company already emitted, and only yours.
Idempotency
Section titled “Idempotency”Emission requires an Idempotency-Key header — a UUID is recommended, but
any unique, stable string works (no min/max length is enforced). With the
SDK:
-
dtes.emitgenerates anIdempotency-Key(UUID) automatically per call — an in-flight retry can never duplicate an invoice. Don’t build an extra dedup layer around individual calls. -
For cross-process dedup (a job retried by your queue), pass your own stable key:
await simplo.dtes.emit(params, { idempotencyKey: `order-${orderId}` });The same key + the same body within 24 hours returns the original result. A repeated emission (same key, same body) arrives tagged with the
X-Idempotency-Replay: trueresponse header. Completed keys are retained for 24 hours. -
Never reuse an idempotency key with a different body — that’s a
ValidationErrorwithcode: 'IDEMPOTENCY_BODY_MISMATCH'.
code |
Status | Meaning |
|---|---|---|
IDEMPOTENCY_IN_PROGRESS |
409 | The same key is being processed right now — wait and check the result |
IDEMPOTENCY_BODY_MISMATCH |
422 | The key was reused with a different body — use a new key |
If emitting returns a 503 for a temporarily unavailable idempotency
service, retry with the same key.