# Bloques — Complete API Reference for AI Agents > Bloques is a SaaS that emits Peruvian electronic invoices accepted by SUNAT (the Peruvian > tax authority). It builds the UBL 2.1 XML, has it signed and transmitted by the SmartPSE > PSE (Proveedor de Servicios Electrónicos), stores the signed XML, the SUNAT CDR and a > printable PDF, optionally emails the customer, and returns the SUNAT verdict in the same > call. Integration surfaces: REST API (`/api/v1`), MCP server (`/api/mcp`), web UI. This document is self-contained: an agent can integrate Bloques end-to-end reading only this file. All examples use the placeholder host `https://bloques.example.com` — replace it with the real deployment host. Human docs: `/docs` (Spanish). OpenAPI spec: `/openapi.json`. --- ## 1. Critical rules (memorize these) 1. **All prices are FINAL — IGV 18% included.** Every `unit_price` you send (in documents and in products) is the amount the customer actually pays. Bloques back-calculates the tax base: `base = total / 1.18` for taxed lines. Example: you send 118.00 → base 100.00 + IGV 18.00 = total 118.00. NEVER send pre-tax prices; that would inflate the charge by 18%. 2. **Document type must match the customer identity:** - `factura` (type 01) REQUIRES a customer with RUC (`doc_type: "6"`, 11 digits). - `boleta` (type 03) MUST NOT have a RUC customer. Use DNI (`doc_type: "1"`, 8 digits), foreigner card (`"4"`), passport (`"7"`), or omit `customer` entirely → the boleta is issued to "CLIENTES VARIOS" (walk-in customer). EXCEPTION: if the boleta's total in soles exceeds S/ 700, SUNAT requires an identified buyer — you must send `customer` (omitting it returns `400 customer_invalid`). 3. **Production is real.** Every accepted document is a legally binding tax document reported to SUNAT. There is no sandbox mode. Confirm with the human before emitting. 4. **No corrections in v1.** Bloques v1 cannot emit credit notes (07), debit notes (08) or voiding communications (comunicaciones de baja); they are on the v2 roadmap. A mistake in an accepted document must be fixed with a credit note OUTSIDE Bloques (e.g. SUNAT's SOL portal). Accepted/rejected documents can never be edited or deleted. 5. **Always use idempotency keys** (header `Idempotency-Key` on the REST API, argument `idempotency_key` on MCP) so retries can never emit twice. 6. **Monetary amounts in responses are strings** with 2 decimals (`"118.00"`). Request amounts are JSON numbers. Dates are `YYYY-MM-DD`; timestamps are ISO 8601 UTC. The operational calendar (default issue date, monthly plan windows) is America/Lima. --- ## 2. Authentication Every API and MCP request needs a bearer token: Authorization: Bearer sk_live_... - Tokens are created by a human in the web app: **Configuración → API**. Token management is deliberately session-only — there is NO API endpoint to create tokens, so a leaked token can never mint more tokens or escalate its permissions. The plaintext token is shown exactly once at creation. - Each token belongs to exactly one company and only sees that company's data. - Tokens carry **scopes** chosen at creation: | Scope | Allows | |-------------------|---------------------------------------------------------------| | `*` | everything below | | `documents:read` | list/read documents, download PDF/XML/CDR, CSV export | | `documents:write` | emit documents | | `quotes:read` | list/read quotes (cotizaciones), download quote PDF | | `quotes:write` | create/update/delete quotes | | `products:read` | list/read products | | `products:write` | create/update/deactivate products | | `customers:read` | list/search customers | | `customers:write` | create/update customers | | `expenses:write` | record expenses — MCP only (`create_expense`), no REST route | `GET /api/v1/series`, `GET /api/v1/usage` and `GET /api/v1/company` accept any valid token (no scope required). `expenses:write` enables no REST endpoint: expenses are recorded from the app or with the MCP tool `create_expense` (§10). There is **no read of expenses or money by token** — a deliberate product decision, not a gap. - **Rate limit: 120 requests per minute per token** → HTTP 429 `rate_limited`. - **CORS is enabled** (`Access-Control-Allow-Origin: *`; allowed headers: Authorization, Content-Type, Idempotency-Key). Browser calls work, but never ship a `sk_live_` token to untrusted browsers. Auth errors: | HTTP | code | meaning | |------|----------------------|------------------------------------------------------| | 401 | `invalid_token` | missing/malformed/revoked token, or orphaned company | | 401 | `unauthorized` | no credentials at all | | 403 | `insufficient_scope` | token lacks the scope the endpoint requires | | 403 | `module_not_enabled` | company lacks the module the endpoint belongs to (e.g. Cotizaciones) | | 429 | `rate_limited` | over 120 req/min on this token | --- ## 3. Conventions - Base URL: `https://bloques.example.com/api/v1` - Requests/responses: JSON UTF-8 (`Content-Type: application/json`), except file downloads (PDF/ZIP/XML) and the CSV export. - **Error envelope** (all errors): { "error": { "code": "plan_limit", "message": "Límite mensual alcanzado (10/10 documentos).", "details": { "used": 10, "limit": 10, "plan": "free" } } } `details` is optional. For body validation errors it is an array: `[{ "path": "items.0.unit_price", "message": "..." }]`. Error messages are in Spanish. - **Pagination**: list endpoints accept `page` (1-based) and `per_page` (max 100; default 25 for documents, 50 for products/customers) and respond `{ "data": [...], "page": 1, "per_page": 25, "total": 137 }`. - Resource IDs are UUIDs. Documents are also identified by `full_number` (e.g. `F001-42`). --- ## 4. The IGV final-price rule (worked math) IGV (Impuesto General a las Ventas) is Peru's 18% VAT. Each line item carries an `affectation` (SUNAT catalog 07 subset): | Code | Name | Meaning | Math on the final price | |-------|-----------|--------------------------------------|-------------------------------------| | `10` | Gravado | subject to IGV (the default) | base = price/1.18; igv = price-base | | `20` | Exonerado | exempt by law | base = price; igv = 0 | | `30` | Inafecto | out of IGV scope | base = price; igv = 0 | Per line: `line_total = quantity × unit_price` (rounded to 2 decimals, half-up), then `line_base = line_total / 1.18` (gravado only) and `line_igv = line_total - line_base`. Document totals are sums of line values, so the grand `total` always equals exactly what was charged. Quantities allow up to 3 decimals. Example, one document mixing affectations: item A: 2 × 59.00 gravado(10) → line_total 118.00, base 100.00, igv 18.00 item B: 1 × 50.00 exonerado(20) → line_total 50.00, base 50.00, igv 0.00 totals: gravado 100.00, exonerado 50.00, inafecto 0.00, igv 18.00, total_value 150.00, total 168.00 **Recargo al consumo (restaurants/bars).** An optional surcharge (Decreto Ley 25988, up to 13% of the sale value) that is NOT a tax: it does not enter the IGV base and carries no IGV of its own — it is simply added to the amount payable and shared among staff. Off by default per company; enable it company-wide (Settings) or per document via `recargo_consumo: {apply, rate}` (rate is a fraction, e.g. 0.05 = 5%). It is computed on `total_value` (the sum of line bases without IGV): `recargo_consumo = round2(total_value × rate)`, and the document `total` becomes `goods-with-IGV + recargo_consumo`. In the XML it is a global `cac:AllowanceCharge` (ChargeIndicator=true, reason code 50, no TaxTotal) feeding `ChargeTotalAmount` → `PayableAmount`. The `totals` object then carries `recargo_consumo` and `recargo_rate`. Example: items summing to base 100.00 + IGV 18.00 with a 10% recargo → recargo_consumo 10.00, total 128.00. **ISC (Impuesto Selectivo al Consumo) — al valor.** An excise tax on specific goods, charged BEFORE the IGV (the IGV grava base + ISC). v1 supports only the "al valor" system (SUNAT catalog 08 = "01"): a percentage of the base, set per product (`isc_rate`, a fraction e.g. 0.10) or overridden per line; ISC applies to gravado lines only. The price stays FINAL: for a gravado line with rate `i`, `base = price / ((1+i) × 1.18)`, `isc = base × i`, `igv = (base+isc) × 0.18`, and `base + isc + igv = price` holds. The IGV `TaxSubtotal` taxable amount becomes `base + isc`. The `totals` object carries `isc`; each item carries `line_isc` and `isc_rate`. Example: 1 × 129.80 gravado with isc_rate 0.10 → base 100.00, isc 10.00, igv 19.80, total 129.80. **ICBPER (Impuesto al Consumo de las Bolsas de Plástico).** A fixed state surcharge per plastic bag (Ley 30884; S/ 0.50 per bag, 2023→). Set `icbper: true` on the product or line. It is its own tax line added ON TOP of the price (it is NOT part of the final price): `line_icbper = round2(quantity × 0.50)`. The bag still has its own price taxed normally. It enters `TaxInclusiveAmount` and the document `total`, but NOT `total_value` (LineExtensionAmount). The `totals` object carries `icbper`; each item carries `line_icbper`. Example: 3 bags × S/2.00 gravado → base 5.08, igv 0.92, icbper 1.50, total 7.50. **Descuento por línea (catálogo 53 código 00).** An optional per-line discount that LOWERS the IGV base (and therefore the IGV). Set `disc_rate` on an item — a fraction `0 ≤ d < 1`; gravado lines only in this version. The discount is taken off the line base first: `line_discount = round2(grossBase × d)`, `line_base = grossBase − line_discount`, and the IGV is charged on the reduced base. It nets into `LineExtensionAmount`; in the XML it is a per-line `cac:AllowanceCharge` (ChargeIndicator=false, reason code 00, MultiplierFactorNumeric = d, BaseAmount = gross base) and there is NO document-level `AllowanceTotalAmount` (line discounts are never double-counted). The `totals` object carries `descuento` (Σ line discounts); each item carries `line_discount` and `disc_rate`. Example: 1 × 118.00 gravado with disc_rate 0.10 → base 90.00, igv 16.20, total 106.20 (vs 100.00 / 18.00 / 118.00 without the discount). --- ## 5. Document lifecycle `POST /api/v1/documents` is synchronous (typically 2–8 s): build XML → sign → transmit to SUNAT → store files → render PDF → email customer. Result statuses: | status | meaning | what to do | |--------------|-------------------------------------------------------------------------|-------------------------------------------------------------------------------| | `accepted` | SUNAT accepted. Legally valid. Hash + CDR available. | Done. Deliver files / rely on the automatic customer email. | | `rejected` | SUNAT rejected (e.g. invalid customer RUC). Number consumed; counts against the plan. | Read `sunat.cdr_description` and `sunat.observations`, fix data, emit a NEW document. | | `error` | PSE/transport failure BEFORE a SUNAT verdict. Does NOT count against the plan. | Re-emit with a NEW idempotency key. Same key would replay this failed document. | | `processing` | transient, mid-pipeline (rare in API responses). | Poll `GET /api/v1/documents/{id}`. | HTTP status on creation: `201` document emitted (status accepted OR rejected), `200` idempotent replay (header `Idempotent-Replay: true`), `502` body is the document with `status: "error"`. Issue dates: `issue_date` defaults to today in America/Lima; it may be backdated at most 7 days (SUNAT deadline) and never in the future. Series and numbering: documents are numbered `SERIES-CORRELATIVE` (`F001-42`). Series are 4 chars: prefix `F` (facturas) or `B` (boletas) + 3 alphanumerics. Onboarding creates `F001` and `B001`. The correlative is allocated atomically at emission and can never be chosen or reused. Omitting `series` uses the type's active series. --- ## 6. Idempotency Send `Idempotency-Key: ` (max 100 chars; use your business event ID, e.g. the order ID) on every `POST /api/v1/documents`. Behavior: - First time: emits normally. - Same key again (same company): HTTP 200 + header `Idempotent-Replay: true` + the ORIGINAL document, regardless of its status. No second emission. - Therefore: retry **network failures/timeouts with the SAME key** (you'll either get the already-created document or a fresh emission), but retry documents that returned `status: "error"` with a **NEW key**. MCP equivalent: pass `idempotency_key` in the `create_document` arguments; the response includes `idempotent_replay: true|false`. --- ## 7. REST endpoints ### 7.1 POST /api/v1/documents — create + emit (scope: documents:write) Body fields: | field | type | required | notes | |--------------|---------|-----------------|------------------------------------------------------------------------| | `type` | string | yes | `"factura"` (01, requires RUC customer) or `"boleta"` (03) | | `series` | string | no | e.g. `"F001"`; must match the type prefix (F/B); default: active series | | `cash_register_id` | string | no | UUID of the caja to emit from (P6). Series resolves per the company's series mode; register + branch are recorded on the document. 422 `series_not_found` if the mode needs a series assigned to the register/branch and none exists | | `issue_date` | string | no | `YYYY-MM-DD`; default today (Lima); max 7 days back; never future | | `currency` | string | no | `"PEN"` (default) or `"USD"` | | `customer` | object | factura: yes | see below; omit on boletas for CLIENTES VARIOS (required if PEN total > S/700) | | `items` | array | yes | 1–100 items, see below | | `payment` | object | no | see below; default `{"type":"contado"}` | | `notes` | string | no | ≤1000 chars; printed on the PDF only (not in the XML) | | `send_email` | boolean | no | email PDF+XML to the customer; default: company setting | | `recargo_consumo` | object | no | restaurants/bars surcharge (no IGV); `{apply: bool, rate: 0–0.13}`; default: company setting | `customer` object: | field | type | required | notes | |--------------|--------|-------------------|--------------------------------------------------------------------| | `id` | uuid | no | existing customer; mutually exclusive with the inline fields | | `doc_type` | string | with inline data | `"6"` RUC, `"1"` DNI, `"4"` foreigner card, `"7"` passport, `"0"` none | | `doc_number` | string | with inline data | ≤15; RUC: 11 digits + check digit; DNI: 8 digits; `"0"` for type 0 | | `name` | string | with inline data | ≤500 | | `email` | string | no | where the PDF+XML is sent | | `address` | string | no | ≤500, printed on the PDF | Inline customers are automatically upserted into the customer directory (keyed by doc_type + doc_number), except type `"0"`. `items[]` — each item references a catalog product (`product_id` OR `code`) or is a free-form line (`description` + `unit_price`): | field | type | required | notes | |---------------|--------|----------|------------------------------------------------------------------------| | `product_id` | uuid | no* | catalog product by id | | `code` | string | no* | catalog product by your code | | `description` | string | no* | ≤500; required for free-form lines; overrides the product name | | `quantity` | number | no | > 0, up to 3 decimals; default 1 | | `unit_price` | number | no* | **FINAL unit price, IGV included**; overrides the product price; required for free-form lines and when product currency ≠ document currency | | `unit_code` | string | no | SUNAT catalog 03 (see §11); default `NIU` (or the product's) | | `affectation` | string | no | `"10"` gravado (default), `"20"` exonerado, `"30"` inafecto (or the product's) | | `isc_rate` | number | no | ISC al valor as a fraction 0–1 (gravado only); overrides the product. Price stays final | | `disc_rate` | number | no | descuento por línea (catálogo 53 código 00) as a fraction 0 ≤ d < 1 (gravado only); lowers the line base and IGV | | `icbper` | bool | no | afecto a ICBPER (bolsa plástica, +S/0.50/unit); overrides the product default | `payment` object: | field | type | required | notes | |----------------|--------|--------------|----------------------------------------------------------------------| | `type` | string | no | `"contado"` (default) or `"credito"` | | `installments` | array | with credito | ≤36 of `{ "amount": number, "due_date": "YYYY-MM-DD" }`; amounts must sum to the document total (±0.01) | Example request: curl -X POST https://bloques.example.com/api/v1/documents \ -H "Authorization: Bearer sk_live_..." \ -H "Content-Type: application/json" \ -H "Idempotency-Key: orden-8842" \ -d '{ "type": "boleta", "items": [ { "description": "Menú del día", "quantity": 2, "unit_price": 25.00 } ] }' Success response (201) — the canonical Document object: { "id": "9f1b2c3d-4e5f-4a7b-8c9d-0e1f2a3b4c5d", "type": "boleta", "doc_type": "03", "series": "B001", "number": 117, "full_number": "B001-117", "file_name": "20123456789-03-B001-117", "status": "accepted", "issue_date": "2026-06-09", "issue_time": "12:30:45", "due_date": null, "currency": "PEN", "customer": { "docType": "0", "docNumber": "0", "name": "CLIENTES VARIOS" }, "totals": { "gravado": "42.37", "exonerado": "0.00", "inafecto": "0.00", "igv": "7.63", "isc": "0.00", "icbper": "0.00", "descuento": "0.00", "total_value": "42.37", "recargo_consumo": "0.00", "recargo_rate": null, "total": "50.00" }, "amount_in_words": "CINCUENTA CON 00/100 SOLES", "payment": { "type": "Contado" }, "notes": null, "sunat": { "hash": "kA1bC2dE3fG4hI5jK6lM7nO8pQ=", "cdr_description": "La Boleta numero B001-117, ha sido aceptada", "observations": null, "error_message": null }, "files": { "pdf": "/api/v1/documents/9f1b2c3d-4e5f-4a7b-8c9d-0e1f2a3b4c5d/pdf", "xml": "/api/v1/documents/9f1b2c3d-4e5f-4a7b-8c9d-0e1f2a3b4c5d/xml", "cdr": "/api/v1/documents/9f1b2c3d-4e5f-4a7b-8c9d-0e1f2a3b4c5d/cdr", "zip": "/api/v1/documents/9f1b2c3d-4e5f-4a7b-8c9d-0e1f2a3b4c5d/zip" }, "items": [ { "position": 1, "product_id": null, "code": null, "description": "Menú del día", "unit_code": "NIU", "quantity": "2", "unit_price": "25.00", "unit_value": "21.1864406780", "line_base": "42.37", "line_discount": "0.00", "disc_rate": null, "line_igv": "7.63", "line_isc": "0.00", "isc_rate": null, "line_icbper": "0.00", "line_total": "50.00", "affectation": "10" } ], "source": "api", "emailed_to": null, "emailed_at": null, "created_at": "2026-06-09T17:30:46.120Z" } Document field notes: - `customer` echoes the snapshot at emission time with camelCase keys (`docType`, `docNumber`, `name`, `email?`, `address?`). - `payment` in responses uses `"Contado"`/`"Credito"` (capitalized) and installments with camelCase `dueDate`: `{ "type": "Credito", "amount": "1180.00", "installments": [{ "amount": "590.00", "dueDate": "2026-07-09" }] }`. - `issue_time` is set only when the document is issued dated today; backdated documents have `null`. - `files.cdr` is `null` while no CDR exists (e.g. status `error`). - `number` is an integer; `full_number` is `SERIES-CORRELATIVE` without leading zeros. - `source` is `"ui"`, `"api"` or `"mcp"`. - If the customer email send failed, the creation response also carries a top-level `email_error` string (the document itself is unaffected). Errors: | HTTP | code | when | |------|---------------------|-----------------------------------------------------------------------| | 400 | `invalid_json` | body is not valid JSON | | 400 | `validation` | bad fields (details array), bad dates, installments ≠ total, etc. | | 400 | `customer_invalid` | factura without RUC, boleta with RUC, invalid identity doc, unknown customer id | | 402 | `plan_limit` | monthly plan limit reached (details: used, limit, plan) | | 409 | `company_not_ready` | company has not finished PSE onboarding | | 422 | `series_not_found` | series missing/inactive/wrong prefix for the type | | 422 | `product_not_found` | an item references a missing or inactive product | | 502 | `provider_error` | PSE error before the document was created | | 502 | (document body) | document persisted with `status: "error"` — PSE/transport failed | | 500 | `internal` | unexpected server error | (+ auth errors from §2 — applies to every endpoint below.) ### 7.2 GET /api/v1/documents — list (scope: documents:read) Query params: `from`, `to` (issue_date bounds, `YYYY-MM-DD`, inclusive), `type` (`factura`|`boleta`), `status` (`accepted`|`rejected`|`error`|`processing`), `q` (free text over full_number / customer name / customer doc, ≤100 chars), `page`, `per_page` (default 25, max 100). Newest first. Line items are NOT included. curl "https://bloques.example.com/api/v1/documents?from=2026-06-01&to=2026-06-30&status=accepted&per_page=100" \ -H "Authorization: Bearer sk_live_..." → { "data": [ {Document without items} ], "page": 1, "per_page": 100, "total": 42 } ### 7.3 GET /api/v1/documents/{id} — read one (scope: documents:read) Returns the Document INCLUDING `items[]`. 404 `not_found` if the UUID does not exist or belongs to another company. ### 7.4 GET /api/v1/documents/{id}/pdf — download PDF (scope: documents:read) Returns `application/pdf` (attachment `{file_name}.pdf`). Optional `?template=` re-renders on the fly with `clasica`, `moderna`, `minimal` or `ticket80` (80 mm ticket printer); without it, the company's default template (cached) is served. Errors: 404 `not_found`, 500 `pdf_failed`. curl -L -o boleta.pdf \ "https://bloques.example.com/api/v1/documents/{id}/pdf?template=ticket80" \ -H "Authorization: Bearer sk_live_..." ### 7.5 GET /api/v1/documents/{id}/xml — download XML (scope: documents:read) Default: the SIGNED XML (`application/xml`, `{file_name}.xml`) — the legally valid file. `?unsigned=true`: the raw unsigned UBL XML, attached as `{file_name}-sin-firmar.xml` so it cannot overwrite the signed download. Errors: 404 `not_found`, 404 `file_not_found` (e.g. signing never happened). ### 7.6 GET /api/v1/documents/{id}/cdr — download CDR (scope: documents:read) SUNAT's signed receipt (constancia de recepción) as XML (`application/xml`, `{file_name}-cdr.xml`). Only exists once SUNAT answered (accepted/rejected). Errors: 404 `not_found`, 404 `file_not_found`. ### 7.6a GET /api/v1/documents/{id}/zip — signed XML + CDR (scope: documents:read) Both files in one archive (`application/zip`, `{file_name}.zip`), holding `{file_name}.xml` and `{file_name}-cdr.xml`. Ships whichever exist — a rejected document is signed but has no CDR — and 404s only when neither does. This is the download the app offers. Errors: 404 `not_found`, 404 `file_not_found`. ### 7.6b POST /api/v1/documents/{id}/resend — corregir y reenviar (scope: documents:write) Re-emit a document that SUNAT **rejected** (`status:"rejected"`) or that never transmitted (`status:"error"`), **reusing the same serie-correlativo**. A rejected comprobante legally never existed, so its number may be reused; an `accepted` one is immutable — resending it would draw SUNAT error 1033 ("el comprobante ya fue informado"), so it is blocked with 409 `not_resendable`. Body: the corrected document, **same shape as `POST /documents`** (§7.1); `type` and `series` are ignored — the document's identity is fixed by its id. Every validation runs again (factura/boleta rules, boleta > S/ 700, dates, items). Quota mirrors a first emission: a resend that ends `accepted` consumes one unit of the monthly plan; a re-rejection releases it (invariant: only `accepted` documents count). Returns the re-emitted Document (HTTP 200) with `status` `accepted` or `rejected`; a transport failure persists `status:"error"` (HTTP 502) and can be resent again. Errors: 404 `not_found`, 409 `not_resendable` (accepted/processing) or `company_not_ready`, 400 `validation`/`customer_invalid`, 402 `plan_limit`, 422 `series_not_found`/`product_not_found`. curl -X POST https://bloques.example.com/api/v1/documents/{id}/resend \ -H "Authorization: Bearer sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "type": "factura", "currency": "PEN", "customer": { "doc_type": "6", "doc_number": "20123456789", "name": "ACME S.A.C." }, "items": [ { "description": "Servicio de consultoría", "quantity": 1, "unit_price": 118.00 } ] }' ### 7.7 GET /api/v1/documents/export — CSV export (scope: documents:read) Same filters as the list (`from`, `to`, `type`, `status`, `q`). Returns `text/csv; charset=utf-8` (with BOM), attachment `documentos-{ruc}.csv`, max 5000 rows, newest first. Columns: full_number, type, status, issue_date, issue_time, due_date, currency, customer_doc_type, customer_doc_number, customer_name, total_gravado, total_exonerado, total_inafecto, total_igv, total_value, total, amount_in_words, sunat_hash, cdr_description, source, emailed_to, created_at, id ### 7.8 POST /api/v1/products — create product (scope: products:write) | field | type | required | notes | |---------------|---------|----------|-----------------------------------------------------------| | `code` | string | yes | your unique code per company, ≤50 | | `name` | string | yes | ≤300 | | `description` | string | no | ≤1000 | | `unit_code` | string | no | catalog 03, default `NIU` | | `unit_price` | number | yes | **FINAL price, IGV included** when affectation is 10 | | `currency` | string | no | `PEN` (default) or `USD` | | `affectation` | string | no | `"10"` (default), `"20"`, `"30"` | | `isc_rate` | number | no | default ISC al valor rate as a fraction 0–1 (gravado only) | | `icbper` | boolean | no | default ICBPER flag (bolsa plástica); default false | | `track_stock` | boolean | no | inventory module tracks stock for this product; default true | | `min_stock` | number | no | low-stock alert threshold (sum across warehouses); omit = no alert | | `barcode` | string | no | barcode (manufacturer EAN or internal); unique per company | | `cost` | number | no | last purchase cost, FINAL (IGV included); informative | | `active` | boolean | no | default true | curl -X POST https://bloques.example.com/api/v1/products \ -H "Authorization: Bearer sk_live_..." -H "Content-Type: application/json" \ -d '{ "code": "CAFE-250", "name": "Café molido 250g", "unit_price": 35.00 }' → 201 { "id": "1a2b3c4d-0000-4000-8000-000000000001", "code": "CAFE-250", "name": "Café molido 250g", "description": null, "unit_code": "NIU", "unit_price": "35.00", "currency": "PEN", "affectation": "10", "isc_rate": null, "icbper": false, "track_stock": true, "min_stock": null, "barcode": null, "cost": null, "active": true, "created_at": "2026-06-09T15:00:00.000Z", "updated_at": "2026-06-09T15:00:00.000Z" } Errors: 400 `invalid_json` / `validation`, 409 `duplicate_code` / `duplicate_barcode`. ### 7.9 GET /api/v1/products — list (scope: products:read) Query: `q` (name or code), `include_inactive=true`, `page`, `per_page` (default 50, max 100). Active-only by default, ordered by code. → `{ "data": [Product], "page", "per_page", "total" }` ### 7.10 GET /api/v1/products/{id} (scope: products:read) One product by UUID. 404 `not_found`. ### 7.11 PUT /api/v1/products/{id} — update (scope: products:write) Partial update: send only the fields to change (same fields/rules as creation, all optional; `description` accepts `null` to clear). Emitted documents keep their snapshots. Errors: 400, 404 `not_found`, 409 `duplicate_code`. ### 7.12 DELETE /api/v1/products/{id} — soft delete (scope: products:write) Marks the product inactive (`active: false`) and returns 200 with the updated product. It disappears from default listings and can no longer be used in emissions. Reactivate with `PUT { "active": true }`. 404 `not_found`. ### 7.13 POST /api/v1/customers — create/upsert customer (scope: customers:write) | field | type | required | notes | |--------------|--------|----------|----------------------------------------------------------| | `doc_type` | string | yes | `"1"` DNI, `"4"` CE, `"6"` RUC, `"7"` passport (no `"0"`) | | `doc_number` | string | yes | validated per type (RUC check digit, DNI 8 digits) | | `name` | string | yes | ≤500 | | `email` | string | no | valid email ≤320 | | `phone` | string | no | digits/+/()/spaces/dashes, 6–20 chars | | `address` | string | no | ≤500 | | `custom_fields` | object | no | company-defined fields, keyed by definition key (see below) | UPSERT semantics: if a customer with the same doc_type + doc_number exists, name, email, phone and address are OVERWRITTEN with what is sent (absent → cleared). `custom_fields` is the exception: **absent → stored values are preserved** (old clients never wipe them); **present → validated against the company's active field definitions and replaced wholesale** (unknown key, type mismatch or missing required field → 400 `custom_fields_invalid` with per-key details; requires the campos-personalizados module, else 403 `module_not_enabled`). Values are JSON scalars — dates travel as "YYYY-MM-DD" strings; null or "" deletes the key. Definitions are managed in Bloques → Configuración → Campos (session-only; not part of this API). Always returns 201 with the customer: → 201 { "id": "7a8b9c0d-0000-4000-8000-000000000002", "doc_type": "6", "doc_number": "20512345678", "name": "ACME PERU S.A.C.", "email": "compras@acme.pe", "phone": "987654321", "address": null, "custom_fields": { "segmento": "Corporativo", "vip": true }, "created_at": "2026-06-09T15:10:00.000Z" } Note: emitting a document with inline customer data also upserts the directory — and NEVER touches `custom_fields` (emission is never blocked by required custom fields). ### 7.14 GET /api/v1/customers — list/search (scope: customers:read) Query: `q` (name or doc number), `page`, `per_page` (default 50, max 100). Ordered by name. → `{ "data": [Customer], "page", "per_page", "total" }`. Each customer includes `phone` and `custom_fields` (always present; `{}` when none). ### 7.15 GET /api/v1/customers/{id} — one customer (scope: customers:read) Returns the customer, same shape as §7.13's response. 404 `not_found` if the id is malformed, unknown, or belongs to another company. ### 7.16 PATCH /api/v1/customers/{id} — edit a customer (scope: customers:write) Partial update: absent fields are untouched; `null` clears email/phone/address. `doc_type`/`doc_number` are NOT patchable — they key the directory (a different document is a different customer; create a new one instead). `custom_fields` follows §7.13: present → validated + wholesale replace. → 200 with the updated customer. Errors: 400 `validation` / `custom_fields_invalid`, 403 `module_not_enabled`, 404 `not_found`. ### 7.17 GET /api/v1/series — numbering series (any valid token) Each series carries its `owner` (P6): `company` (no owner), `branch`, or `register`. `branch_id` / `cash_register_id` name the owner when applicable. The owner drives which series is auto-selected when emitting from a caja (see `cash_register_id` in POST /documents). → { "data": [ { "type": "factura", "doc_type": "01", "code": "F001", "next_number": 43, "active": true, "owner": "company", "branch_id": null, "cash_register_id": null }, { "type": "boleta", "doc_type": "03", "code": "B002", "next_number": 118, "active": true, "owner": "register", "branch_id": null, "cash_register_id": "…uuid…" } ] } ### 7.18 GET /api/v1/usage — monthly consumption (any valid token) Current America/Lima calendar month vs the plan limit. Counts documents with status accepted, rejected or processing (a rejection consumed a PSE signature); `error` does not count. → { "plan": "pro", "planStatus": "active", "used": 137, "limit": 3000, "remaining": 2863, "periodStart": "2026-06-01T05:00:00.000Z" } ### 7.19 GET /api/v1/company — company profile (any valid token) → { "id": "c0a80001-0000-4000-8000-000000000003", "ruc": "20123456789", "razon_social": "MI EMPRESA S.A.C.", "email": "facturacion@miempresa.pe", "direccion": "Av. Arequipa 1234, Lince", "ubigeo": "150116", "distrito": "Lince", "provincia": "Lima", "departamento": "Lima", "environment": "produccion", "plan": "pro", "plan_status": "active", "pdf_template": "moderna", "email_enabled": true, "created_at": "2026-01-15T14:00:00.000Z" } Never exposes PSE credentials. --- ## 7b. Quotes (Cotizaciones) A **quote** is an invoice-shaped priced document that is **never sent to SUNAT**: no UBL, no signing, no correlativo, and it does **NOT** count against the monthly plan limit. Each quote gets a sequential per-company reference like `COT-0001`. Quotes are editable drafts until converted. The derived `status` is `open`, `expired` (when `valid_until` has passed, Lima calendar) or `converted`. The request body reuses the same `customer`, `item` and `payment` objects as documents, but drops `type`, `series` and `send_email`, and adds `valid_until`. Quote PDFs render on demand (not cached) and support only the `clasica` (default) and `minimal` templates. ### 7b.1 POST /api/v1/quotes — create a quote (scope: quotes:write) Returns 201 with the quote and its `items`. curl -X POST https://bloques.example.com/api/v1/quotes \ -H "Authorization: Bearer sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "currency": "PEN", "valid_until": "2026-07-15", "customer": { "doc_type": "6", "doc_number": "20123456789", "name": "CLIENTE S.A.C." }, "items": [{ "description": "Consultoría", "quantity": 1, "unit_price": 1180 }] }' → { "id": "…", "code": "COT-0001", "status": "open", "valid_until": "2026-07-15", "totals": { "igv": "180.00", "total": "1180.00", … }, "converted": { "document_id": null, "at": null }, "files": { "pdf": "/api/v1/quotes/…/pdf" }, "items": [ … ] } ### 7b.2 GET /api/v1/quotes — list (scope: quotes:read) Filters: `from`, `to` (issue date), `converted` (true/false), `q` (code + customer), `page`, `per_page`. Returns quotes WITHOUT items. ### 7b.3 GET /api/v1/quotes/{id} — read one (scope: quotes:read) Returns the quote including `items`. ### 7b.4 PUT /api/v1/quotes/{id} — replace (scope: quotes:write) Same body as create; the `code` is preserved. Fails with 400 if the quote was already converted. ### 7b.5 DELETE /api/v1/quotes/{id} — delete (scope: quotes:write) Fails with 409 if the quote was already converted into a document. ### 7b.6 GET /api/v1/quotes/{id}/pdf — download PDF (scope: quotes:read) curl -L -o COT-0001.pdf \ "https://bloques.example.com/api/v1/quotes/{id}/pdf?template=minimal" \ -H "Authorization: Bearer sk_live_..." ### 7b.7 Converting a quote into a document Emit a normal document (`POST /api/v1/documents`) with `from_quote_id` set to the quote's UUID. If SUNAT **accepts** the emission, the quote is stamped `converted` (idempotent; a rejected or errored emission does NOT convert it). You rebuild `items` and `customer` from the quote yourself (fetch it via `GET /api/v1/quotes/{id}`) and add the SUNAT-specific fields (`type`, optional `series`). curl -X POST https://bloques.example.com/api/v1/documents \ -H "Authorization: Bearer sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "type": "factura", "from_quote_id": "…", "customer": { "doc_type": "6", "doc_number": "20123456789", "name": "CLIENTE S.A.C." }, "items": [{ "description": "Consultoría", "quantity": 1, "unit_price": 1180 }] }' MCP tools mirror these: `create_quote`, `get_quote`, `list_quotes`, `update_quote`, `delete_quote`, `get_quote_files`. --- ## 8. Error code reference (complete) | HTTP | code | endpoints | meaning / fix | |------|----------------------|---------------------|---------------------------------------------------------------------| | 400 | `invalid_json` | POST/PUT bodies | body is not valid JSON | | 400 | `validation` | all writes | field-level problems; see `details[{path,message}]` | | 400 | `customer_invalid` | POST /documents | factura↔RUC rule broken, bad identity doc, unknown customer id | | 401 | `invalid_token` | all | bad/revoked bearer token | | 401 | `unauthorized` | all | no credentials | | 402 | `plan_limit` | POST /documents | monthly limit reached — upgrade plan or wait for next Lima month | | 403 | `insufficient_scope` | all | token lacks required scope — create a token with the right scopes | | 403 | `module_not_enabled` | /quotes | company doesn't have the Cotizaciones module enabled | | 403 | `no_company` | session callers | user has no company yet (onboarding incomplete) | | 404 | `not_found` | /{id} routes | resource missing or owned by another company | | 404 | `file_not_found` | xml/cdr downloads | that file doesn't exist for this document | | 409 | `company_not_ready` | POST /documents | PSE onboarding incomplete | | 409 | `not_resendable` | POST /documents/{id}/resend | document is accepted (SUNAT 1033) or already processing — number can't be reused | | 409 | `duplicate_code` | products | another product already uses that code | | 422 | `series_not_found` | POST /documents | series missing/inactive/wrong prefix | | 422 | `product_not_found` | POST /documents | item references missing/inactive product | | 429 | `rate_limited` | all | >120 req/min/token — back off, respect the minute window | | 500 | `internal` | all | unexpected error | | 500 | `pdf_failed` | pdf download | PDF rendering failed — retry later | | 502 | `provider_error` | POST /documents | PSE failed before the document existed | | 502 | (document body) | POST /documents | body is a Document with `status:"error"` — retry with NEW idempotency key | --- ## 9. Plans & limits | Plan | Price | Documents/month | Notes | |------|--------------|-----------------|--------------------------------------------------| | free | S/ 0 | 10 | full feature set (API, MCP, PDFs, email) | | pro | S/ 40 /month | 3000 | falls back to free limits while past_due/canceled | - Months follow the **America/Lima calendar** (reset on the 1st, 00:00 Lima = 05:00 UTC). - accepted + rejected + processing count; `error` does not. - At the limit, emission returns 402 `plan_limit`. Check `GET /api/v1/usage` proactively. --- ## 10. MCP server Endpoint: `POST https://bloques.example.com/api/mcp` — Model Context Protocol over **Streamable HTTP, stateless** (each POST is an independent JSON-RPC message; GET returns 405; no sessions). Protocol versions: 2025-06-18, 2025-03-26, 2024-11-05. Capabilities: tools only. Auth: the SAME `Authorization: Bearer sk_live_…` tokens as the REST API; without a valid token nothing about the server is disclosed. Connect from Claude Code: claude mcp add --transport http facturo https://bloques.example.com/api/mcp \ --header "Authorization: Bearer sk_live_..." Generic client config: { "mcpServers": { "facturo": { "type": "http", "url": "https://bloques.example.com/api/mcp", "headers": { "Authorization": "Bearer sk_live_..." } } } } Security model: a token only sees the tools its scopes allow (`tools/list` is filtered — unauthorized surface stays hidden); tools that belong to a module (e.g. `create_expense` → module `finanzas`) are hidden as well unless the company has that module active, and calling one anyway fails — the server re-checks on execution; every token maps to exactly one company; every tool call is written to the company's audit log (`mcp.tool_call`). Best practice: one dedicated token per agent with minimal scopes (read-only for analysis agents). Expenses are write-only over MCP: no tool reads expenses, balances or money movements. Tools (args mirror the REST API; amounts are final prices, IGV included): 1. `create_document` (scope documents:write) — emit a factura/boleta to SUNAT, synchronous. Args: `type` (required, "factura"|"boleta"), `series`, `cash_register_id` (P6: caja to emit from; series resolves per the company's series mode), `issue_date`, `currency`, `customer` {id | doc_type, doc_number, name, email, address}, `items` (required, 1–100: {product_id | code | description+unit_price, quantity, unit_code, affectation, isc_rate, disc_rate, icbper}), `payment` {type, installments[{amount, due_date}]}, `notes`, `send_email`, `recargo_consumo` {apply, rate} (restaurants/bars surcharge, no IGV; default: company setting), `idempotency_key` (recommended). Returns the Document plus `idempotent_replay` and a `files_note`. REAL fiscal emission — confirm with the user first. Counts against the monthly plan. 2. `get_document` (documents:read) — args: `id_or_number` (UUID or "F001-42"). Returns the Document with items. 3. `list_documents` (documents:read) — args: `from`, `to`, `type`, `status`, `q`, `page`, `per_page` (default 25, max 100). Returns `{data, page, per_page, total}`. 4. `get_document_files` (documents:read) — args: `id` (UUID). Returns authenticated download URLs: `pdf`, `xml_signed`, `xml_unsigned`, `cdr`, `zip` (signed XML + CDR in one archive). GET them with the same Bearer header. 5. `list_products` (products:read) — args: `q`, `page`. Active products. 6. `create_product` (products:write) — args: `code` (req), `name` (req), `unit_price` (req, FINAL price), `description`, `unit_code` (default NIU), `currency`, `affectation` (default "10"), `isc_rate` (ISC al valor fraction), `icbper` (bolsa plástica). 7. `search_customers` (customers:read) — args: `q`. Searches name/doc number. Each result includes `phone` and `custom_fields`. 8. `get_usage` (no scope) — no args. Monthly usage vs plan. 9. `create_expense` (expenses:write; requires the `finanzas` module) — record an expense (money OUT): supplier purchase, service, payroll, rent or tax, with any purchase document or none at all. NOT sent to SUNAT, no correlativo, does NOT count against the plan limit. Args: `issue_date` (required, YYYY-MM-DD), `total` (required, IGV included — string for exactness or number), `doc_type` (01 factura default · 02 recibo por honorarios · 03 boleta · 04 liquidación · 07/08 nota de crédito/débito recibida · 10 arrendamiento · 12 ticket · 13 bank/insurance · 14 utilities · 00 none), `supplier_id` or `supplier` {doc_type, doc_number, name} (neither → PROVEEDOR VARIOS), `description` (optional short name for the expense — what it was spent on, max 200 chars; shown in the list and searchable; not `notes`, which is the long internal note), `series`, `number` (free-form text), `due_date`, `currency`, `exchange_rate`, `treatment` (gravado|exonerado|inafecto — only suggests the breakdown), `amounts` {base_gravada, igv, base_exonerada, base_inafecta, isc, otros_tributos} (must add up to `total` EXACTLY), `igv_destination`, `credito_fiscal`, `deductible`, `is_fixed_asset`, `detraction` {code, rate, amount, constancy, date} (snapshot of the constancia), `retention_rate`, `retention_amount`, `category_id`, `branch_id`, `cash_register_id`, `payment_terms`, `items` (optional lines that ALLOCATE the total; must sum to it), `notes`. Returns `{id, full_number}` only — expenses cannot be read back over MCP. Tool errors come back as MCP tool results with `isError: true` and a text like `"plan_limit: Límite mensual alcanzado (10/10 documentos)."`. --- ## 11. Catalog reference Document types (SUNAT catalog 01, v1 subset): - `01` FACTURA ELECTRÓNICA (API name "factura") — business customer with RUC - `03` BOLETA DE VENTA ELECTRÓNICA (API name "boleta") — consumers Customer identity documents (catalog 06): - `0` SIN DOCUMENTO (doc_number must be "0"; boletas only; auto-used when customer omitted) - `1` DNI (8 digits) - `4` CARNET DE EXTRANJERÍA (≤15 alphanumeric) - `6` RUC (11 digits with mod-11 check digit; facturas only) - `7` PASAPORTE (≤15 alphanumeric) IGV affectations (catalog 07 subset): `10` gravado (18% included in price, default), `20` exonerado, `30` inafecto. Unit codes (catalog 03 subset): NIU Unidad (default) · ZZ Servicio · KGM Kilogramo · GRM Gramo · LTR Litro · MTR Metro · MTK Metro cuadrado · MTQ Metro cúbico · CEN Ciento · DZN Docena · BX Caja · PK Paquete · BG Bolsa · BO Botella · GLL Galón (EE.UU.) · HUR Hora · DAY Día · TNE Tonelada · SET Juego · PR Par Currencies: PEN (Sol, "S/"), USD (Dólar americano, "$"). Series rules: 4 chars, prefix F (facturas) / B (boletas) + 3 alphanumerics. QR code on PDFs follows R.S. 097-2012/SUNAT: `RUC|docType|series|number|igv|total|issueDate|customerDocType|customerDocNumber|hash`. --- ## 12. Worked end-to-end examples ### 12.1 Boleta to a walk-in customer (no customer data) curl -X POST https://bloques.example.com/api/v1/documents \ -H "Authorization: Bearer sk_live_..." \ -H "Content-Type: application/json" \ -H "Idempotency-Key: pos-2026-06-09-0007" \ -d '{ "type": "boleta", "items": [ { "description": "Corte de cabello", "quantity": 1, "unit_price": 30.00, "unit_code": "ZZ" } ] }' Customer resolves to `{ "docType": "0", "docNumber": "0", "name": "CLIENTES VARIOS" }`. Totals: base 25.42 + IGV 4.58 = total 30.00. (Anonymous is fine here — only boletas over S/ 700 require an identified buyer.) ### 12.2 Factura with RUC, paid in 2 credit installments curl -X POST https://bloques.example.com/api/v1/documents \ -H "Authorization: Bearer sk_live_..." \ -H "Content-Type: application/json" \ -H "Idempotency-Key: contrato-771-cuota-plan" \ -d '{ "type": "factura", "customer": { "doc_type": "6", "doc_number": "20512345678", "name": "ACME PERU S.A.C.", "email": "facturas@acme.pe", "address": "Jr. Union 500, Lima" }, "items": [ { "description": "Implementación de sistema", "quantity": 1, "unit_price": 1180.00, "unit_code": "ZZ" } ], "payment": { "type": "credito", "installments": [ { "amount": 590.00, "due_date": "2026-07-09" }, { "amount": 590.00, "due_date": "2026-08-09" } ] } }' Installments sum 1180.00 = total (required, ±0.01). Response `payment`: `{ "type": "Credito", "amount": "1180.00", "installments": [ { "amount": "590.00", "dueDate": "2026-07-09" }, { "amount": "590.00", "dueDate": "2026-08-09" } ] }` and `due_date: "2026-08-09"` (the last installment). ### 12.3 Retry safely with an idempotency key # Attempt 1 times out at your HTTP client — outcome unknown. Retry SAME key: curl -X POST https://bloques.example.com/api/v1/documents \ -H "Authorization: Bearer sk_live_..." -H "Content-Type: application/json" \ -H "Idempotency-Key: orden-8842" \ -d '{ "type": "boleta", "items": [ { "code": "CAFE-250", "quantity": 1 } ] }' # If attempt 1 actually emitted: → 200, header "Idempotent-Replay: true", # body = the original document. Nothing was emitted twice. # If it never reached Bloques: → 201, fresh emission. # If the response has status "error" (HTTP 502): the PSE failed. Re-emit with a # NEW key (e.g. "orden-8842-r2") — the old key is now bound to the failed document. ### 12.4 List this month's accepted documents curl "https://bloques.example.com/api/v1/documents?from=2026-06-01&to=2026-06-30&status=accepted&per_page=100&page=1" \ -H "Authorization: Bearer sk_live_..." Iterate `page` until `page * per_page >= total`. For accounting hand-off, prefer the CSV: curl -L -o junio.csv \ "https://bloques.example.com/api/v1/documents/export?from=2026-06-01&to=2026-06-30&status=accepted" \ -H "Authorization: Bearer sk_live_..." ### 12.5 Download a document's PDF (and the rest of its files) DOC=9f1b2c3d-4e5f-4a7b-8c9d-0e1f2a3b4c5d curl -L -o doc.pdf "https://bloques.example.com/api/v1/documents/$DOC/pdf" \ -H "Authorization: Bearer sk_live_..." curl -L -o doc.pdf "https://bloques.example.com/api/v1/documents/$DOC/pdf?template=ticket80" \ -H "Authorization: Bearer sk_live_..." # 80mm ticket version curl -L -o doc.xml "https://bloques.example.com/api/v1/documents/$DOC/xml" \ -H "Authorization: Bearer sk_live_..." # signed XML (legal file) curl -L -o cdr.xml "https://bloques.example.com/api/v1/documents/$DOC/cdr" \ -H "Authorization: Bearer sk_live_..." # SUNAT receipt curl -L -o doc.zip "https://bloques.example.com/api/v1/documents/$DOC/zip" \ -H "Authorization: Bearer sk_live_..." # both of the above, zipped --- ## 13. v1 limitations (current) - Document types: facturas (01) and boletas (03) only. - NO credit notes (07), debit notes (08) or voiding (comunicación de baja) — v2 roadmap. Corrections to accepted documents must be handled outside Bloques. - Currencies: PEN and USD. IGV rate fixed at 18%; affectations 10/20/30. ISC is supported in the "al valor" system only (catalog 08 "01"); ICBPER (plastic-bag tax, S/0.50/unit) is supported. Still out of scope: gratuitous transfers, exports, detractions, perception/retention regimes, and ISC monto-fijo / PVP (catalog 08 "02"/"03"). - One company (RUC) per account; tokens are per company. - Emission is synchronous; there are no webhooks — poll `GET /api/v1/documents` or use the response of the emission call itself. End of reference. Human-readable docs: https://bloques.example.com/docs