Skip to content
SimploSimplo Docs

For coding agents

Self-contained playbook for the task "integrate Simplo's electronic invoicing into this app" — canonical emission flow over REST, errors, idempotency, testing and checklist.

Instructions for a coding agent whose task is to “integrate Simplo’s electronic invoicing (Chilean DTEs) into this application.” This page is self-contained; linked docs add depth, not prerequisites.

These docs also exist in machine format: /llms.txt is a real index in llmstxt.org format — it lists every page on the site and every API operation with its markdown link. Every HTML page has a plain-markdown mirror by appending index.md to the URL (e.g. /comienza/quickstart/index.md), and the full API reference lives in a single file at /api-reference.md.

This site publishes the simplo-invoicing Agent Skill at /.well-known/skills/:

Ventana de terminal
npx skills add https://docs.simplo.cl

The skill hands your agent this same playbook (install, canonical emission flow, errors by class, idempotency, testing and checklist) plus references for DTE types, errors, multi-company and sandbox — with the same honest limits: no key or endpoint should ever be invented, and the key only reaches the five DTE routes in the table below.

Domain vocabulary: DTE = Documento Tributario Electrónico (electronic tax document). SII = the Chilean tax authority. RUT = Chilean tax ID (76543210-3). Folio = document number authorized by the SII. CAF = the SII file that authorizes a range of folios.

