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.
Install the skill
Section titled “Install the skill”This site publishes the simplo-invoicing Agent Skill at
/.well-known/skills/:
npx skills add https://docs.simplo.clThe 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.
Configure your credentials
Section titled “Configure your credentials”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 theX-Api-Keyheader. 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.exampleas 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.
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”.
The canonical emission flow
Section titled “The canonical emission flow”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:
- Amounts are integer Chilean pesos (CLP). On invoices
preciois net (the API adds the 19% VAT); on boletas (39/41)preciois gross (VAT included). No cents, no floats for money. The API computes VAT (19%). - The response returns before the SII’s verdict. Persist
dte.id, treatfirmadoas “in flight”, and resolve the final state by pollingGET /api/v1/billing/dte/{id}untilestadoisaceptado,rechazadoorcon_reparos. (Webhook delivery isn’t live yet — polling is the supported path today.) - Save
dte.idanddte.folioon your side (e.g. on the order row) immediately after emitting. - Don’t block the user’s flow on SII acceptance — it can take a while. Emit, respond, reconcile asynchronously.
- The correlation with your order lives in YOUR database, not in the API.
The request
metadatafield is accepted but not persisted today (silently dropped — themetadatareturned byGET /dte/{id}is server-generated). Storedte.idandfolioagainst yourorder_idin your own system, and never use the customer’s RUT (or other PII) as a correlation identifier.
Error handling — by HTTP status
Section titled “Error handling — by HTTP status”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.
Idempotency rules
Section titled “Idempotency rules”- Every emission (
POST /api/v1/billing/dte) requires theIdempotency-Keyheader (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
422withcode: 'IDEMPOTENCY_BODY_MISMATCH'.
Multi-company
Section titled “Multi-company”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.
Testing
Section titled “Testing”- 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 fakefetchor use an HTTP mocking library (e.g.msw,nock) to simulateapi.simplo.cl’s responses. - Assert: correct
tipo_dte, integer CLP amounts, RUT passed through untouched,dte.id/foliopersisted, theIdempotency-Keyheader present, and the4xxerror 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-Typeheaders); key fromSIMPLO_API_KEY;.env.exampleupdated; no key in code or frontend. - Emission path implemented with integer CLP amounts (net for invoices, gross for boletas), the correct
tipo_dtefor the use case (33 B2B invoice / 39 consumer receipt), and a freshIdempotency-Keyper request. -
dte.idandfoliopersisted 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 abilling:dtekey). - The
rechazadopath 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.codeandbody.detailssurfaced 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/sdkwas not installed as a requirement (not published yet).