# 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://enbloques.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
Two methods, one per surface:
- **REST API (`/api/v1`)** — API tokens: `Authorization: Bearer sk_live_...`
- **MCP server (`/api/mcp`)** — **OAuth 2.1** (MCP authorization spec 2025-06-18); see §10.
`sk_live_` tokens are still accepted there during the transition and will be removed.
- An MCP OAuth access token **also works on `/api/v1`** (same scopes, same clamp), because the
`get_document_files` tool hands out `/api/v1/documents/{id}/...` download URLs.
The scopes below are the same vocabulary for both.
- 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/delete customers |
| `expenses:write` | record expenses — MCP only (`create_expense`), no REST route |
| `crm:read` | read the customer pipeline and each customer's CRM log — MCP only |
| `crm:write` | move customers in the pipeline, write/archive CRM log entries — MCP only |
`GET /api/v1/series`, `GET /api/v1/usage` and `GET /api/v1/company` accept any valid
token (no scope required).
`expenses:write`, `crm:read` and `crm:write` enable no REST endpoint: they are used from the
app or with the MCP tools of §10. There is **no read of expenses or money by token** — a
deliberate product decision, not a gap. The CRM does expose reads, but **not configuration**:
creating, renaming, reordering or deleting a pipeline stage is `crm:manage`, which is
session-only like `team:manage` and `tokens:manage`.
- Over OAuth the same scopes are requested by the client and approved by the user, and then
**intersected with what that user's role allows, on every request**. A grant can never do
more than its owner: lose a capability and the agent loses it too.
- **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://enbloques.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.
**Detracción (SPOT).** For operations subject to the Sistema de Pago de Obligaciones
Tributarias, the CUSTOMER withholds a percentage of the invoice and deposits it into the
ISSUER's Banco de la Nación account, paying the issuer the rest. Critically, **it does not
change any amount on the document**: `totals.total` and the XML `PayableAmount` remain the
full amount — the detraction is informational. Do NOT subtract it.
Facturas only (a boleta customer cannot make the deposit), and only when the company has a
detraction account configured in Settings; otherwise emission fails with 422. Send
`detraction: {code, payment_method?, percent?, amount?}` where `code` is SUNAT catálogo 54
(e.g. "022" = otros servicios empresariales), `payment_method` is catálogo 59 (default
"001", depósito en cuenta), and `percent` is a PERCENTAGE, not a fraction (default 12) —
note this differs from `recargo_consumo.rate`, which IS a fraction.
`amount` is **always in PEN**, whatever the document currency ("la información de
detracción siempre se registrará en moneda nacional"). It defaults to
`round2(total × percent / 100)`, and is REQUIRED when `currency` is not `PEN`, because the
document carries no exchange rate to convert with. In the XML this becomes catálogo 51
operation type `1001` on `InvoiceTypeCode/@listID`, a `cac:PaymentMeans` block with the
account, a `cac:PaymentTerms` block (`Detraccion`, coexisting with the
`FormaPago` block rather than replacing it) whose `` is
hardcoded to soles, plus leyenda 2006. The response carries a `detraction` object (or
`null`) whose `percent` is a PERCENTAGE string ("12.00") — the same units the request takes,
so the object can be sent straight back.
**Credit + detracción.** On a `credito` factura subject to SPOT, the `Credito` amount and the
installments must sum to the **monto neto pendiente de pago** = `total − detraction` (RS
193-2020, Anexo 1 campo 64-A: the net pending amount discounts IGV withholdings, detractions
and other deductions), NOT the full total — the customer deposits the detraction and pays the
rest in installments. In USD documents the deduction is `total × percent` in USD (the PEN
deposit cannot be subtracted from a USD total). `PayableAmount` stays the full total. A
`detraction.amount` ≥ total is rejected (422 `validation`).
On **resend**, the body is a full replacement like every other field: omitting `detraction`
REMOVES it from the re-emitted document (which reuses the same serie-correlativo). If the
original was subject to detracción, send `detraction` again — echoing the one from GET
/documents/{id} is enough — or send a corrected one, which is the point of a resend when
SUNAT rejected because of it.
**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).
**Transferencia gratuita (operación no onerosa).** A line handed over WITHOUT charging for it:
"buy 10 get 1 free", samples, prizes, donations, giveaways to staff. Set `affectation` to one of
the catálogo 07 free codes (gravadas 11–16, exonerada 21, inafectas 31–36) and pass the item's
`unit_price` as the REFERENCE price — what it would have cost, IGV included when the code is a
gravada one. The comprobante is then issued with **precio de venta 0.00, valor de venta 0.00 and
line total 0.00**, and the reference value travels as SUNAT's *valor referencial* (catálogo 16
price type "02"), which is what makes the line legal: a free line without a reference value > 0
is rejected (error 2641). Free lines never reach the amount charged — `total_value`,
`TaxInclusiveAmount` and `total` all exclude them — and the IGV of a *gravada gratuita* is
informative only (the transferor absorbs it), so it stays out of `igv` and of the document's
tax total. The `totals` object carries `gratuitas` (Σ reference values) and `igv_gratuitas`;
each item carries `unit_reference_value`, `line_free_value` and `line_free_igv`. When EVERY line
is free the document totals 0.00 and carries leyenda 1002 ("TRANSFERENCIA GRATUITA DE UN BIEN
Y/O SERVICIO PRESTADO GRATUITAMENTE"); a mixed document does not. Free lines accept no
`isc_rate`, `disc_rate` or `icbper`, and are only valid on facturas/boletas — a quote or nota de
venta with one returns 422. Example: 2 × 59.00 gravado + 1 × 30.00 with affectation 31 →
gravado 100.00, igv 18.00, gratuitas 30.00, total 118.00.
**Grupos de ítems (sections).** Purely presentational sections inside ONE document, so a single
quote or sale can carry several separate jobs — the motivating case is a quote for two projects
("Servicio web proyecto 1": landing 400 + SEO 75 + redes 124; "Servicio ERP proyecto 2": POS 700
+ almacén 1200) that the customer reads as two blocks but pays as one. Group lines by repeating
the same `group_title` on consecutive items, or by passing an explicit `group_index` (needed only
to keep two ADJACENT groups apart when they share a title or have none — the form uses it because
it lets you create two still-unnamed groups). Stored indices are canonicalised: renumbered 1..N by
order of appearance, with the title propagated to every line of the group, so re-sending what you
read back is a no-op. Lines with no group are "loose" and print first.
Each group prints with a heading (`COT-0001-A`, `COT-0001-B` — the letter is the group's POSITION,
so deleting or moving a group renumbers the rest) and its own subtotal = Σ(`line_total` +
`line_icbper`) of its lines. The recargo al consumo is deliberately NOT attributed to any group
(it is a document-level percentage), so with a recargo the group subtotals do not add up to
`total`. There is always exactly ONE grand total, one IGV and one `amount_in_words`.
**A group is never a fiscal fact.** It does not reach the SUNAT XML in any form — the UBL of a
grouped sale is byte-identical to the same sale sent flat — it changes no amount, no tax, no
quota and no stock movement. Groups exist on the printed representation and the screens, nothing
else. They ARE carried through quote → comprobante conversion (unlike a quote's `title`, which is
dropped): the sections are how the customer read the proposal. Available on documents, quotes and
notas de venta; max 50 groups per document, titles ≤80 chars.
---
## 5. Document lifecycle
**Emission is ASYNCHRONOUS.** `POST /api/v1/documents` validates, consumes the correlative,
persists the document as `processing` and QUEUES the rest — build XML → sign → transmit to SUNAT →
store files → render PDF → email customer — which runs in a job with automatic retries. The call
returns in a few hundred milliseconds. Result statuses:
| status | meaning | what to do |
|--------------|-------------------------------------------------------------------------|-------------------------------------------------------------------------------|
| `processing` | The document exists and its number is consumed; SUNAT has not answered yet. This is how EVERY document starts. | Poll `GET /api/v1/documents/{id}`. **Never re-emit** — you would create a second comprobante. |
| `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; does NOT count against the plan. | Read `sunat.cdr_description` and `sunat.observations`, fix data, resend with `POST /documents/{id}/resend`. |
| `error` | Never transmitted (the PSE was down through every retry). Does NOT count against the plan. | Resend it with `POST /documents/{id}/resend` — same number, no new document. |
`sunat.settled_at` says when the document left `processing`; it is null while in flight.
HTTP status on creation: `202` created and queued (status `processing`, `Location` header),
`201` only with `Prefer: wait=N` when the verdict arrived within the deadline (header
`Preference-Applied`), `200` idempotent replay (header `Idempotent-Replay: true`, in whatever
state the original is). There is no longer a `502` on emission: a PSE failure is retried in the
job, not surfaced in the request.
`Prefer: wait=N` (RFC 7240, seconds, capped at 30) asks the server to hold the request until the
verdict arrives. It is a preference, not a guarantee.
While a document is `processing`, the PDF is already available (`files.pdf` is set, `GET /pdf`
answers 200): it is rendered from the stored rows and its QR is complete, because the Valor
Resumen in the QR is a digest of the XML that Bloques builds, not something SUNAT returns. The
other three (`/xml`, `/cdr`, `/zip`) answer `409 document_processing` and stay null — those are
bytes the PSE has not sent yet. 409 and not 404 because the document exists, its verdict does not.
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 |
| `collection_method` | string | no | how it was collected: `efectivo`/`transferencia`/`yape`/`plin`/`tarjeta`/`deposito`/`otro`. Internal only — never reaches SUNAT or the PDF (that's `payment`) |
| `recargo_consumo` | object | no | restaurants/bars surcharge (no IGV); `{apply: bool, rate: 0–0.13}`; default: company setting |
| `detraction` | object | no | detracción (SPOT), facturas only; `{code, payment_method?, percent?, amount?}`; see below |
`customer` object:
| field | type | required | notes |
|--------------|--------|-------------------|--------------------------------------------------------------------|
| `id` | uuid | no | existing customer; mutually exclusive with the inline fields |
| `doc_type` | string | with a number | `"6"` RUC, `"1"` DNI, `"4"` foreigner card, `"7"` passport, `"0"` none |
| `doc_number` | string | with a type 1/4/6/7 | ≤15; RUC: 11 digits + check digit; DNI: 8 digits. Omit for a nameless named customer |
| `name` | string | with inline data | ≤500. Alone (no number) catalogues a nameless directory row on quotes/sales notes |
Half an identity is a 400, never a nameless row: a number without its type, or a type
1/4/6/7 without its number. Falling back would DISCARD the document you did send, and a
document already written is never corrected afterwards — only a missing one can be added
later (§7.16).
| `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). A name with no number catalogues a nameless row (type `"0"`).
Walk-in CLIENTES VARIOS (omit `customer`) is never catalogued — and neither is the
explicit sentinel `doc_type "0"` + `doc_number "0"` WITH a name: that one keeps the name
in the document snapshot and leaves the directory alone.
`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 |
| `modifier_id` | uuid | no | product modifier (see 7.8). REQUIRES `product_id` or `code` of the parent. Takes the modifier's price + code and composes the description as `"{product} — {modifier}"`; affectation/ISC/ICBPER/unit/currency are inherited from the parent, but its `price_tiers` are its OWN (never the parent's) — the parent's `pool_tiers`, on the other hand, IS inherited. An explicit `unit_price` or `description` on the item wins over the modifier |
| `extra_ids` | uuid[] | no | the product's `extras` (see 7.8) charged on this line. Each must belong to that product, be active and be OFFERED for the chosen modifier (`prices[modifier_id] === null` = not offered → 400 `validation`; missing/inactive/other product/other company → 422 `extra_not_found`). Per-unit amounts are SUMMED on top of the resolved unit price — also on top of an explicit `unit_price` — and the description is composed as `"{description} + {extra} + {extra}"`. REQUIRES `product_id` or `code`; no duplicates; ≤30 |
| `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**; `0` is allowed and marks the line as FREE (a sample, a replacement, a promo item) — that is NOT the same as omitting the field, which prices the line from the catalog. Overrides the product price **and any volume tier** (the line still counts toward the global tier count when its product has `pool_tiers`); required for free-form lines and when product currency ≠ document currency. Omit it to take the catalog price — which is the `price_tiers` step matching this line's **qualifying** quantity when the product (or modifier) has tiers, and the plain `unit_price` otherwise. The qualifying quantity is the line's own `quantity` unless the product has `pool_tiers`, in which case it is the summed quantity of every flagged line in the document (see 7.8) |
| `unit_code` | string | no | SUNAT catalog 03 (see §11); default `NIU` (or the product's) |
| `affectation` | string | no | SUNAT catálogo 07. Onerous: `"10"` gravado (default), `"20"` exonerado, `"30"` inafecto (or the product's). **Transferencia gratuita** (the line is handed over free): gravadas `"11"` premio, `"12"` donación, `"13"` retiro, `"14"` publicidad, `"15"` bonificación, `"16"` entrega a trabajadores; exonerada `"21"`; inafectas `"31"` bonificación, `"32"` retiro, `"33"` muestras médicas, `"34"` convenio colectivo, `"35"` premio, `"36"` publicidad. On a free line `unit_price` is the REFERENCE price (what it would have cost, IGV included when gravada), still required and > 0 — the comprobante is issued with price 0.00 and that value is declared as the valor referencial SUNAT requires. No `isc_rate`/`disc_rate`/`icbper` on a free line; facturas and boletas only (a quote with one → 422) |
| `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 |
| `group_title` | string | no | ≤80. **ITEM GROUP (section)** this line belongs to — lets ONE document carry several separate jobs ("Servicio web proyecto 1" with 3 lines, "Servicio ERP proyecto 2" with 2). Repeat the SAME title on consecutive lines to group them: each group prints with a heading and its own subtotal, and the document still has ONE grand total. Presentational only — see §7.10 |
| `group_index` | number | no | explicit group ordinal (1, 2, …). Only needed to keep two ADJACENT groups apart when they would share a title (or have none). Wins over `group_title`. Stored indices are renumbered 1..N by order of appearance |
| `attributed_staff_id` | uuid | no | staff attribution (optional module `personal`): id of the ACTIVE staff member who served this line, for per-person performance/commission reporting. Validated against the company's staff (invalid → 400); silently dropped when the module is disabled. Never sent to SUNAT |
`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) — or to `total − detraction` when the factura carries a `detraction` (see Detracción) |
Example request:
curl -X POST https://enbloques.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",
"gratuitas": "0.00",
"igv_gratuitas": "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" }] }`. `amount` is the
monto neto pendiente de pago: the total, minus the detraction when the factura carries one.
- `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).
- If the document was emitted from a caja (`cash_register_id`) that has an NFC link and SUNAT
accepted it, the creation (and resend) response also carries a top-level
`nfc_window: { "cash_register_id", "expires_at", "seconds" }`: for ~60 s the customer can tap
the caja's NFC tag to download the receipt (PDF, signed XML + CDR) on their phone. Absent when
there is no tag to tap. The link is managed in the app (Configuración → Sucursales y cajas).
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 |
| 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://enbloques.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`, `lino`, `oscura` 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://enbloques.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). Like a first emission it QUEUES: returns HTTP 202 with the document
back in `processing`, or 200 with the settled document when `Prefer: wait=N` is honoured.
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://enbloques.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. Stored rounded to **3** decimals — a unit cost usually comes from a division (a thousand bags, a box of 144), unlike `unit_price`, which is 2 |
| `is_public` | boolean | no | shown on the company's public profile page (perfil-publico module); default false |
| `active` | boolean | no | default true |
| `price_tiers` | array | no | up to 20 volume price steps; see "Volume pricing" below |
| `pool_tiers` | boolean | no | **global tier count**: `true` makes this product's quantity add up with every other `pool_tiers` product in the document when reading `price_tiers`; see "Global tier count" below. Default false |
| `images` | array | no | up to 3 image REFERENCES, in order (first = cover); see "Product images" below |
| `modifiers` | array | no | up to 50 priced variations; see "Modifiers" below |
| `extras` | array | no | up to 30 add-ons summed on top of the price; see "Extras" below |
curl -X POST https://enbloques.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,
"price_tiers": [], "pool_tiers": false, "images": [],
"is_public": false, "active": true, "modifiers": [], "extras": [],
"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` /
`duplicate_modifier_name` / `duplicate_modifier_code` / `duplicate_extra_name`, 422
`invalid_image_key` (an `images[].key` was not uploaded by this company) / `modifier_not_found`
(an extra's `prices` names a modifier that is not this product's).
**Extras (`extras`).** An extra is not another product: it is the same one with something on
top ("Manga larga +4.00", "Barba +8.00"). Its amount is **summed on top of the resolved unit
price** of a line, after volume tiers and agreements are applied — it never competes with them
and is never discounted by them. Up to 30 per product, synced id-preserving exactly like
`modifiers` (with `id` updates, without creates, an active extra missing from the array is
deactivated; omit the field to touch nothing, `[]` deactivates all).
| field | type | required | notes |
|------------------|---------------|----------|-------------------------------------------------------------|
| `name` | string | yes | ≤100, unique per product among active extras |
| `default_amount` | number | yes | per-unit amount, FINAL (IGV included when gravado); 0 = free |
| `prices` | array\|object | no | per-modifier amounts (three states, below); omit = every modifier at `default_amount` |
Per modifier an extra is in one of **three states**: an amount, **free** (`0` — offered, charges
0.00 and shows up on the line) or **not offered** (`null` — selling it with that modifier is a
validation error). Charging zero and not having it are not the same thing. `prices` takes either
an **array aligned with the `modifiers` array of the same request** (`prices[i]` is the amount for
`modifiers[i]` — the only way to price a modifier that has no id yet; requires `modifiers` in the
request, same length) or an **object keyed by modifier id**. In responses it is always the object
form with string amounts (`{ "": "4.00", "": "0.00", "": null }`); a
modifier absent from it inherits `default_amount`. A product without modifiers has a single
amount: `default_amount`.
curl -X POST https://enbloques.com/api/v1/products \
-H "Authorization: Bearer sk_live_..." -H "Content-Type: application/json" \
-d '{ "code": "POLO", "name": "Polo", "unit_price": 28.00,
"modifiers": [ { "name": "S", "unit_price": 28 }, { "name": "M", "unit_price": 30 },
{ "name": "L", "unit_price": 32 } ],
"extras": [ { "name": "Manga larga", "default_amount": 4 },
{ "name": "Bordado", "default_amount": 5, "prices": [0, 5, 5] },
{ "name": "Talla especial", "default_amount": 3, "prices": [null, null, 3] } ] }'
To charge extras on a sale, pass their ids as `extra_ids` on the item (7.1) — documents, quotes
and sales notes alike. The server re-reads the extra's state for the chosen modifier (offered,
free, not offered), sums the amounts on top of the resolved unit price and composes the line
description (`"Polo — M + Manga larga"`). Every line in a response carries `extras`
(`[{ id, name, amount }]`, the amounts frozen at sale time; `unit_price` already includes them).
Customer **agreements**, which sit next to extras on the product screen, remain catalog data
for now: no sale endpoint applies them yet.
**Volume pricing (`price_tiers`).** A product — or a modifier — can carry up to 20 price
steps: *from* `min_quantity` units, the FINAL unit price is `unit_price`. They are
**absolute prices, not discounts**, and the tax math is unchanged (the base is still
derived from the final price).
| field | type | required | notes |
|----------------|--------|----------|-----------------------------------------------------|
| `min_quantity` | number | yes | quantity threshold, up to 3 decimals; two tiers cannot share one |
| `unit_price` | number | yes | **FINAL ABSOLUTE price** at this step. Never a delta |
The step with the **highest** `min_quantity` that the line's **qualifying quantity** reaches
wins; below the lowest one the plain `unit_price` applies. The qualifying quantity is the
line's own `quantity` unless the product has `pool_tiers` on (see below). Array order is
irrelevant. Tiers are used
**only when an item omits `unit_price`** — a price you send always wins. That includes
conversions: emitting with `from_quote_id` / `from_sales_note_id` re-resolves the items you send,
so resend the quoted `unit_price` if you do not want the current catalog tier. Tiers are not
required to descend: the API does not stop you from charging more at a higher quantity.
Omitting `price_tiers` on an update touches nothing; `[]` clears them. In responses the
field is always present (`[]` when there are none) and its values are **strings**
(`{ "min_quantity": "12", "unit_price": "4.50" }`), like every other amount here.
curl -X POST https://enbloques.com/api/v1/products \
-H "Authorization: Bearer sk_live_..." -H "Content-Type: application/json" \
-d '{ "code": "POLO", "name": "Polo", "unit_price": 5.00,
"price_tiers": [ { "min_quantity": 12, "unit_price": 4.50 },
{ "min_quantity": 60, "unit_price": 4.00 } ] }'
# 30 polos, sin unit_price en el ítem → S/ 4.50 por unidad
curl -X POST https://enbloques.com/api/v1/documents \
-H "Authorization: Bearer sk_live_..." -H "Content-Type: application/json" \
-d '{ "type": "boleta", "items": [ { "code": "POLO", "quantity": 30 } ] }'
**Global tier count (`pool_tiers`).** A product can have its global tier count switched on,
which makes several products reach their volume thresholds **together**. It never changes
WHICH table is read — every product is always priced from **its own** `price_tiers` — only
which QUANTITY reads it:
| the item's product | qualifying quantity |
|-------------------------|------------------------------------------------------------------------|
| `pool_tiers: false` (default) | the line's own `quantity` — the classic behaviour, unchanged |
| `pool_tiers: true` | the SUM of the quantities of every line in the document whose product also has it on |
So with shorts at S/10 (`from 12 → 9`) and polos at S/15 (`from 12 → 14`), both with
`pool_tiers: true`, an order of **7 shorts + 5 polos = 12 garments** bills them at S/9 and S/14 —
each read from its own table. Rules worth knowing before you integrate:
- **There is ONE count per company.** The flag does not say WITH WHOM a product sums: it sums
with every flagged product in the document. It replaced the former tier groups (`tier_group`
/ `tier_group_id`), which are gone from this contract.
- **Any identified flagged line adds its quantity, however its price was set.** Sending an
explicit `unit_price` keeps that price (it always wins) but the line **still counts**. Only
free-form lines (no `product_id`/`code`) never count.
- **A modifier has no flag of its own: it inherits the parent product's**, while still using
its own `price_tiers`. "Polo Niño" 5 + "Polo Adulto" 7 reach 12 together.
- **Unflagged lines do not sum with each other.** Off means each line on its own.
- **Two states on `PUT`**: omitting the field touches nothing, a boolean switches it.
- A flagged product **without** tiers of its own simply adds its quantity and is billed at its
plain `unit_price`.
- Credit notes never re-price: a `nota de crédito` copies the original document's prices, so
returning 5 of 12 garments credits them at the tier price and the 7 that remain keep it.
curl -X POST https://enbloques.com/api/v1/products \
-H "Authorization: Bearer sk_live_..." -H "Content-Type: application/json" \
-d '{ "code": "SHORT", "name": "Short", "unit_price": 10.00, "pool_tiers": true,
"price_tiers": [ { "min_quantity": 12, "unit_price": 9.00 } ] }'
→ 201 { ..., "pool_tiers": true, ... }
# 7 shorts + 5 polos, both flagged, no unit_price on the items
# → 12 qualifying units: shorts at S/9.00 and polos at S/14.00
curl -X POST https://enbloques.com/api/v1/documents \
-H "Authorization: Bearer sk_live_..." -H "Content-Type: application/json" \
-d '{ "type": "boleta", "items": [ { "code": "SHORT", "quantity": 7 },
{ "code": "POLO-P", "quantity": 5 } ] }'
**Modifiers.** A product can carry up to 50 priced variations (size, finish, audience) —
one catalog entry instead of a dozen near-identical products. Each entry of `modifiers`:
| field | type | required | notes |
|--------------|---------|----------|--------------------------------------------------------------------|
| `id` | uuid | no | present = update that modifier; absent = create it |
| `name` | string | yes | <=150; unique per product among ACTIVE modifiers |
| `code` | string | no | <=50; unique per product when not null. Display/snapshot only — NOT a lookup key |
| `unit_price` | number | yes | **FINAL ABSOLUTE price**, IGV included when the parent is gravado, in the parent's currency. NEVER a delta |
| `price_tiers`| array | no | volume price steps OF THIS MODIFIER (it never inherits the parent's) |
| `active` | boolean | no | default true |
There are NO per-modifier routes: modifiers are synced inside the product payload, in one
atomic request, **preserving ids** — an entry with `id` updates it (so already-emitted
lines keep referencing the same row), one without `id` creates it, and an active modifier
**absent from the array is deactivated** (soft; the row survives for traceability).
Omitting `modifiers` touches nothing; `[]` deactivates all of them. `position` is not sent:
the array order IS the order. A modifier inherits affectation, ISC, ICBPER, unit and
currency from its parent — only the price and the label differ, and **that includes the
volume tiers**: a modifier uses its own `price_tiers` and never the parent's (otherwise
"Polo · Niño" would be sold with the adult's price table). The parent's **`pool_tiers` IS
inherited**, though — that is a qualifying quantity, not a price, so "Polo Niño" 5 +
"Polo Adulto" 7 reach the 12 together and each is then read from its own table.
There is no per-modifier stock and no per-modifier barcode. Reference a modifier on an item with `modifier_id` (7.1);
its `code` is never a lookup key.
curl -X POST https://enbloques.com/api/v1/products \
-H "Authorization: Bearer sk_live_..." -H "Content-Type: application/json" \
-d '{
"code": "POLO-ALG", "name": "Polo algodón", "unit_code": "ZZ", "unit_price": 32.00,
"modifiers": [
{ "name": "Cuello camisero · Adulto", "code": "CAM-AD", "unit_price": 32.00 },
{ "name": "Cuello rib · Adulto", "code": "RIB-AD", "unit_price": 28.00 },
{ "name": "Cuello camisero · Niño", "code": "CAM-NI", "unit_price": 30.00 }
]
}'
→ 201 {
"id": "2b3c4d5e-0000-4000-8000-000000000002",
"code": "POLO-ALG", "name": "Polo algodón", "unit_code": "ZZ",
"unit_price": "32.00", "currency": "PEN", "affectation": "10", "active": true,
"modifiers": [
{ "id": "aaaa1111-...-0001", "name": "Cuello camisero · Adulto", "code": "CAM-AD", "unit_price": "32.00", "position": 0, "active": true },
{ "id": "aaaa1111-...-0002", "name": "Cuello rib · Adulto", "code": "RIB-AD", "unit_price": "28.00", "position": 1, "active": true },
{ "id": "aaaa1111-...-0003", "name": "Cuello camisero · Niño", "code": "CAM-NI", "unit_price": "30.00", "position": 2, "active": true }
],
"created_at": "2026-08-01T15:00:00.000Z", "updated_at": "2026-08-01T15:00:00.000Z"
}
**Product images (`images`).** A product can carry up to **3** images. The array order IS
the display order and the first one is the **cover** (the thumbnail the app shows in its
catalog list). What travels on this API is the **reference**, never the bytes:
"images": [ { "key": "products/{company_id}/{product_id}/9f2c…-polo.webp",
"name": "polo-azul.webp",
"content_type": "image/webp",
"bytes": 48213 } ]
- **Uploading is browser-session only.** The bytes go to `POST /api/products/images`
(multipart `file`, optional `product_id`), which stores them in R2 and returns exactly the
object above — send that back verbatim inside `images`. The web app converts every photo to
WebP **in the browser** before uploading, because the API runs on Cloudflare Workers and has
no image codec. There is no v1 endpoint for the upload and API tokens cannot perform it.
- **You cannot download them with a token either.** `GET /api/products/images?key=…` serves
the bytes to a browser session only, which is why no `url` field is promised here.
- **The key is checked on write.** A `key` outside your own company's prefix is rejected with
422 `invalid_image_key` — the key is the only tenant isolation R2 has, so it is verified when
storing and not just when serving.
- **`images` is a full replacement, never a merge.** On `PUT`, omitting the key touches
nothing and `[]` removes every image. Removing an image from the array does not delete the
R2 object.
- Accepted formats are PNG, JPEG and WebP, decided by **magic number** (not the declared
content type), 5 MB max per file. PDFs are rejected — unlike expense attachments.
### 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" }`
Every product carries its `modifiers` array (resolved in the same query, `[]` when none),
so discovering modifier ids never needs a second request. It also carries `pool_tiers` (the
global tier count).
### 7.10 GET /api/v1/products/{id} (scope: products:read)
One product by UUID, including its active `modifiers` ordered by `position`. 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.
Sending `modifiers` syncs the whole list with the id-preserving semantics above; omitting
it leaves every modifier untouched. `pool_tiers` has two states: omitting the key touches
nothing, a boolean switches the global tier count on or off — so a client that saves the
whole product must send the key explicitly.
`images` behaves the same way as a whole: omitting it touches nothing, `[]` clears every
image, and any array you send replaces the list outright.
Errors: 400, 404 `not_found`, 409 `duplicate_code` / `duplicate_modifier_name` / `duplicate_modifier_code`,
422 `invalid_image_key`.
### 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 | no | `"0"` none, `"1"` DNI, `"4"` CE, `"6"` RUC, `"7"` passport. Omit (with no number) for a nameless customer |
| `doc_number` | string | no | validated per type (RUC check digit, DNI 8 digits). Omit for a name-only row (`null` stored); a type without its number, a number without its type, or `"0"` WITH a number are all 400 |
| `name` | string | yes | ≤500 |
| `nickname` | string | no | ≤120, everyday nickname — directory only (see below) |
| `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) |
| `agreement_id` | uuid | no | price agreement, or `null` for no agreement (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). Nameless customers
(type `"0"`, no number) never collide — each POST creates a new row. `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.",
"nickname": "ACME", "email": "compras@acme.pe", "phone": "987654321", "address": null,
"custom_fields": { "segmento": "Corporativo", "vip": true },
"agreement_id": "3c4d5e6f-0000-4000-8000-000000000009",
"created_at": "2026-06-09T15:10:00.000Z"
}
Note: emitting a document with inline customer data also upserts the directory — and
NEVER touches `nickname` or `custom_fields` (emission is never blocked by required custom
fields, and it cannot wipe a nickname the business typed by hand).
`nickname` is what the business calls the customer day to day ("el Chino"). It lives only
in the directory: it is never copied into a document's `customer_snapshot`, so it never
reaches the UBL XML or the PDF — the legal `name` is the fiscal identity. The web app's
sales and quotes lists search it against the CURRENT directory, so adding a nickname today
finds last year's sales.
`agreement_id` is the customer's **price agreement** (catalog module): a named set of
pricing rules — a percentage off the catalog from N units, plus per-product exceptions
(own fixed price, own quantity ladder, or "no discount"). When a sale identifies this
customer, every catalog line is priced with it, and **the agreement beats the catalog even
when the catalog is cheaper** (there is no `min()`). An explicit `unit_price` on the item
still wins over everything. Like `custom_fields`, it is **preserved when absent** on the
upsert; `null` sets the customer back to «Público — sin acuerdo». A foreign, unknown or
ARCHIVED agreement id all return the same 404 — never another company's pricing. The
agreement is live customer data: the sale line stores the resolved price, never which
agreement produced it, so reassigning an agreement never rewrites past sales. Agreements
themselves are created and edited in the web app (Catálogo → Acuerdos); they are not part
of this API.
### 7.14 GET /api/v1/customers — list/search (scope: customers:read)
Query: `q` (name, nickname or doc number), `page`, `per_page` (default 50, max 100).
Ordered by name. → `{ "data": [Customer], "page", "per_page", "total" }`. Each customer
includes `nickname`, `phone`, `custom_fields` (always present; `{}` when none) and
`agreement_id` (`null` when the customer has no agreement).
### 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 nickname/email/phone/address
and `agreement_id`. `custom_fields` follows §7.13: present → validated + wholesale
replace. → 200 with the updated customer.
`doc_type`/`doc_number` are a **one-way door**: send them together to give a document to a
customer that has none (a name-only row, §7.13), and never to change one. They key the
directory, so rewriting the pair would move sales already made to a different person — a
mistyped document is fixed by creating another customer, not by editing this one.
→ 409 identity_locked the customer already has a document
→ 409 document_taken another customer of yours already holds that document
→ 400 validation only one of the two fields, doc_type "0", or an invalid document
Adding a document does NOT lose the customer's history: sales made while they had none stay
theirs (they are matched by the directory id frozen in their snapshot). Documents already
issued are never rewritten — their snapshot still reads SIN DOCUMENTO.
Other errors: 400 `validation` / `custom_fields_invalid`, 403 `module_not_enabled`,
404 `not_found`.
### 7.16a DELETE /api/v1/customers/{id} — delete a customer (scope: customers:write)
HARD delete, not reversible — unlike products (§7.12), which are only deactivated. The
customer leaves the directory; already issued documents, quotes, sales notes and bookings
are untouched (each keeps its own immutable copy of the customer data and simply loses the
live link). Emitting again to the same doc_type + doc_number recreates the customer as a new
row. → 200 `{ "deleted": true, "id": "…" }`. 404 `not_found` if the id is malformed,
unknown, or belongs to another company.
### 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. **Only `accepted` documents count**: the
unit is consumed when the document is created and released again if SUNAT rejects it or it never
reached SUNAT. A document still in `processing` holds its unit until it settles.
→ { "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",
"igv_rate": "0.180000", "series_mode": "company",
"modules": ["catalogo", "clientes", "cotizaciones", "finanzas"],
"membership": null }
Never exposes PSE credentials.
Also the client bootstrap: `igv_rate` is THIS company's effective IGV+IPM rate — use it to
preview totals instead of a hardcoded 0.18. `series_mode` is `company | per_branch |
per_register`. `modules` lists the active module slugs. `membership` is `{ role, capabilities }`
for session calls and `null` for `sk_live_…` tokens (a token carries scopes, not a role).
---
## 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 `title` and
`valid_until`. `title` is the optional subject line of the proposal (max 120 chars) — it heads
the PDF and the detail screen and is matched by the list's `q` search; it is NOT carried over
when the quote is converted into a document.
Quote PDFs render on demand (not cached) and support only the `clasica` (default),
`minimal`, `lino` and `oscura` 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://enbloques.com/api/v1/quotes \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"currency": "PEN",
"title": "Cambio de luminarias — Sede Surco",
"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", "title": "Cambio de luminarias — Sede Surco",
"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 + title + 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. It is a REPLACE, not a patch: omitting `title`
(or `notes`, or `valid_until`) clears the stored value. 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://enbloques.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://enbloques.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 |
| 409 | `duplicate_modifier_name` | products | that product already has an ACTIVE modifier with that name |
| 409 | `duplicate_modifier_code` | products | that product already has an ACTIVE modifier with that code |
| 422 | `series_not_found` | POST /documents | series missing/inactive/wrong prefix |
| 422 | `product_not_found` | POST /documents | item references missing/inactive product |
| 422 | `modifier_not_found` | POST /documents, /quotes | item references a modifier that doesn't exist, is inactive, or belongs to another product/company |
| 422 | `extra_not_found` | POST /documents, /quotes | an `extra_ids` entry doesn't exist, is inactive, or belongs to another product/company (an extra that exists but is not offered for the chosen modifier is 400 `validation`) |
| 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 |
| 409 | `document_processing`| GET /documents/{id}/xml, /cdr, /zip | no SUNAT verdict yet — poll the document and retry (the PDF is served from the start) |
---
## 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).
- Only `accepted` counts. `rejected` and `error` release their unit; `processing` holds it until
it settles.
- At the limit, emission returns 402 `plan_limit`. Check `GET /api/v1/usage` proactively.
---
## 10. MCP server
Endpoint: `POST https://enbloques.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: OAuth 2.1**, per the MCP authorization spec (2025-06-18). Nothing to paste: an
unauthenticated POST returns 401 with
WWW-Authenticate: Bearer error="invalid_token",
resource_metadata="https://enbloques.com/.well-known/oauth-protected-resource"
from which the client discovers the authorization server
(`/.well-known/oauth-authorization-server`), registers itself (RFC 7591 dynamic client
registration, open), and runs authorization code + PKCE (S256, mandatory). The user logs in
and **approves an explicit consent screen every time** — the server never issues a code
without it. Access tokens are opaque, live 1 hour and are validated against the database on
every call; refresh tokens live 30 days, require `offline_access`, and are **single-use** — the
one you exchange dies in that same request.
Connect from Claude Code:
claude mcp add --transport http bloques https://enbloques.com/api/mcp
Generic client config:
{ "mcpServers": { "bloques": {
"type": "http",
"url": "https://enbloques.com/api/mcp" } } }
Legacy: `Authorization: Bearer sk_live_…` still works here during the transition and will be
removed from MCP (not from the REST API).
Security model: a connection only sees the tools its **effective** scopes allow — what the
user approved intersected with what that user's role can do right now, recomputed per
request (`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;
the company is resolved from the user's membership, so removing someone from the team kills
their agents immediately; every tool call is written to the company's audit log
(`mcp.tool_call`). Users disconnect agents from Configuración → API y MCP.
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,
It waits up to 25 s for SUNAT's verdict and returns the document either way; a `processing`
result means "poll `get_document`", never "emit again". 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, modifier_id, extra_ids, quantity,
unit_code, affectation, isc_rate, disc_rate, icbper, group_title, group_index}),
`payment` {type, installments[{amount, due_date}]}, `notes`,
`send_email`, `recargo_consumo` {apply, rate} (restaurants/bars surcharge, no IGV;
default: company setting), `detraction` {code, payment_method, percent, amount}
(SPOT, facturas only; does NOT change the total — see the Detracción section),
`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, each with its
`modifiers` array (`[]` when none) — that is where modifier ids are discovered, so no
second call is needed before referencing one with `modifier_id` on an item. Each product
also reports `pool_tiers` (global tier count).
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),
`price_tiers` (array of {min_quantity, unit_price} — ABSOLUTE prices per threshold),
`pool_tiers` (boolean; true = this product's quantity adds up with every other flagged
product in the document when reading `price_tiers` — see 7.8).
`modifiers` (array of {name, code?, unit_price} — each price FINAL and ABSOLUTE, never a delta).
**No `images`**: photos are uploaded as files from the app, and there is no way for a tool
call to produce a valid image key. Sending one fails the call — do not invent keys.
7. `search_customers` (customers:read) — args: `q`. Searches name/nickname/doc number.
Each result includes `nickname`, `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 · 91 documento
internacional — the invoice of a non-domiciled supplier, no `series` and no crédito
fiscal · 00 none), `supplier_id`
or `supplier` {doc_type, doc_number, name} (neither → PROVEEDOR VARIOS; the supplier
`doc_type` accepts `int`, the internal "documento internacional", for a foreign supplier
with no Peruvian ID — its tax ID verbatim, up to 20 chars, separators included: send
`99-1141420`, not `991141420`. An identified `supplier` — any doc_type but "0" — is
added to the supplier directory and linked to the expense, and an existing record is
never overwritten), `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.
10. `list_crm_stages` (crm:read; requires the `crm` module) — no args. The pipeline stages in
board order: `{id, name, position}`. Stages are configured in the app (Configuración →
Etapas del CRM) and CANNOT be created, renamed, reordered or deleted over MCP — that is
`crm:manage`, which is session-only.
11. `get_crm_pipeline` (crm:read; `crm` module) — no args. The whole board: every stage with
the customers in it, plus a final column with `stage: null` — the customers who are in the
directory but NOT in the funnel. That column is the ABSENCE of a stage, not a stage: a
customer created by issuing a document shows up there on its own, and taking someone out of
the funnel returns them to it. Each column carries its real `total` and at most 50
customers, most recently moved first. Cards carry only `customer_id`, `name`, `nickname`,
`entered_at` and `days_in_stage`: reading the pipeline is not reading the directory — use
`search_customers` (customers:read) for phone/email/document.
12. `move_crm_client` (crm:write; `crm` module) — args: `customer_id` (req), `stage_id` (req,
UUID or `null`). `null` takes the customer OUT of the funnel: it deletes their place on the
board, NOT the customer and NOT their CRM log. Moving someone to the stage they are already
in is a no-op that keeps their time-in-stage running.
13. `list_crm_interactions` (crm:read; `crm` module) — args: `customer_id` (req), `archived`
(default false). The customer's CRM log, most recent first: `{id, kind, occurred_on, note,
author_name, created_at, archived_at}`. At most 50 entries plus the real `total`;
`archived` always reports how many are put away, whichever set you asked for. The live and
archived sets are disjoint.
14. `log_crm_interaction` (crm:write; `crm` module; **OAuth only**) — args: `customer_id`
(req), `kind` (req: call|whatsapp|meeting|email|visit|note — the CHANNEL, not a status),
`occurred_on` (req, YYYY-MM-DD, the DAY it happened; backdating fine, the future is not —
today is America/Lima), `note` (req, ≤1000 chars). The customer does not need to be in the
funnel. The entry is SIGNED by the connected person, so a company token (`sk_live_…`) is
refused with `signature_required`: an unsigned entry would be indistinguishable from one
whose author was deleted. Entries are never edited nor deleted — correcting one means
writing a new entry and archiving the old.
15. `archive_crm_interaction` (crm:write; `crm` module) — args: `interaction_id` (req),
`archived` (req). Archives an entry or brings it back. The log NEVER deletes: an archived
entry leaves the list and stays readable with `list_crm_interactions({archived: true})`.
Archiving twice does not move the archive date.
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://enbloques.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://enbloques.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://enbloques.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.
# status "processing" is NOT a failure: poll the document, never re-emit.
# If it settles as "error": nothing reached SUNAT. Resend it 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://enbloques.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://enbloques.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://enbloques.com/api/v1/documents/$DOC/pdf" \
-H "Authorization: Bearer sk_live_..."
curl -L -o doc.pdf "https://enbloques.com/api/v1/documents/$DOC/pdf?template=ticket80" \
-H "Authorization: Bearer sk_live_..." # 80mm ticket version
curl -L -o doc.xml "https://enbloques.com/api/v1/documents/$DOC/xml" \
-H "Authorization: Bearer sk_live_..." # signed XML (legal file)
curl -L -o cdr.xml "https://enbloques.com/api/v1/documents/$DOC/cdr" \
-H "Authorization: Bearer sk_live_..." # SUNAT receipt
curl -L -o doc.zip "https://enbloques.com/api/v1/documents/$DOC/zip" \
-H "Authorization: Bearer sk_live_..." # both of the above, zipped
### 12.6 A service sold at several prices (product modifiers)
A print shop sells one service — making polo shirts — at a different list price per
combination. Instead of a dozen near-identical catalog entries, create ONE product with
modifiers, then quote the exact one.
# 1. One catalog entry with its three priced variations.
curl -X POST https://enbloques.com/api/v1/products \
-H "Authorization: Bearer sk_live_..." -H "Content-Type: application/json" \
-d '{
"code": "POLO-ALG", "name": "Polo algodón", "unit_code": "ZZ", "unit_price": 32.00,
"modifiers": [
{ "name": "Cuello camisero · Adulto", "code": "CAM-AD", "unit_price": 32.00 },
{ "name": "Cuello rib · Adulto", "code": "RIB-AD", "unit_price": 28.00 },
{ "name": "Cuello camisero · Niño", "code": "CAM-NI", "unit_price": 30.00 }
]
}'
# → 201; keep the product id and the modifier ids from the "modifiers" array.
# 2. Quote 20 units of the cheapest one. modifier_id REQUIRES product_id (or code).
curl -X POST https://enbloques.com/api/v1/quotes \
-H "Authorization: Bearer sk_live_..." -H "Content-Type: application/json" \
-d '{
"customer": { "doc_type": "6", "doc_number": "20123456789", "name": "Colegio San Juan S.A.C." },
"items": [
{ "product_id": "2b3c4d5e-0000-4000-8000-000000000002",
"modifier_id": "aaaa1111-...-0002",
"quantity": 20 }
]
}'
# The stored line reads:
# description "Polo algodón — Cuello rib · Adulto" (product name + " — " + modifier name)
# code "RIB-AD" (the modifier's, falling back to the product's)
# unit_price "28.00" (the modifier's FINAL price, IGV included)
# modifier_id "aaaa1111-...-0002"
# unit_code, affectation, isc_rate and icbper are inherited from the parent product.
# Sending unit_price or description on the item overrides the modifier.
# 3. Convert to a factura: rebuild the items (modifier_id included) and post them
# to /documents with from_quote_id. Add-ons (embroidery, print) are separate
# catalog services — extra lines, not modifiers.
---
## 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 plus the gratuitas
(11-16/21/31-36), which issue the line at 0.00 with a valor referencial. 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: exports, detractions,
perception/retention regimes, and ISC monto-fijo / PVP (catalog 08 "02"/"03").
- Product modifiers are referenced by `modifier_id` only — never by their `code` — and have
no per-modifier stock or barcode (inventory aggregates on the parent product). The CSV
product import does not create modifiers.
- One company (RUC) per account; tokens are per company.
- Emission is asynchronous and there are no webhooks — poll `GET /api/v1/documents/{id}`,
send `Prefer: wait=N`, or use
the response of the emission call itself.
End of reference. Human-readable docs: https://enbloques.com/docs