Documentación

Guía para agentes IA

(In English — this page is written for the agents themselves.)

This is the integration recipe for AI agents and coding assistants adding Peruvian electronic invoicing (SUNAT) to any system — an online store, a POS, an ERP, a chatbot. Bloques exposes a REST API and an MCP server; both use the same tokens and the same rules. The complete machine-readable reference lives at /llms-full.txt.

Safety rules (read first)

  • Prices are FINAL, IGV (18%) included. unit_price is what the customer actually pays. Bloques back-calculates the tax base (base = total / 1.18). Never send pre-tax prices — that would overcharge by 18%.
  • factura ↔ RUC. A factura requires a customer with RUC (doc_type: "6", 11 digits). A boleta must NOT have a RUC customer; use DNI ("1") or omit customer entirely for walk-in sales.
  • This is production. Every accepted document is fiscally bindingand reported to the tax authority. There is no sandbox. Confirm with the human before each real emission, and never emit “test” documents.
  • v1 cannot void or credit-note documents. Mistakes on accepted documents must be fixed with a credit note outside Bloques. Prevention (confirmation + idempotency) is your only tool.
  • Always send an idempotency key derived from your business event (e.g. order ID), so retries can never double-invoice.

The 7-step recipe

  1. 1

    Get a token

    Ask the human to create an API token in Configuración → API (token management is session-only by design — you cannot create tokens via the API). Request minimal scopes: an invoicing agent needs documents:write + documents:read; add products:read/products:write if you manage the catalog. Send it on every request as Authorization: Bearer sk_live_…. Rate limit: 120 req/min.

  2. 2

    Read /llms-full.txt

    Fetch https://<host>/llms-full.txt once. It is the complete, self-contained API reference (every endpoint, field, error code, and worked examples). The OpenAPI spec is at /openapi.json. Then verify connectivity with GET /api/v1/company and GET /api/v1/usage.

  3. 3

    Create products — or use free-form items

    Two ways to describe what you sell. If the system has a stable catalog, create products once and emit by code:

    POST /api/v1/products
    { "code": "PLAN-PRO", "name": "Suscripción Pro mensual", "unit_price": 40.00, "unit_code": "ZZ" }

    Then items are just { "code": "PLAN-PRO", "quantity": 1 }. For ad-hoc lines, skip the catalog and send description + unit_price directly. Both can be mixed in one document.

  4. 4

    Emit with an idempotency key

    POST /api/v1/documents with header Idempotency-Key: <your-order-id>. The call is synchronous (typically 2–8 s) and returns the SUNAT verdict. If you repeat the key, you get HTTP 200, header Idempotent-Replay: true, and the original document — no duplicate emission, ever.

    Retry policy: network error / timeout → retry with the same key. Response with status: "error" (HTTP 502) → retry with a new key (the same key would just replay the failed document).

  5. 5

    Handle accepted / rejected / error

    switch (doc.status) {
      case "accepted":   // Done. Legally valid. Store doc.id + full_number, deliver files.
      case "rejected":   // SUNAT said no. Read sunat.cdr_description + sunat.observations,
                         // fix the data (often the customer RUC), emit a NEW document.
                         // The number is consumed; the rejected doc cannot be edited.
      case "error":      // PSE/transport failure before a SUNAT verdict. Not counted
                         // against the plan. Re-emit with a NEW idempotency key.
      case "processing": // Rare (sync API). Poll GET /api/v1/documents/{id}.
    }
  6. 6

    Download files

    Same Bearer token: GET /api/v1/documents/{id}/pdf (optionally ?template=clasica|moderna|minimal|ticket80), /xml (signed XML; ?unsigned=true for the raw UBL), /cdr (SUNAT receipt XML), /zip (both XMLs in one archive). The document JSON already ships these paths under files.*. Bulk reporting: GET /api/v1/documents/export (CSV, same filters as the list, max 5000 rows).

  7. 7

    Monitor usage

    GET /api/v1/usage returns { used, limit, remaining } for the current America/Lima month (free: 10 docs, pro: 3000). When remaining is low, warn the human; at 0, emission fails with 402 plan_limit. Listing this month: GET /api/v1/documents?from=2026-06-01&status=accepted.

Worked example: store checkout emits a boleta

Scenario: an online store charges a consumer S/ 147.80 (two products, final prices) and must emit a boleta on payment confirmation. The buyer gave DNI and email; the order ID is ORD-2026-5512.

Request
POST https://bloques.example.com/api/v1/documents
Authorization: Bearer sk_live_...
Content-Type: application/json
Idempotency-Key: ORD-2026-5512