Simplo is a REST API (https://api.simplo.cl): any language that speaks HTTP + JSON works, no install required.

  • Credential: environment variable SIMPLO_API_KEY, sent in the X-Api-Key header. Formats: sk_simplo_sandbox_... = sandbox (test documents, no tax validity); sk_simplo_... without _sandbox_ = production (real documents). Backend only — never expose the key in a browser bundle, never commit it. Add it to the project’s .env.example as a name, without a value.
  • If the user doesn’t have a key: they create one themselves at app.simplo.cl → Desarrolladores (a sandbox key, self-serve — no need to request access from anyone). Production emission is enabled per company ([email protected]); build and test everything in the sandbox in the meantime. Don’t invent a key or fall back to fake endpoints.
Ventana de terminal
export SIMPLO_API_KEY="sk_simplo_sandbox_..."

Wrap calls in a thin client (module or factory function), don’t repeat headers at every call site.

What your API key can do — call nothing else

Section titled “What your API key can do — call nothing else”

A billing:dte key (sandbox or live) reaches exactly these routes:

Operation By key?
POST /api/v1/billing/dte (emit; Idempotency-Key required) ✅
GET /api/v1/billing/dte · GET /api/v1/billing/dte/{id} ✅
GET /api/v1/billing/dte/{id}/pdf · …/xml ✅
Any other /api/v1/billing/* route (CAF, company, certificate, webhooks, customers, products) ❌ 403 BILLING_API_KEY_ROUTE_NOT_ALLOWED — session-only (app.simplo.cl)
POST /api/v1/mcp ❌ 401 — the MCP requires an agent:read key or OAuth, never billing:dte

Practical consequences: don’t “preflight” certificate/CAF/environment checks with the key (those routes respond 403; in the sandbox the certificate and folios are already provisioned — just emit), and a 403 BILLING_API_KEY_ROUTE_NOT_ALLOWED means “route outside the key surface”, never “account not activated”.

Every invoicing integration reduces to this flow. Adapt the names, keep the shape:

export async function emitInvoice(order: {
id: string; // your own order identifier
customerRut: string; // e.g. '76543210-3'
customerName: string;
items: Array<{ name: string; quantity: number; unitPriceClp: number }>;
}) {
const res = await fetch('https://api.simplo.cl/api/v1/billing/dte', {
method: 'POST',
headers: {
'X-Api-Key': process.env.SIMPLO_API_KEY!,
'Content-Type': 'application/json',
'Idempotency-Key': crypto.randomUUID(), // a retry can never duplicate the invoice
},
body: JSON.stringify({
tipo_dte: 33, // 33 invoice (B2B) — 39 for consumer receipts (boletas)
// For boletas (type 39/41) add ind_servicio: 3 — it's REQUIRED
// (omitting it is a 400 "IndServicio is required for boleta").
receptor: {
rut: order.customerRut,
razon_social: order.customerName,
},
detalle: order.items.map((item) => ({
nombre: item.name,
cantidad: item.quantity,
precio: item.unitPriceClp, // integer CLP, NET (the API adds VAT)
})),
// Do NOT send `metadata`: the API accepts it but does NOT persist it
// today (silently dropped). Keep the correlation in YOUR database (rule 5).
}),
});
if (!res.ok) throw await toSimploError(res); // see "Error handling" below
const dte = await res.json();
// dte.estado === 'firmado': signed and queued. SII acceptance is ASYNCHRONOUS.
return dte;
}

Rules that make this correct:

  1. Amounts are integer Chilean pesos (CLP). On invoices precio is net (the API adds the 19% VAT); on boletas (39/41) precio is gross (VAT included). No cents, no floats for money. The API computes VAT (19%).
  2. The response 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 estado is aceptado, rechazado or con_reparos. (Webhook delivery isn’t live yet — polling is the supported path today.)
  3. Save dte.id and dte.folio on your side (e.g. on the order row) immediately after emitting.
  4. Don’t block the user’s flow on SII acceptance — it can take a while. Emit, respond, reconcile asynchronously.
  5. The correlation with your order lives in YOUR database, not in the API. The request metadata field is accepted but not persisted today (silently dropped — the metadata returned by GET /dte/{id} is server-generated). Store dte.id and folio against your order_id in your own system, and never use the customer’s RUT (or other PII) as a correlation identifier.

An API error is a 4xx/5xx with a JSON body { code, message, details?, action? } (full contract: Errors):

async function toSimploError(res: Response) {
const body = await res.json().catch(() => ({}));
return new SimploApiError(res.status, body.code, body.message, body.details);
}
Status What it means Do this
400/422 Invalid input (code: "VALIDATION_ERROR") Bug or bad user input. Show body.details (field/code/message). Never retry as-is. Common: INVALID_RUT — validate the RUT check digit in your form layer.
401 Misconfigured, revoked or invalid key Fail fast with a setup hint; don’t retry. The API doesn’t distinguish the reason (anti-enumeration).
403 BILLING_API_KEY_ROUTE_NOT_ALLOWED: the route is session-only (outside the 5 DTE routes) Stop calling that route — use the table above. It’s not an unactivated account; don’t retry.
404 Wrong ID or a resource from another company Treat as a data bug.
409 IDEMPOTENCY_IN_PROGRESS: the same emission is already running Wait and re-check instead of re-emitting. Otherwise (e.g. duplicate folio), surface it.
422 IDEMPOTENCY_BODY_MISMATCH: same key, different body Use a new Idempotency-Key per distinct emission.
429 Rate limit (60 calls/min per company on the key surface) Queue/back off the caller; respect the Retry-After header.
5xx Transient API issue Retry only if the request is idempotent (see below); log the error for your retry queue.

Full table: Errors.

  • Every emission (POST /api/v1/billing/dte) requires the Idempotency-Key header (UUID) — generate a fresh one per call. An in-flight retry with the same key can never duplicate an invoice.
  • For cross-process dedup (a job retried by your queue), use your own stable key as the header value: Idempotency-Key: order-${orderId}. The same key + the same body within 24h returns the original result.
  • Never reuse an idempotency key with a different body — that’s a 422 with code: 'IDEMPOTENCY_BODY_MISMATCH'.

One API key = one company. If the app serves several companies, keep one key per company (separate environment variables / secret entries) and pick the right key before each request. Don’t mix keys from different companies in the same request. Details: Multi-company.

Status updates (polling — webhooks don’t deliver yet)

Section titled “Status updates (polling — webhooks don’t deliver yet)”

Webhook delivery isn’t live yet. Although endpoint registration exists in the contract, today no events are delivered — don’t build the verdict flow on webhooks. Implement a polling job: GET /api/v1/billing/dte/{id} until a terminal estado (aceptado / rechazado / con_reparos), with backoff (in the sandbox the cycle takes seconds; in production, however long the SII takes). The delivery contract (Standard Webhooks) is documented in Webhooks for when it goes live.

  • Never call production from tests — documents emitted with a live key are real tax documents. For integration tests against the real API, use a sandbox key (sk_simplo_sandbox_..., self-serve — see Quickstart): same contract, documents with no tax validity, and magic recipient RUTs to force rejection, con_reparos or exhausted folios (unhappy paths).
  • For unit tests, mock fetch (or whatever HTTP client you use) at your integration module’s boundary — inject a fake fetch or use an HTTP mocking library (e.g. msw, nock) to simulate api.simplo.cl’s responses.
  • Assert: correct tipo_dte, integer CLP amounts, RUT passed through untouched, dte.id/folio persisted, the Idempotency-Key header present, and the 4xx error paths (validation, conflict).
  • Make sure the key used in tests is the sandbox one (sk_simplo_sandbox_) — never the live one.

Definition of done — integration checklist

Section titled “Definition of done — integration checklist”
  • Thin HTTP client created (centralized X-Api-Key/Content-Type headers); key from SIMPLO_API_KEY; .env.example updated; no key in code or frontend.
  • Emission path implemented with integer CLP amounts (net for invoices, gross for boletas), the correct tipo_dte for the use case (33 B2B invoice / 39 consumer receipt), and a fresh Idempotency-Key per request.
  • dte.id and folio persisted in the domain record on emission.
  • Asynchronous verdict handled with a polling job until a terminal estado (webhooks don’t deliver yet — don’t depend on them).
  • No calls to routes outside the key surface (no CAF/company/ certificate/webhooks with X-Api-Key; no MCP with a billing:dte key).
  • The rechazado path exists: operator notification or corrective flow — never silent.
  • Cross-process retries carry a stable Idempotency-Key.
  • Errors handled by HTTP status per the table; body.code and body.details surfaced where applicable.
  • Tests: fetch/HTTP mocked (or sandbox for integration), never production, error paths covered.
  • If multi-company: the correct key per company on every request; no mixed credentials.
  • @simplohq/sdk was not installed as a requirement (not published yet).