Skip to content
SimploSimplo Docs

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.

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.

  1. Amounts are integer Chilean pesos (CLP). On invoices precio is net and the API adds the 19% VAT; on boletas (39/41) precio is gross (VAT included) and the engine breaks it out internally. No cents, no floats for money. The API computes VAT (19%).

  2. emit returns before the SII’s verdict. Persist dte.id, treat firmado as “in flight”, and resolve the final state by polling GET /api/v1/billing/dte/{id} until a terminal state (aceptado, rechazado, con_reparos or error_envio). (Webhook delivery isn’t live yet.)

  3. Boletas (types 39 and 41) require ind_servicio — use 3 (sales and services boleta). Omitting it responds 400 IND_SERVICIO_REQUERIDO, with action: "set_ind_servicio" and context.field. Invoices (33) don’t carry it.

  4. Save dte.id and dte.folio on your side (e.g. on the order row) immediately after emitting.

  5. The API validates amounts for you — and names the field. A negative precio, cantidad: 0 or a zero total respond 400 MONTO_INVALIDO with context.field (detalle[0].precio) and received_value; nothing is emitted and no folio is burned. Measured 2026-09-04. Validating on your side is still worth a saved round-trip.

  6. Don’t send metadata — we now tell you with an error. The field is still not persisted, so it would not come back on GET /api/v1/billing/dte/{id}. It used to be accepted and silently dropped; since 2026-09-10 a metadata with content responds 422 BILLING_METADATA_NOT_SUPPORTED and nothing is emitted. We’d rather cost you a round-trip today than lose your correlation id without telling you. metadata: {} and metadata: null still pass (they lose nothing). Your correlation lives in your database, keyed by the dte.id the 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 metadata while reusing the same key gives IDEMPOTENCY_BODY_MISMATCH). Look the document up in GET /api/v1/billing/dte by folio/amount/date first; there is no lookup by Idempotency-Key, so this is a manual check, not an automatic recovery. The window closes on its own: completed keys are retained for 24 hours.

  7. 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-6 is 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 and razon_social there instead of 66666666-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.

  8. You can emit in parallel. The folio-allocation race that produced a spurious 400 CAF_NO_DISPONIBLE is 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.

  9. Don’t block the user’s flow waiting for SII acceptance — it can take a while. Emit, respond, and reconcile asynchronously.

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:

  1. POST /api/v1/billing/dte for each boleta, with auto_send: false. Each responds 201 with its id and folio, in state firmado.
  2. POST /api/v1/billing/dte/send-boletas once, with every id from the previous step. That is the envelope.
Ventana de terminal
set -euo pipefail
IDS=()
# 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 three
ENVIO=$(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}.

  • 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_ids and compare num_dtes against the length of your list. With 200 you 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 a 400), 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 with 400.
  • Only boletas in state firmado or error_envio are accepted. An already-sent boleta aborts the entire send with 400.
  • The track_id belongs 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 with GET /api/v1/billing/dte/{id} (tracking / sii_status fields). This response’s estado: "enviado" is the result of the upload, not the SII’s final verdict.

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 400 does not prove nothing was uploaded. A failure after the upload answers 400 with the boletas still in firmado.
  • Boletas still sitting in firmado don’t prove it either, for the same reason.
  • Two overlapping calls can both read firmado and both upload. Nothing locks against it, and the Idempotency-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 a track_id it only exhausts retries: don’t assume it fixes itself. Keep envio_id, track_id and your dte_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.

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.emit generates an Idempotency-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: true response header. Completed keys are retained for 24 hours.

  • Never reuse an idempotency key with a different body — that’s a ValidationError with code: '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.

  • DTE types — codes and requirements per type
  • States — the full lifecycle
  • Errors — error classes and retries
  • Limits — the per-company rate limit