{
  "type": "boleta",
  "customer": {
    "doc_type": "1",
    "doc_number": "44556677",
    "name": "MARIA FERNANDA QUISPE ROJAS",
    "email": "maria@example.com"
  },
  "items": [
    { "code": "CAFE-250", "quantity": 3 },
    { "description": "Taza térmica Bloques", "quantity": 1, "unit_price": 42.80 }
  ],
  "payment": { "type": "contado" },
  "notes": "Pedido ORD-2026-5512",
  "send_email": true
}
Response — 201 Created
{
  "id": "5e6f7a8b-9c0d-4e1f-8a2b-3c4d5e6f7a8b",
  "type": "boleta",
  "doc_type": "03",
  "series": "B001",
  "number": 118,
  "full_number": "B001-118",
  "file_name": "20123456789-03-B001-118",
  "status": "accepted",
  "issue_date": "2026-06-09",
  "issue_time": "16:45:12",
  "due_date": null,
  "currency": "PEN",
  "customer": {
    "docType": "1",
    "docNumber": "44556677",
    "name": "MARIA FERNANDA QUISPE ROJAS",
    "email": "maria@example.com"
  },
  "totals": {
    "gravado": "125.25",
    "exonerado": "0.00",
    "inafecto": "0.00",
    "igv": "22.55",
    "total_value": "125.25",
    "total": "147.80"
  },
  "amount_in_words": "CIENTO CUARENTA Y SIETE CON 80/100 SOLES",
  "payment": { "type": "Contado" },
  "notes": "Pedido ORD-2026-5512",
  "sunat": {
    "hash": "tU8vW9xY0zA1bC2dE3fG4hI5jK6=",
    "cdr_description": "La Boleta numero B001-118, ha sido aceptada",
    "observations": null,
    "error_message": null
  },
  "files": {
    "pdf": "/api/v1/documents/5e6f7a8b-9c0d-4e1f-8a2b-3c4d5e6f7a8b/pdf",
    "xml": "/api/v1/documents/5e6f7a8b-9c0d-4e1f-8a2b-3c4d5e6f7a8b/xml",
    "cdr": "/api/v1/documents/5e6f7a8b-9c0d-4e1f-8a2b-3c4d5e6f7a8b/cdr",
    "zip": "/api/v1/documents/5e6f7a8b-9c0d-4e1f-8a2b-3c4d5e6f7a8b/zip"
  },
  "items": [
    {
      "position": 1,
      "product_id": "1a2b3c4d-0000-4000-8000-000000000001",
      "code": "CAFE-250",
      "description": "Café molido 250g",
      "unit_code": "NIU",
      "quantity": "3",
      "unit_price": "35.00",
      "unit_value": "29.6610169492",
      "line_base": "88.98",
      "line_igv": "16.02",
      "line_total": "105.00",
      "affectation": "10"
    },
    {
      "position": 2,
      "product_id": null,
      "code": null,
      "description": "Taza térmica Bloques",
      "unit_code": "NIU",
      "quantity": "1",
      "unit_price": "42.80",
      "unit_value": "36.2711864407",
      "line_base": "36.27",
      "line_igv": "6.53",
      "line_total": "42.80",
      "affectation": "10"
    }
  ],
  "source": "api",
  "emailed_to": "maria@example.com",
  "emailed_at": "2026-06-09T21:45:15.882Z",
  "created_at": "2026-06-09T21:45:13.001Z"
}

Note how total (147.80) equals exactly what was charged: 3 × 35.00 + 42.80. The base and IGV were derived per line, never altering the amount the customer paid. The checkout flow then stores id and full_number with the order; the customer already got the PDF + XML by email (emailed_to is set).

Pseudo-code for the integration

async function onPaymentConfirmed(order) {
  const res = await fetch("https://bloques.example.com/api/v1/documents", {
    method: "POST",
    headers: {
      "Authorization": "Bearer " + BLOQUES_TOKEN,
      "Content-Type": "application/json",
      "Idempotency-Key": order.id,            // safe retries
    },
    body: JSON.stringify({
      type: order.customerRuc ? "factura" : "boleta",
      customer: order.customerRuc
        ? { doc_type: "6", doc_number: order.customerRuc, name: order.businessName, email: order.email }
        : order.dni
          ? { doc_type: "1", doc_number: order.dni, name: order.fullName, email: order.email }
          : undefined,                         // boleta to CLIENTES VARIOS
      items: order.lines.map(l => ({
        description: l.title,
        quantity: l.qty,
        unit_price: l.finalUnitPrice,          // IGV INCLUDED — what was charged
      })),
      notes: "Pedido " + order.id,
    }),
  });

  const doc = await res.json();
  if (res.status === 502 || doc.status === "error") {
    return scheduleRetryWithNewKey(order);     // PSE down — new idempotency key
  }
  if (doc.status === "rejected") {
    return alertHuman(order, doc.sunat.cdr_description); // fix data, emit new doc
  }
  await saveInvoice(order.id, doc.id, doc.full_number);  // accepted
}

Prefer MCP?

If you operate inside an MCP-capable client, skip the HTTP plumbing: connect to POST https://<host>/api/mcp with the same Bearer header and use the tools create_document, get_document, list_documents, get_document_files, list_products, create_product, search_customers and get_usage — same semantics, same idempotency (idempotency_key argument). Details on the MCP page.

Pre-flight checklist

  • Token has the minimal scopes needed, nothing more.
  • Every unit_price is the final, IGV-inclusive amount actually charged.
  • Document type matches the customer: RUC → factura; DNI/none → boleta.
  • Idempotency-Key set from the business event ID on every emission.
  • The human confirmed that real fiscal documents should be emitted.
  • Handlers exist for rejected, error, 402 plan_limit and 429 rate_limited.
  • usage.remaining is monitored before high-volume runs.