Documentación
API REST
Referencia completa de la API de Bloques. Base URL: https://<tu-host>/api/v1. Todas las peticiones y respuestas son JSON UTF-8 (salvo las descargas de archivos y el CSV). También disponible como OpenAPI 3.1 y como referencia para LLMs.
Autenticación
Cada petición lleva un token Bearer creado en Configuración → API:
Authorization: Bearer sk_live_...- Los tokens tienen la forma
sk_live_…y se muestran una sola vez al crearlos; Bloques guarda solo su hash. - La gestión de tokens (crear, listar, revocar) es solo por sesión web, por diseño: no existe endpoint de API para crear tokens, de modo que un token filtrado nunca pueda fabricar más tokens ni escalar sus permisos.
- Cada token pertenece a una empresa y solo ve los datos de esa empresa.
- Límite de tasa: 120 solicitudes por minuto por token. Al excederlo recibes
429 rate_limited. - CORS habilitado (
Access-Control-Allow-Origin: *): puedes llamar a la API desde un navegador. Cabeceras permitidas:Authorization,Content-Type,Idempotency-Key. Aun así, nunca incrustes un tokensk_live_…en código que llegue al navegador de terceros.
Alcances (scopes)
Al crear un token eliges sus alcances. Un token solo puede usar los endpoints de sus alcances:
| Scope | Permite |
|---|---|
* | Todos los alcances (acceso total de API). |
documents:read | Listar y leer documentos, descargar PDF/XML/CDR, exportar CSV. |
documents:write | Emitir documentos. |
quotes:read | Listar y leer cotizaciones, descargar su PDF. |
quotes:write | Crear, actualizar y eliminar cotizaciones. |
products:read | Listar y leer productos. |
products:write | Crear, actualizar y desactivar productos. |
customers:read | Listar y buscar clientes. |
customers:write | Crear/actualizar clientes. |
expenses:write | Registrar gastos. Solo por MCP (herramienta create_expense): la API REST no expone gastos. |
crm:read | Ver el embudo de clientes y la bitácora de cada uno. Solo por MCP: la API REST no expone el CRM. |
crm:write | Mover clientes en el embudo y anotar en su bitácora. Solo por MCP. Configurar las etapas NO entra: eso se hace en la app. |
GET /series, GET /usage y GET /company aceptan cualquier token válido (no exigen scope).
expenses:write, crm:read y crm:write no habilitan ningún endpoint REST: se usan desde la app o con las herramientas del servidor MCP. No existe lectura de gastos ni de dinero por token — es una decisión de producto, no una omisión. Del CRM sí sale lectura, pero no configuración: crear, renombrar, reordenar o borrar una etapa pide una sesión, como gestionar el equipo o los tokens.
Formato de error
Todos los errores comparten el mismo sobre:
{
"error": {
"code": "plan_limit",
"message": "Límite mensual alcanzado (10/10 documentos).",
"details": { "used": 10, "limit": 10, "plan": "free" }
}
}details es opcional. En errores validation de cuerpo JSON es una lista [{ "path": "items.0.unit_price", "message": "…" }].
Paginación
Los listados aceptan page (desde 1) y per_page (máx. 100; por defecto 25 en documentos y 50 en productos/clientes) y responden:
{ "data": [ ... ], "page": 1, "per_page": 25, "total": 137 }Dinero, fechas y zona horaria
- Los montos en las respuestas son strings con 2 decimales (
"118.00") para evitar errores de coma flotante. En las peticiones envías números (118o118.00). - Todos los precios que envías son finales, con IGV (18%) incluido. Bloques calcula la base (total ÷ 1.18) y el IGV por ti.
- Fechas
YYYY-MM-DD; timestamps ISO 8601 UTC. El calendario operativo (fecha por defecto, meses del plan) es America/Lima.
Documentos
/api/v1/documentsdocuments:writeCrea el comprobante y lo encola. Responde 202 Accepted con el documento en processing —número, totales e ítems ya definitivos— y un Location a su detalle. La firma, el envío a SUNAT y el veredicto ocurren después, con reintentos automáticos. En producción es una emisión fiscal real.
Cabecera Prefer: wait
Si prefieres esperar el veredicto en la misma llamada, manda Prefer: wait=N (RFC 7240, segundos, tope 30). Si SUNAT contesta dentro del plazo la respuesta es 201 con el documento ya cerrado y la cabecera Preference-Applied: wait=N; si no, es 202 y consultas GET /api/v1/documents/{id} cuando quieras. Es una preferencia, no una garantía: el veredicto de SUNAT no es del request.
Cabecera Idempotency-Key
Envía Idempotency-Key: <clave única> (máx. 100 caracteres, p. ej. el ID de tu orden) en cada emisión. Si repites una clave ya usada por tu empresa, Bloques no emite de nuevo: responde 200 con el documento original y la cabecera Idempotent-Replay: true. Así un timeout o doble clic jamás genera dos comprobantes. Ojo: el replay devuelve el documento original aunque haya quedado en estado error — para reintentar de verdad usa una clave nueva.
Cuerpo
| Campo | Tipo | Requerido | Descripción |
|---|---|---|---|
type | string | Sí | "factura" (01, exige cliente con RUC) o "boleta" (03). |
series | string | No | Serie de 4 caracteres (F001/B001…). Prefijo F para facturas, B para boletas. Por defecto, la serie activa del tipo. |
cash_register_id | string | No | UUID de la caja desde la que emites. La serie se resuelve según el modo de series de la empresa y la caja + su sucursal quedan registradas en el documento. Si el modo exige una serie asignada a la caja/sucursal y no existe, devuelve 422 series_not_found. Si la caja tiene enlace NFC y SUNAT acepta el comprobante, la respuesta incluye nfc_window (cash_register_id, expires_at, seconds): los ~60 s en que el cliente puede acercar su celular al tag de la caja para descargar su comprobante. |
issue_date | string | No | YYYY-MM-DD. Por defecto hoy (Lima). Máximo 7 días atrás; nunca futura. |
currency | string | No | "PEN" (por defecto) o "USD". |
customer | object | Factura: sí | Ver tabla siguiente. Opcional en boletas: si se omite, se emite a CLIENTES VARIOS — salvo que el total en soles supere S/ 700, en cuyo caso SUNAT exige identificar al cliente. |
items | array | Sí | 1 a 100 ítems. Ver tabla de ítems. |
payment | object | No | Forma de pago. Por defecto contado. Ver tabla de pago. |
notes | string | No | Hasta 1000 caracteres. Texto libre impreso en el PDF (no forma parte del XML). |
send_email | boolean | No | Enviar PDF + XML al email del cliente. Por defecto, la configuración de la empresa. |
collection_method | string | No | Medio de cobro: efectivo, transferencia, yape, plin, tarjeta, deposito u otro. Es un dato interno del negocio: no viaja a SUNAT ni sale en el PDF. No confundir con payment (la forma de pago del XML) ni con detraction.payment_method (catálogo 59). Omitirlo lo deja sin registrar. |
recargo_consumo | object | No | Recargo al consumo (restaurantes/bares): sin IGV, se suma al total. apply (boolean) lo habilita/deshabilita y rate (fracción 0–0.13, p. ej. 0.05 = 5%) fija la tasa. Por defecto, la configuración de la empresa. |
detraction | object | No | Detracción (SPOT). Solo facturas, y requiere que la empresa tenga configurada su cuenta del Banco de la Nación. code (catálogo 54 de SUNAT, p. ej. "022"), payment_method (catálogo 59; por defecto "001", depósito en cuenta), percent (porcentaje, no fracción; por defecto 12) y amount (monto a detraer siempre en soles; por defecto total × percent, y obligatorio si el comprobante no está en PEN). No modifica los totales: el comprobante se emite por el íntegro y el cliente deposita la detracción. |
Objeto customer
| Campo | Tipo | Requerido | Descripción |
|---|---|---|---|
id | string (uuid) | No | Cliente existente del directorio. Excluyente con los campos en línea. |
doc_type | string | Con datos en línea | "6" RUC · "1" DNI · "4" carnet de extranjería · "7" pasaporte · "0" sin documento. |
doc_number | string | Con datos en línea | Máx. 15. RUC: 11 dígitos con dígito verificador. DNI: 8 dígitos. Omitirlo, con el nombre, cataloga una ficha sin documento (nota de venta / cotización); "0" con tipo "0" es la venta al paso con nombre y no cataloga nada. Media identidad (número sin tipo, o tipo sin número) es un 400. |
name | string | Con datos en línea | Razón social o nombre. Máx. 500. |
email | string | No | Destino del PDF + XML. |
address | string | No | Dirección impresa en el PDF. Máx. 500. |
Objeto item
Cada ítem referencia un producto del catálogo (product_id o code) o es una línea libre (description + unit_price).
| Campo | Tipo | Requerido | Descripción |
|---|---|---|---|
product_id | string (uuid) | No* | Producto del catálogo por id. |
code | string | No* | Producto del catálogo por tu código. |
modifier_id | string (uuid) | No | Modificador del producto (ver modifiers en Productos). Exige product_id o code del producto padre. Toma el precio y el código de la modificador y compone la descripción como "Nombre del producto — Nombre del modificador"; todo lo demás (afectación, ISC, ICBPER, unidad, moneda) se hereda del producto — salvo sus price_tiers, que son propios y nunca los del padre. La pool_tiers del padre, en cambio, sí se hereda: es una cantidad calificadora, no un precio. Un unit_price o una description explícitos en el ítem le ganan al modificador. El modificador solo se referencia por id: su code no sirve para buscarla. |
extra_ids | string[] (uuid) | No | Extras del producto que se cobran en la línea (ver extras en Productos). Cada uno tiene que ser de ese producto, estar activo y ofrecerse para el modificador elegido (una celda null en prices es «no se ofrece»: venderlo es un error, no una fila en cero). Su monto por unidad se suma al precio resuelto —también a un unit_price explícito— y la descripción los nombra: "Polo — M + Manga larga". Exige product_id o code; sin repetidos; máx. 30. |
description | string | No* | Descripción de línea libre (exige unit_price) o reemplazo de la descripción del producto. Máx. 500. |
quantity | number | No | > 0, hasta 3 decimales. Por defecto 1. |
unit_price | number | No* | Precio unitario FINAL, con IGV incluido. Sobrescribe el precio del producto y cualquier escalón por cantidad (la línea igual aporta su cantidad a la cuenta global si su producto la tiene). Omítelo para tomar el precio del catálogo: ahí entra el escalón de price_tiers que corresponde a la cantidad calificadora de la línea cuando el producto (o el modificador, que tiene los suyos) los define, y el unit_price de siempre cuando no. La cantidad calificadora es la quantity de la propia línea, salvo que el producto tenga pool_tiers: entonces es la suma de las cantidades de todas las líneas del documento cuyo producto también lo tiene. Obligatorio en líneas libres y cuando la moneda del producto no coincide con la del documento. |
unit_code | string | No | Catálogo 03 SUNAT: NIU unidad (por defecto), ZZ servicio, KGM kg, HUR hora, etc. |
affectation | string | No | IGV (catálogo 07): "10" gravado (por defecto) · "20" exonerado · "30" inafecto.Transferencia gratuita (la línea se entrega sin cobrar): "15" bonificación, "14" publicidad/muestra, "11" premio, "12" donación, "13" retiro, "16" entrega a trabajadores (gravadas, el IGV lo asumes tú) · "21" exonerada · "31", "32", "33", "34", "35", "36" (inafectas). En una línea gratuita, unit_price es el precio de referencia —lo que habría costado— y sigue siendo obligatorio y mayor a 0: el comprobante sale con precio 0.00 y ese valor viaja como valor referencial, que es lo que SUNAT exige. No admite isc_rate, disc_rate ni icbper, y solo vale en factura o boleta (en una cotización responde 422). |
isc_rate | number | No | ISC al valor como fracción 0–1 (ej. 0.10 = 10%); solo líneas gravadas. Sobrescribe al producto. El precio sigue siendo final: la base, el ISC y el IGV se derivan. |
disc_rate | number | No | Descuento por línea (catálogo 53 código 00) como fracción 0 ≤ d < 1 (ej. 0.10 = 10%); solo líneas gravadas. Baja la base imponible y el IGV de la línea; se descuenta dentro del valor de venta (no es un descuento global). |
icbper | boolean | No | Afecto a ICBPER (bolsa plástica): suma S/ 0.50 por unidad sobre el precio. Sobrescribe al producto. |
group_title | string | No | Grupo de ítems (sección) al que pertenece la línea: sirve para mandar varios trabajos separados en un solo documento («Servicio web proyecto 1» con 3 líneas, «Servicio ERP proyecto 2» con 2). Repite el mismo título en líneas consecutivas y quedan en un grupo: cada uno se imprime con su cabecera y su subtotal, y el documento sigue teniendo un solo total. Máx. 80 caracteres. Es presentación pura: no llega al XML de SUNAT ni cambia un solo importe. |
group_index | number | No | Ordinal explícito del grupo (1, 2, …). Solo hace falta para separar dos grupos contiguos que compartirían título (o no tienen ninguno). Manda sobre group_title. Al guardar, los índices se renumeran por orden de aparición, así que no hace falta que lleguen contiguos ni desde 1. |
attributed_staff_id | uuid | No | Quién atendió la línea (módulo opcional personal): id de una persona del personal de la empresa, para el informe de desempeño y comisiones. Un id inválido es 400; con el módulo apagado se descarta en silencio. No llega al XML de SUNAT. |
Objeto payment
| Campo | Tipo | Requerido | Descripción |
|---|---|---|---|
type | string | No | "contado" (por defecto) o "credito". |
installments | array | Con credito | Hasta 36 cuotas { "amount": número, "due_date": "YYYY-MM-DD" }. Deben sumar exactamente el total del documento (tolerancia ±0.01) — o el monto neto pendiente de pago (total − detraction.amount) si la factura lleva detraction (RS 193-2020). |
Ejemplo
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": "factura",
"customer": {
"doc_type": "6",
"doc_number": "20512345678",
"name": "ACME PERU S.A.C.",
"email": "facturas@acme.pe"
},
"items": [
{ "code": "CONSULT-HR", "quantity": 10 },
{ "description": "Bolsa ecológica", "quantity": 2, "unit_price": 5.90, "unit_code": "NIU" }
],
"notes": "Orden de compra OC-2026-118"
}'{
"id": "9f1b2c3d-4e5f-4a7b-8c9d-0e1f2a3b4c5d",
"type": "factura",
"doc_type": "01",
"series": "F001",
"number": 42,
"full_number": "F001-42",
"file_name": "20123456789-01-F001-42",
"status": "processing",
"issue_date": "2026-06-09",
"issue_time": "14:32:05",
"due_date": null,
"currency": "PEN",
"customer": {
"docType": "6",
"docNumber": "20512345678",
"name": "ACME PERU S.A.C.",
"email": "facturas@acme.pe"
},
"totals": {
"gravado": "1010.00",
"exonerado": "0.00",
"inafecto": "0.00",
"igv": "181.80",
"isc": "0.00",
"icbper": "0.00",
"descuento": "0.00",
"total_value": "1010.00",
"recargo_consumo": "0.00",
"recargo_rate": null,
"total": "1191.80"
},
"amount_in_words": "MIL CIENTO NOVENTA Y UNO CON 80/100 SOLES",
"payment": { "type": "Contado" },
"notes": "Orden de compra OC-2026-118",
"detraction": null,
"sunat": {
"hash": null,
"settled_at": null,
"cdr_description": null,
"observations": null,
"error_message": null
},
"files": { "pdf": null, "xml": null, "cdr": null, "zip": null },
"items": [
{
"position": 1,
"product_id": "1a2b3c4d-0000-4000-8000-000000000001",
"code": "CONSULT-HR",
"description": "Consultoría por hora",
"unit_code": "HUR",
"quantity": "10",
"unit_price": "118.00",
"unit_value": "100.0000000000",
"line_base": "1000.00",
"line_discount": "0.00",
"disc_rate": null,
"line_igv": "180.00",
"line_isc": "0.00",
"isc_rate": null,
"line_icbper": "0.00",
"line_total": "1180.00",
"affectation": "10"
},
{
"position": 2,
"product_id": null,
"code": null,
"description": "Bolsa ecológica",
"unit_code": "NIU",
"quantity": "2",
"unit_price": "5.90",
"unit_value": "5.0000000000",
"line_base": "10.00",
"line_discount": "0.00",
"disc_rate": null,
"line_igv": "1.80",
"line_isc": "0.00",
"isc_rate": null,
"line_icbper": "0.00",
"line_total": "11.80",
"affectation": "10"
}
],
"source": "api",
"emailed_to": "facturas@acme.pe",
"emailed_at": "2026-06-09T19:32:08.412Z",
"created_at": "2026-06-09T19:32:06.120Z"
}Códigos de estado
| HTTP | Significado |
|---|---|
| 202 | Documento creado y encolado. status es processing: el número ya está consumido y el veredicto llega después. Location apunta a su detalle. |
| 201 | Solo con Prefer: wait, cuando el veredicto llegó dentro del plazo. status es accepted, rejected o error (el veredicto de SUNAT viene en sunat.cdr_description). Un rechazo también responde 201: el documento existe. |
| 200 | Replay idempotente — la clave ya se usó; se devuelve el documento original con la cabecera Idempotent-Replay: true, en el estado que tenga (processing incluido). |
Errores
| HTTP | Código | Cuándo |
|---|---|---|
| 400 | invalid_json | El cuerpo no es JSON válido. |
| 400 | validation | Datos inválidos (con details por campo): fechas fuera de rango, cuotas que no suman el total, cantidad con más de 3 decimales, etc. |
| 400 | customer_invalid | Cliente inconsistente: factura sin RUC, boleta con RUC, documento de identidad inválido o cliente inexistente. |
| 402 | plan_limit | Límite mensual del plan alcanzado. details trae used, limit y plan. |
| 409 | company_not_ready | La empresa aún no completó el onboarding con el PSE. |
| 422 | series_not_found | La serie no existe, está inactiva o su prefijo no corresponde al tipo. |
| 422 | product_not_found | Un ítem referencia un product_id o code inexistente o inactivo. |
| 422 | modifier_not_found | Un ítem referencia un modifier_id inexistente, inactivo, de otro producto o de otra empresa. |
| 422 | extra_not_found | Un ítem referencia en extra_ids un extra inexistente, inactivo, de otro producto o de otra empresa. Un extra que existe pero no se ofrece para el modificador elegido es 400 validation. |
| 500 | internal | Error interno inesperado. |
| 401 | invalid_token | Token ausente, mal formado, revocado o de una empresa eliminada. |
| 403 | insufficient_scope | El token no tiene el alcance (scope) que exige el endpoint. |
| 403 | module_not_enabled | Tu empresa no tiene habilitado el módulo del endpoint (p. ej. Cotizaciones). |
| 429 | rate_limited | Más de 120 solicitudes por minuto con el mismo token. |
Ya no hay un 502 en la emisión. Un fallo del PSE ya no ocurre dentro del request: se reintenta con backoff y, si se agota, el documento queda en status: "error" (sin consumir tu plan) y puede reenviarse con su mismo número.
/api/v1/documentsdocuments:readLista documentos, los más recientes primero. Sin los ítems de línea (pídelos por id).
| Parámetro | Descripción |
|---|---|
from | Fecha de emisión mínima, YYYY-MM-DD (inclusive). |
to | Fecha de emisión máxima, YYYY-MM-DD (inclusive). |
type | factura o boleta. |
status | accepted · rejected · error · processing. |
q | Búsqueda libre por número completo, nombre o documento del cliente (máx. 100 caracteres). |
page | Página, desde 1. |
per_page | Por defecto 25, máximo 100. |
curl "https://enbloques.com/api/v1/documents?from=2026-06-01&to=2026-06-30&type=boleta&status=accepted&per_page=50" \
-H "Authorization: Bearer sk_live_..."{
"data": [
{
"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": "11:02:44",
"due_date": null,
"currency": "PEN",
"customer": { "docType": "0", "docNumber": "0", "name": "CLIENTES VARIOS" },
"totals": { "gravado": "100.00", "exonerado": "0.00", "inafecto": "0.00",
"igv": "18.00", "isc": "0.00", "icbper": "0.00", "descuento": "0.00", "total_value": "100.00",
"recargo_consumo": "0.00", "recargo_rate": null, "total": "118.00" },
"amount_in_words": "CIENTO DIECIOCHO CON 00/100 SOLES",
"payment": { "type": "Contado" },
"notes": null,
"sunat": { "hash": "xY9z...=", "cdr_description": "La Boleta numero B001-117, ha sido aceptada",
"observations": null, "error_message": null },
"files": { "pdf": "/api/v1/documents/9f1b.../pdf", "xml": "/api/v1/documents/9f1b.../xml",
"cdr": "/api/v1/documents/9f1b.../cdr", "zip": "/api/v1/documents/9f1b.../zip" },
"source": "api",
"emailed_to": null,
"emailed_at": null,
"created_at": "2026-06-09T16:02:45.001Z"
}
],
"page": 1,
"per_page": 50,
"total": 1
}| HTTP | Código | Cuándo |
|---|---|---|
| 401 | invalid_token | Token ausente, mal formado, revocado o de una empresa eliminada. |
| 403 | insufficient_scope | El token no tiene el alcance (scope) que exige el endpoint. |
| 403 | module_not_enabled | Tu empresa no tiene habilitado el módulo del endpoint (p. ej. Cotizaciones). |
| 429 | rate_limited | Más de 120 solicitudes por minuto con el mismo token. |
/api/v1/documents/{id}documents:readDevuelve un documento por su UUID, incluyendo el arreglo items (misma forma que la respuesta de creación).
curl https://enbloques.com/api/v1/documents/9f1b2c3d-4e5f-4a7b-8c9d-0e1f2a3b4c5d \
-H "Authorization: Bearer sk_live_..."| HTTP | Código | Cuándo |
|---|---|---|
| 404 | not_found | El documento no existe o pertenece a otra empresa. |
| 401 | invalid_token | Token ausente, mal formado, revocado o de una empresa eliminada. |
| 403 | insufficient_scope | El token no tiene el alcance (scope) que exige el endpoint. |
| 403 | module_not_enabled | Tu empresa no tiene habilitado el módulo del endpoint (p. ej. Cotizaciones). |
| 429 | rate_limited | Más de 120 solicitudes por minuto con el mismo token. |
Archivos mientras el comprobante está en proceso
El PDF se sirve desde que el comprobante existe, processing incluido: se renderiza de los datos guardados y su QR ya está completo, porque el Valor Resumen del QR es un hash del XML que construye Bloques y no un dato que devuelva SUNAT. Es lo que permite imprimir y entregar el comprobante en el acto. files.pdf viaja desde el primer momento.
Los otros tres (/xml, /cdr, /zip) responden 409 document_processing mientras status sea processing, y van en null: son archivos que el PSE todavía no devolvió. Es 409 y no 404 a propósito — el documento existe, lo que no existe todavía es su veredicto. Cierra en segundos; consulta GET /api/v1/documents/{id} y vuelve a pedirlo.
/api/v1/documents/{id}/pdfdocuments:readDescarga el PDF imprimible (application/pdf, adjunto {file_name}.pdf).
| Parámetro | Descripción |
|---|---|
template | Opcional: re-renderiza al vuelo con otra plantilla — lino, clasica, moderna, minimal, oscura o ticket80. Sin el parámetro se sirve el PDF de la plantilla por defecto de la empresa (cacheado desde que el comprobante cierra; en processing se re-renderiza en cada pedido). |
curl -L -o F001-42.pdf \
"https://enbloques.com/api/v1/documents/9f1b2c3d-4e5f-4a7b-8c9d-0e1f2a3b4c5d/pdf?template=ticket80" \
-H "Authorization: Bearer sk_live_..."| HTTP | Código | Cuándo |
|---|---|---|
| 404 | not_found | El documento no existe o pertenece a otra empresa. |
| 500 | pdf_failed | No se pudo generar el PDF. |
| 401 | invalid_token | Token ausente, mal formado, revocado o de una empresa eliminada. |
| 403 | insufficient_scope | El token no tiene el alcance (scope) que exige el endpoint. |
| 403 | module_not_enabled | Tu empresa no tiene habilitado el módulo del endpoint (p. ej. Cotizaciones). |
| 429 | rate_limited | Más de 120 solicitudes por minuto con el mismo token. |
/api/v1/documents/{id}/xmldocuments:readDescarga el XML firmado (application/xml, {file_name}.xml) — el archivo con valor legal. Para bajar el XML firmado y el CDR en un solo archivo, usa /documents/{id}/zip.
| Parámetro | Descripción |
|---|---|
unsigned | true devuelve el XML UBL crudo sin firmar (application/xml, {file_name}-sin-firmar.xml), útil para depurar. |
| HTTP | Código | Cuándo |
|---|---|---|
| 404 | not_found | El documento no existe o pertenece a otra empresa. |
| 409 | document_processing | SmartPSE aún no devolvió este archivo; el comprobante no tiene veredicto. |
| 404 | file_not_found | El archivo no está disponible (p. ej. el documento nunca llegó a firmarse). |
| 401 | invalid_token | Token ausente, mal formado, revocado o de una empresa eliminada. |
| 403 | insufficient_scope | El token no tiene el alcance (scope) que exige el endpoint. |
| 403 | module_not_enabled | Tu empresa no tiene habilitado el módulo del endpoint (p. ej. Cotizaciones). |
| 429 | rate_limited | Más de 120 solicitudes por minuto con el mismo token. |
/api/v1/documents/{id}/cdrdocuments:readDescarga el CDR de SUNAT (constancia de recepción) como XML (application/xml, {file_name}-cdr.xml). Solo existe cuando SUNAT respondió (documentos accepted o rejected); en la respuesta JSON, files.cdr es null cuando no hay CDR.
| HTTP | Código | Cuándo |
|---|---|---|
| 404 | not_found | El documento no existe o pertenece a otra empresa. |
| 409 | document_processing | SmartPSE aún no devolvió este archivo; el comprobante no tiene veredicto. |
| 404 | file_not_found | Este documento no tiene CDR. |
| 401 | invalid_token | Token ausente, mal formado, revocado o de una empresa eliminada. |
| 403 | insufficient_scope | El token no tiene el alcance (scope) que exige el endpoint. |
| 403 | module_not_enabled | Tu empresa no tiene habilitado el módulo del endpoint (p. ej. Cotizaciones). |
| 429 | rate_limited | Más de 120 solicitudes por minuto con el mismo token. |
/api/v1/documents/{id}/zipdocuments:readDescarga el XML firmado y el CDR juntos en un ZIP (application/zip, {file_name}.zip), con {file_name}.xml y {file_name}-cdr.xml dentro. Si solo existe uno de los dos —un documento rechazado se firmó pero no tiene CDR— el ZIP trae ese. Es la descarga que ofrece la app.
| HTTP | Código | Cuándo |
|---|---|---|
| 404 | not_found | El documento no existe o pertenece a otra empresa. |
| 409 | document_processing | SmartPSE aún no devolvió este archivo; el comprobante no tiene veredicto. |
| 404 | file_not_found | Este documento no tiene XML firmado ni CDR. |
| 401 | invalid_token | Token ausente, mal formado, revocado o de una empresa eliminada. |
| 403 | insufficient_scope | El token no tiene el alcance (scope) que exige el endpoint. |
| 403 | module_not_enabled | Tu empresa no tiene habilitado el módulo del endpoint (p. ej. Cotizaciones). |
| 429 | rate_limited | Más de 120 solicitudes por minuto con el mismo token. |
/api/v1/documents/{id}/resenddocuments:writeCorrige y reenvía un documento que SUNAT rechazó (status:"rejected") o que no llegó a transmitirse (status:"error"), reutilizando el mismo serie-correlativo. Un comprobante rechazado no existe legalmente, así que su número puede reusarse; uno accepted es inmutable —reenviarlo daría el error 1033 de SUNAT («el comprobante ya fue informado»)— y se bloquea con 409 not_resendable.
El cuerpo es el documento corregido, con la misma forma que POST /documents; se ignoran type y series (la identidad del documento es fija por su id). Se vuelven a correr todas las validaciones. La cuota se comporta como en una primera emisión: un reenvío que termina accepted consume una unidad del plan y, si vuelve a rechazarse, la devuelve (solo los accepted cuentan). Como la emisión, encola: devuelve 202 con el documento en processing, y honra Prefer: wait=N (entonces es 200 con el veredicto ya puesto).
curl -X POST https://enbloques.com/api/v1/documents/9f1b2c3d-4e5f-4a7b-8c9d-0e1f2a3b4c5d/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 } ]
}'| HTTP | Código | Cuándo |
|---|---|---|
| 400 | invalid_json | El cuerpo no es JSON válido. |
| 400 | validation | Datos inválidos (con details por campo). |
| 400 | customer_invalid | Cliente inconsistente: factura sin RUC, boleta con RUC o documento inválido. |
| 402 | plan_limit | Límite mensual del plan alcanzado. details trae used, limit y plan. |
| 404 | not_found | El documento no existe o pertenece a otra empresa. |
| 409 | not_resendable | El documento ya fue aceptado (SUNAT 1033) o está en proceso; su número no puede reusarse. |
| 409 | company_not_ready | La empresa aún no completó el onboarding con el PSE. |
| 422 | series_not_found | La serie no existe, está inactiva o su prefijo no corresponde al tipo. |
| 422 | product_not_found | Un ítem referencia un product_id o code inexistente o inactivo. |
| 422 | modifier_not_found | Un ítem referencia un modifier_id inexistente, inactivo, de otro producto o de otra empresa. |
| 422 | extra_not_found | Un ítem referencia en extra_ids un extra inexistente, inactivo, de otro producto o de otra empresa. Un extra que existe pero no se ofrece para el modificador elegido es 400 validation. |
| 500 | internal | Error interno inesperado. |
| 401 | invalid_token | Token ausente, mal formado, revocado o de una empresa eliminada. |
| 403 | insufficient_scope | El token no tiene el alcance (scope) que exige el endpoint. |
| 403 | module_not_enabled | Tu empresa no tiene habilitado el módulo del endpoint (p. ej. Cotizaciones). |
| 429 | rate_limited | Más de 120 solicitudes por minuto con el mismo token. |
/api/v1/documents/exportdocuments:readExporta documentos a CSV (UTF-8 con BOM, separado por comas) con los mismos filtros del listado (from, to, type, status, q). Máximo 5000 filas, las más recientes primero. Ideal para conciliaciones o para tu contador.
Columnas:
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, idcurl -L -o documentos-junio.csv \
"https://enbloques.com/api/v1/documents/export?from=2026-06-01&to=2026-06-30" \
-H "Authorization: Bearer sk_live_..."| HTTP | Código | Cuándo |
|---|---|---|
| 401 | invalid_token | Token ausente, mal formado, revocado o de una empresa eliminada. |
| 403 | insufficient_scope | El token no tiene el alcance (scope) que exige el endpoint. |
| 403 | module_not_enabled | Tu empresa no tiene habilitado el módulo del endpoint (p. ej. Cotizaciones). |
| 429 | rate_limited | Más de 120 solicitudes por minuto con el mismo token. |
Cotizaciones
Una cotización es un documento con precios que nunca se envía a SUNAT: sin UBL, sin firma, sin correlativo y no consume el límite del plan. Cada una recibe una referencia secuencial COT-0001 y puede convertirse luego en un comprobante real emitiendo un documento con from_quote_id. El cuerpo reutiliza los objetos customer, item y payment de los documentos, pero sin type, series ni send_email, y añade title y valid_until. El status derivado es open, expired (cuando valid_until ya pasó, calendario Lima) o converted.
/api/v1/quotesquotes:writeCrea una cotización. Devuelve 201 con la cotización y sus ítems.
| Campo | Tipo | Requerido | Descripción |
|---|---|---|---|
title | string | No | Título de la propuesta (máx. 120). Encabeza el PDF y el detalle; se busca con q. En PUT, omitirlo lo borra. |
issue_date | string | No | YYYY-MM-DD. Por defecto hoy (Lima). Puede ser futura o pasada. |
valid_until | string | No | YYYY-MM-DD. Fecha límite de validez, impresa en el PDF. |
currency | "PEN" | "USD" | No | Por defecto PEN. |
customer | object | No | Mismo objeto que en documentos. Vacío → CLIENTES VARIOS. |
items | item[] | Sí | 1 a 100 líneas. Precios finales con IGV incluido. |
payment | object | No | Mismo objeto payment que en documentos. |
notes | string | No | Texto libre impreso en el PDF. |
recargo_consumo | object | No | Recargo al consumo (sin IGV, se suma al total). |
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 }]
}'| HTTP | Código | Cuándo |
|---|---|---|
| 401 | invalid_token | Token ausente, mal formado, revocado o de una empresa eliminada. |
| 403 | insufficient_scope | El token no tiene el alcance (scope) que exige el endpoint. |
| 403 | module_not_enabled | Tu empresa no tiene habilitado el módulo del endpoint (p. ej. Cotizaciones). |
| 429 | rate_limited | Más de 120 solicitudes por minuto con el mismo token. |
/api/v1/quotesquotes:readLista cotizaciones (más recientes primero) con filtros y paginación.
| Parámetro | Descripción |
|---|---|
from / to | Rango por issue_date (YYYY-MM-DD). |
converted | true o false para filtrar por estado de conversión. |
q | Busca por referencia (COT-…), título, nombre o documento del cliente. |
page / per_page | Paginación estándar (máx. 100). |
| HTTP | Código | Cuándo |
|---|---|---|
| 401 | invalid_token | Token ausente, mal formado, revocado o de una empresa eliminada. |
| 403 | insufficient_scope | El token no tiene el alcance (scope) que exige el endpoint. |
| 403 | module_not_enabled | Tu empresa no tiene habilitado el módulo del endpoint (p. ej. Cotizaciones). |
| 429 | rate_limited | Más de 120 solicitudes por minuto con el mismo token. |
/api/v1/quotes/{id}quotes:readDevuelve una cotización con sus ítems, totales, estado derivado y la ruta del PDF.
| HTTP | Código | Cuándo |
|---|---|---|
| 404 | not_found | La cotización no existe o es de otra empresa. |
| 401 | invalid_token | Token ausente, mal formado, revocado o de una empresa eliminada. |
| 403 | insufficient_scope | El token no tiene el alcance (scope) que exige el endpoint. |
| 403 | module_not_enabled | Tu empresa no tiene habilitado el módulo del endpoint (p. ej. Cotizaciones). |
| 429 | rate_limited | Más de 120 solicitudes por minuto con el mismo token. |
/api/v1/quotes/{id}quotes:writeReemplaza el contenido de una cotización editable (su code se conserva). El mismo cuerpo que POST /quotes. Falla si la cotización ya fue convertida.
| HTTP | Código | Cuándo |
|---|---|---|
| 404 | not_found | La cotización no existe. |
| 400 | validation | Ya convertida o datos inválidos. |
| 401 | invalid_token | Token ausente, mal formado, revocado o de una empresa eliminada. |
| 403 | insufficient_scope | El token no tiene el alcance (scope) que exige el endpoint. |
| 403 | module_not_enabled | Tu empresa no tiene habilitado el módulo del endpoint (p. ej. Cotizaciones). |
| 429 | rate_limited | Más de 120 solicitudes por minuto con el mismo token. |
/api/v1/quotes/{id}quotes:writeElimina una cotización. Falla con 409 si ya fue convertida en comprobante.
| HTTP | Código | Cuándo |
|---|---|---|
| 404 | not_found | La cotización no existe. |
| 409 | validation | Ya convertida — no se puede eliminar. |
| 401 | invalid_token | Token ausente, mal formado, revocado o de una empresa eliminada. |
| 403 | insufficient_scope | El token no tiene el alcance (scope) que exige el endpoint. |
| 403 | module_not_enabled | Tu empresa no tiene habilitado el módulo del endpoint (p. ej. Cotizaciones). |
| 429 | rate_limited | Más de 120 solicitudes por minuto con el mismo token. |
/api/v1/quotes/{id}/pdfquotes:readDescarga el PDF de la cotización (se genera al vuelo, no se cachea). ?template=clasica (por defecto), ?template=minimal, ?template=lino o ?template=oscura.
curl -L -o COT-0001.pdf \
"https://enbloques.com/api/v1/quotes/{id}/pdf?template=minimal" \
-H "Authorization: Bearer sk_live_..."| HTTP | Código | Cuándo |
|---|---|---|
| 404 | not_found | La cotización no existe. |
| 401 | invalid_token | Token ausente, mal formado, revocado o de una empresa eliminada. |
| 403 | insufficient_scope | El token no tiene el alcance (scope) que exige el endpoint. |
| 403 | module_not_enabled | Tu empresa no tiene habilitado el módulo del endpoint (p. ej. Cotizaciones). |
| 429 | rate_limited | Más de 120 solicitudes por minuto con el mismo token. |
Convertir una cotización en comprobante
Para convertir, emite un documento normal (POST /api/v1/documents) incluyendo from_quote_id. Si la emisión es aceptada por SUNAT, la cotización se marca como converted (idempotente; una emisión rechazada o con error no la convierte). Reconstruye los items y el customer a partir de la cotización (ver GET /quotes/{id}) y añade lo propio de SUNAT (type, serie, etc.).
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": "9f1b2c3d-4e5f-4a7b-8c9d-0e1f2a3b4c5d",
"customer": { "doc_type": "6", "doc_number": "20123456789", "name": "CLIENTE S.A.C." },
"items": [{ "description": "Consultoría", "quantity": 1, "unit_price": 1180 }]
}'Productos
/api/v1/productsproducts:writeCrea un producto del catálogo.
| Campo | Tipo | Requerido | Descripción |
|---|---|---|---|
code | string | Sí | Tu código interno, único por empresa. Máx. 50. |
name | string | Sí | Máx. 300. |
description | string | No | Máx. 1000. |
unit_code | string | No | Catálogo 03. Por defecto NIU. |
unit_price | number | Sí | Precio FINAL — lo que paga el cliente, con IGV incluido cuando es gravado. |
currency | string | No | PEN (por defecto) o USD. |
affectation | string | No | "10" (por defecto) · "20" · "30". |
isc_rate | number | No | ISC al valor por defecto como fracción 0–1 (ej. 0.10); solo productos gravados. |
icbper | boolean | No | Marca ICBPER por defecto (bolsa plástica) para las líneas que usen este producto. |
track_stock | boolean | No | Si el módulo de inventario (cuando está activo) controla stock de este producto. Por defecto true. |
min_stock | number | No | Umbral de alerta de stock bajo (suma entre almacenes); omitido = sin alerta. |
barcode | string | No | Código de barras (EAN del fabricante o interno). Único por empresa. |
cost | number | No | Último costo de compra, FINAL (IGV incluido). Informativo. |
active | boolean | No | Por defecto true. |
price_tiers | array | No | Precios por cantidad: desde min_quantity unidades, el precio unitario final es unit_price. Máx. 20. Son precios absolutos, no descuentos. Ver la sección de abajo. |
pool_tiers | boolean | No | Cuenta global de escalones. Con true, la cantidad que consulta sus price_tiers es la suma de las líneas del documento cuyo producto también lo tiene (7 shorts + 5 polos = 12), cada uno con su propia escalera. Por defecto false: cada línea califica con su propia cantidad. Ver la sección de abajo. |
images | array | No | Hasta 3 imágenes del producto, en orden (la primera es la portada). Viaja la referencia, no los bytes: la imagen se sube antes por POST /api/products/images y acá va tal cual lo que esa ruta devuelve. Ver la sección de abajo. |
is_public | boolean | No | Visible en el perfil público de la empresa (módulo perfil-publico). Por defecto false: publicar un producto es un acto explícito. |
modifiers | array | No | Variaciones del producto con precio propio (talla, acabado, público). Máx. 50. Cada entrada: name (obligatorio, máx. 150), code (opcional, máx. 50) y unit_price (FINAL absoluto, con IGV incluido cuando el producto es gravado, en la moneda del producto — nunca un delta) y, opcionalmente, sus propios price_tiers. El orden del array es el orden mostrado. Ver la semántica de sincronización más abajo. |
extras | array | No | Extras del producto: no es otro producto, es el mismo con algo encima («Manga larga +4.00»). El monto se suma al precio resuelto de la línea y nunca compite con escalones ni acuerdos. Máx. 30, sincronizados preservando ids como modifiers. Cada entrada: name (máx. 100), default_amount (monto FINAL por unidad; 0 = gratis) y, opcionalmente, prices por modificador — un array alineado con modifiers del mismo request o un objeto por id — donde 0 = gratis y null = no se ofrece. En las respuestas prices es siempre el objeto por id. |
Precios por cantidad
Un producto —o un modificador— puede llevar hasta 20 escalones: desde min_quantity unidades, el precio unitario final es unit_price. Son precios absolutos, no descuentos, y la aritmética fiscal no cambia (la base se sigue derivando del precio final). Gana el escalón más alto que la cantidad calificadora de la línea alcanza —su propia quantity, o la de la cuenta global cuando el producto tiene pool_tiers (ver abajo)—; por debajo del primero rige el unit_price del producto. El orden del array no importa y dos escalones no pueden compartir la misma min_quantity.
Los escalones solo entran cuando el ítem omite unit_price: un precio enviado gana siempre. Tampoco se exige que la tabla baje — puedes cobrar más a mayor cantidad si tu negocio lo necesita. Omitir price_tiers al actualizar no toca nada; [] los limpia. En las respuestas el campo está siempre presente ([] cuando no hay) y sus valores son strings, como todo importe de esta API. Un modificador usa los suyos y nunca hereda los del padre.
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 } ] }'Cuenta global de escalones
Un producto puede tener encendida la cuenta global de escalones (pool_tiers), que hace que varios productos alcancen juntos los escalones de arriba. La cuenta nunca cambia qué tabla se lee —cada producto se cobra siempre con la suya—, solo con qué cantidad se consulta:
| Campo | Tipo | Requerido | Descripción |
|---|---|---|---|
pool_tiers: false | — | — | La cantidad calificadora es la quantity de la propia línea — el comportamiento de siempre, sin cambios. |
pool_tiers: true | — | — | La cantidad calificadora es la suma de las cantidades de todas las líneas del documento cuyo producto también tiene pool_tiers. Hay una sola cuenta por empresa: el campo no dice con quién suma un producto, suma con todos los marcados. |
Con shorts a S/ 10 (desde 12 → S/ 9) y polos a S/ 15 (desde 12 → S/ 14), los dos con la cuenta encendida, un pedido de 7 shorts + 5 polos = 12 prendas se cobra a S/ 9 y S/ 14, cada uno leyendo su propia tabla.
Reglas que conviene saber antes de integrar: toda línea con producto identificado y cuenta encendida aporta su cantidad, sin importar cómo se fijó su precio —enviar unit_price conserva ese precio pero la línea igual cuenta—, y lo único que no aporta es una línea libre (sin producto). Un modificador no tiene cuenta propia: hereda la del producto y sigue usando sus escalones. Las líneas sin cuenta no suman entre sí: cada una se mira sola. Un producto con cuenta pero sin escalones propios aporta su cantidad y se cobra a su unit_price de siempre. En el PUT parcial el campo tiene dos estados: omitirlo no toca nada y un booleano lo enciende o apaga. La nota de crédito no vuelve a tarifar —copia los precios del comprobante original—, así que devolver 5 de 12 prendas las acredita al precio del escalón y las 7 que quedan lo conservan.
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, los dos con pool_tiers y sin unit_price en los ítems
# → 12 unidades calificadoras: los shorts a S/ 9.00 y los polos a 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 } ] }'Imágenes del producto
Un producto puede llevar hasta 3 imágenes. El orden del array es el orden y la primera es la portada (la miniatura que se ve en el catálogo de la app). Lo que viaja por esta API es la referencia, nunca los bytes:
"images": [
{
"key": "products/{company_id}/{product_id}/9f2c…-polo.webp",
"name": "polo-azul.webp",
"content_type": "image/webp",
"bytes": 48213
}
]La subida es solo con sesión de navegador. Los bytes van a POST /api/products/images (multipart, campo file, con product_id opcional), que los guarda en R2 y devuelve exactamente el objeto de arriba — eso es lo que se reenvía dentro de images. La app convierte cada foto a WebP en el navegador antes de subirla, porque la API corre en Cloudflare Workers y no tiene códec de imagen. No hay ruta v1 para la subida y un token no puede hacerla; leer los bytes (GET /api/products/images?key=…) también es solo sesión, y por eso este contrato no promete ninguna URL.
La key se comprueba al escribir, no solo al servir: una clave que no vive bajo el prefijo de tu propia empresa devuelve 422 invalid_image_key — es el único aislamiento entre empresas que tiene R2. Se aceptan PNG, JPEG y WebP, decididos por magic number (no por el tipo declarado), 5 MB máximo por archivo; el PDF se rechaza. En el PUT, images es reemplazo completo: omitirlo no toca nada, [] deja el producto sin imágenes y cualquier array reemplaza la lista entera. Quitar una imagen del array no borra el objeto de R2.
Modificadores: cómo se sincronizan
Los modificadores no tienen rutas propias: se gestionan dentro del payload del producto, en un solo request atómico. Al crear o actualizar, el array modifiers se sincroniza preservando ids:
| Campo | Tipo | Requerido | Descripción |
|---|---|---|---|
con id | — | — | Actualiza ese modificador. El id sobrevive a los cambios de nombre y precio, así que las líneas ya emitidas lo siguen referenciando. |
sin id | — | — | Crea un modificador nuevo. |
ausente del array | — | — | Un modificador activo que ya no aparece se desactiva (borrado suave: la fila sobrevive para la trazabilidad). |
campo omitido | — | — | Omitir modifiers no toca ningún modificador; [] los desactiva todos. |
La position no se envía: es el índice en el array. Los nombres son únicos por producto entre los modificadores activos, y los code no nulos también. Toda respuesta de producto incluye modifiers (array vacío cuando no hay).
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 }'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 }
]
}'{
"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,
"is_public": false,
"active": true,
"modifiers": [],
"created_at": "2026-06-09T15:00:00.000Z",
"updated_at": "2026-06-09T15:00:00.000Z"
}{
"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-0000-4000-8000-000000000001", "name": "Cuello camisero · Adulto", "code": "CAM-AD", "unit_price": "32.00", "position": 0, "active": true },
{ "id": "aaaa1111-0000-4000-8000-000000000002", "name": "Cuello rib · Adulto", "code": "RIB-AD", "unit_price": "28.00", "position": 1, "active": true },
{ "id": "aaaa1111-0000-4000-8000-000000000003", "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"
}| HTTP | Código | Cuándo |
|---|---|---|
| 400 | invalid_json | El cuerpo no es JSON válido. |
| 400 | validation | Campos inválidos (incluye un modificador cuyo id no pertenece a este producto). |
| 409 | duplicate_code | Ya existe un producto con ese código. |
| 409 | duplicate_barcode | Ya existe un producto con ese código de barras. |
| 409 | duplicate_modifier_name | El producto ya tiene un modificador activo con ese nombre. |
| 409 | duplicate_modifier_code | El producto ya tiene un modificador activo con ese código. |
| 401 | invalid_token | Token ausente, mal formado, revocado o de una empresa eliminada. |
| 403 | insufficient_scope | El token no tiene el alcance (scope) que exige el endpoint. |
| 403 | module_not_enabled | Tu empresa no tiene habilitado el módulo del endpoint (p. ej. Cotizaciones). |
| 429 | rate_limited | Más de 120 solicitudes por minuto con el mismo token. |
/api/v1/productsproducts:readLista el catálogo (solo activos por defecto), ordenado por código.
| Parámetro | Descripción |
|---|---|
q | Búsqueda por nombre o código. |
include_inactive | true incluye productos desactivados. |
page | Página, desde 1. |
per_page | Por defecto 50, máximo 100. |
Cada producto de la página trae su arreglo modifiers (vacío cuando no tiene), resuelto en la misma consulta: no hace falta un segundo request para descubrirlas. También trae su pool_tiers (cuenta global de escalones).
{ "data": [ { "id": "…", "code": "CAFE-250", "modifiers": [], … } ], "page": 1, "per_page": 50, "total": 12 }| HTTP | Código | Cuándo |
|---|---|---|
| 401 | invalid_token | Token ausente, mal formado, revocado o de una empresa eliminada. |
| 403 | insufficient_scope | El token no tiene el alcance (scope) que exige el endpoint. |
| 403 | module_not_enabled | Tu empresa no tiene habilitado el módulo del endpoint (p. ej. Cotizaciones). |
| 429 | rate_limited | Más de 120 solicitudes por minuto con el mismo token. |
/api/v1/products/{id}products:readDevuelve un producto por UUID, incluyendo sus modifiers activas ordenadas por position.
| HTTP | Código | Cuándo |
|---|---|---|
| 404 | not_found | El producto no existe o pertenece a otra empresa. |
| 401 | invalid_token | Token ausente, mal formado, revocado o de una empresa eliminada. |
| 403 | insufficient_scope | El token no tiene el alcance (scope) que exige el endpoint. |
| 403 | module_not_enabled | Tu empresa no tiene habilitado el módulo del endpoint (p. ej. Cotizaciones). |
| 429 | rate_limited | Más de 120 solicitudes por minuto con el mismo token. |
/api/v1/products/{id}products:writeActualización parcial: envía solo los campos a cambiar (mismos campos y reglas que la creación; todos opcionales; description admite null para limpiar). Los documentos ya emitidos conservan su snapshot. Mandar modifiers sincroniza la lista completa con la semántica de arriba (con id actualiza, sin id crea, ausente desactiva); omitirlo no toca ninguna. pool_tiers tiene dos estados: omitirlo no toca la cuenta global y un booleano la enciende o apaga — si tu cliente guarda el producto entero, mándalo explícito.
curl -X PUT https://enbloques.com/api/v1/products/1a2b3c4d-0000-4000-8000-000000000001 \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{ "unit_price": 38.00 }'# El modificador omitida del array se desactiva; la que lleva id conserva su id.
curl -X PUT https://enbloques.com/api/v1/products/2b3c4d5e-0000-4000-8000-000000000002 \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"modifiers": [
{ "id": "aaaa1111-0000-4000-8000-000000000001", "name": "Cuello camisero · Adulto", "code": "CAM-AD", "unit_price": 34.00 },
{ "id": "aaaa1111-0000-4000-8000-000000000002", "name": "Cuello rib · Adulto", "code": "RIB-AD", "unit_price": 28.00 }
]
}'| HTTP | Código | Cuándo |
|---|---|---|
| 400 | invalid_json | El cuerpo no es JSON válido. |
| 400 | validation | Campos inválidos (incluye un modificador cuyo id no pertenece a este producto). |
| 404 | not_found | El producto no existe o pertenece a otra empresa. |
| 409 | duplicate_code | El nuevo código ya pertenece a otro producto. |
| 409 | duplicate_barcode | El nuevo código de barras ya pertenece a otro producto. |
| 409 | duplicate_modifier_name | El producto ya tiene un modificador activo con ese nombre. |
| 409 | duplicate_modifier_code | El producto ya tiene un modificador activo con ese código. |
| 401 | invalid_token | Token ausente, mal formado, revocado o de una empresa eliminada. |
| 403 | insufficient_scope | El token no tiene el alcance (scope) que exige el endpoint. |
| 403 | module_not_enabled | Tu empresa no tiene habilitado el módulo del endpoint (p. ej. Cotizaciones). |
| 429 | rate_limited | Más de 120 solicitudes por minuto con el mismo token. |
/api/v1/products/{id}products:writeBorrado suave: marca el producto como inactivo (active: false) y responde 200 con el producto actualizado. Deja de aparecer en listados y de poder usarse en emisiones; el historial no se toca. Reactívalo con PUT { "active": true }.
| HTTP | Código | Cuándo |
|---|---|---|
| 404 | not_found | El producto no existe o pertenece a otra empresa. |
| 401 | invalid_token | Token ausente, mal formado, revocado o de una empresa eliminada. |
| 403 | insufficient_scope | El token no tiene el alcance (scope) que exige el endpoint. |
| 403 | module_not_enabled | Tu empresa no tiene habilitado el módulo del endpoint (p. ej. Cotizaciones). |
| 429 | rate_limited | Más de 120 solicitudes por minuto con el mismo token. |
Clientes
/api/v1/customerscustomers:writeCrea un cliente en el directorio. name es lo único obligatorio. Sin número se guarda como tipo "0" y doc_number null (ficha para nota de venta o cotización) — y esas fichas nunca chocan: cada POST sin número crea una nueva. Un tipo sin su número, un número sin su tipo o un "0" con número son 400. Si ya existe uno con el mismo doc_type + doc_number, nombre, email, teléfono y dirección se sobrescriben con lo enviado (responde 201 igualmente). custom_fields es la excepción: ausente se preserva lo guardado; presente se valida contra las definiciones activas de tu empresa (Configuración → Campos) y reemplaza el objeto completo. Nota: emitir un documento con datos de cliente en línea también lo registra/actualiza — y nunca toca los campos personalizados.
| Campo | Tipo | Requerido | Descripción |
|---|---|---|---|
doc_type | string | No | "1" DNI · "4" CE · "6" RUC · "7" pasaporte · "0" sin documento. Omítelo (sin número) para una ficha solo con nombre. |
doc_number | string | No | Validado según el tipo (RUC: dígito verificador; DNI: 8 dígitos). Omítelo para una ficha sin documento; el tipo "0" no lleva número. |
name | string | Sí | Máx. 500. |
nickname | string | No | Apodo del día a día («el Chino», «la bodega de la esquina»), máx. 120. Vive solo en el directorio: no se copia al comprobante ni llega al XML de SUNAT. Se busca por él acá y en las listas de ventas y cotizaciones de la app. |
email | string | No | Email válido, máx. 320. |
phone | string | No | Dígitos, +, (), espacios y guiones; 6–20 caracteres. |
address | string | No | Máx. 500. |
custom_fields | object | No | Campos propios de tu empresa, por clave de definición. Valores escalares JSON (fechas como "AAAA-MM-DD"; null o "" borra la clave). Requiere el módulo Campos personalizados. |
agreement_id | uuid | No | Acuerdo de precio del cliente: con qué precios se le vende. Toda línea de catálogo de una venta que lo identifique se tarifa con él, y el acuerdo gana al catálogo aunque el catálogo sea más barato (un unit_price explícito sigue ganándole a todo). Omitido preserva el que tuviera; null lo deja en «Público — sin acuerdo». Un acuerdo ajeno, inexistente o archivado dan el mismo 404. Los acuerdos se crean y editan en la app (Catálogo → Acuerdos), no por esta API. |
curl -X POST https://enbloques.com/api/v1/customers \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{ "doc_type": "6", "doc_number": "20512345678", "name": "ACME PERU S.A.C.", "email": "compras@acme.pe" }'{
"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"
}| HTTP | Código | Cuándo |
|---|---|---|
| 400 | invalid_json | El cuerpo no es JSON válido. |
| 400 | validation | Campos o documento de identidad inválidos. |
| 400 | custom_fields_invalid | Campos personalizados inválidos (detalle por clave en details). |
| 403 | module_not_enabled | Tu plan no incluye el módulo Campos personalizados (solo si envías custom_fields). |
| 401 | invalid_token | Token ausente, mal formado, revocado o de una empresa eliminada. |
| 403 | insufficient_scope | El token no tiene el alcance (scope) que exige el endpoint. |
| 403 | module_not_enabled | Tu empresa no tiene habilitado el módulo del endpoint (p. ej. Cotizaciones). |
| 429 | rate_limited | Más de 120 solicitudes por minuto con el mismo token. |
/api/v1/customerscustomers:readLista o busca clientes, ordenados por nombre.
| Parámetro | Descripción |
|---|---|
q | Búsqueda por nombre, apodo o número de documento. |
page | Página, desde 1. |
per_page | Por defecto 50, máximo 100. |
{ "data": [ { "id": "…", "doc_type": "6", "doc_number": "20512345678", "name": "ACME PERU S.A.C.", … } ],
"page": 1, "per_page": 50, "total": 1 }| HTTP | Código | Cuándo |
|---|---|---|
| 401 | invalid_token | Token ausente, mal formado, revocado o de una empresa eliminada. |
| 403 | insufficient_scope | El token no tiene el alcance (scope) que exige el endpoint. |
| 403 | module_not_enabled | Tu empresa no tiene habilitado el módulo del endpoint (p. ej. Cotizaciones). |
| 429 | rate_limited | Más de 120 solicitudes por minuto con el mismo token. |
/api/v1/customers/{id}customers:readUn cliente por id, con la misma forma que la respuesta del POST (incluye phone y custom_fields).
| HTTP | Código | Cuándo |
|---|---|---|
| 404 | not_found | El cliente no existe o no es de tu empresa. |
| 401 | invalid_token | Token ausente, mal formado, revocado o de una empresa eliminada. |
| 403 | insufficient_scope | El token no tiene el alcance (scope) que exige el endpoint. |
| 403 | module_not_enabled | Tu empresa no tiene habilitado el módulo del endpoint (p. ej. Cotizaciones). |
| 429 | rate_limited | Más de 120 solicitudes por minuto con el mismo token. |
/api/v1/customers/{id}customers:writeEdita la ficha. Parche parcial: lo ausente no se toca; null limpia apodo, email, teléfono o dirección. custom_fields presente se valida y reemplaza el objeto completo (igual que en el POST).
doc_type + doc_number son una puerta de un solo sentido: se mandan juntos para agregarle documento a una ficha que no lo tiene (la que se creó solo con nombre), nunca para cambiar uno ya puesto. Ese par es la clave del directorio, y reescribirlo mudaría a otra persona las ventas ya hechas — un documento mal tipeado se arregla dando de alta otra ficha. Agregarlo no le quita su historial: las ventas que hizo sin documento siguen siendo suyas, y los comprobantes ya emitidos no se reescriben (su copia congelada sigue diciendo SIN DOCUMENTO).
curl -X PATCH https://enbloques.com/api/v1/customers/7a8b9c0d-… \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{ "phone": "987654321", "custom_fields": { "segmento": "Retail", "vip": false } }'curl -X PATCH https://enbloques.com/api/v1/customers/7a8b9c0d-… \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{ "doc_type": "1", "doc_number": "12345678" }'| HTTP | Código | Cuándo |
|---|---|---|
| 400 | validation | Campos inválidos, media identidad (uno de los dos campos sin el otro), tipo "0" o documento mal formado. |
| 400 | custom_fields_invalid | Campos personalizados inválidos (detalle por clave en details). |
| 403 | module_not_enabled | Tu plan no incluye el módulo Campos personalizados (solo si envías custom_fields). |
| 404 | not_found | El cliente no existe o no es de tu empresa. |
| 409 | identity_locked | La ficha ya tiene documento: no se cambia ni se quita. |
| 409 | document_taken | Otra ficha tuya ya tiene ese documento. |
| 401 | invalid_token | Token ausente, mal formado, revocado o de una empresa eliminada. |
| 403 | insufficient_scope | El token no tiene el alcance (scope) que exige el endpoint. |
| 403 | module_not_enabled | Tu empresa no tiene habilitado el módulo del endpoint (p. ej. Cotizaciones). |
| 429 | rate_limited | Más de 120 solicitudes por minuto con el mismo token. |
/api/v1/customers/{id}customers:writeBorrado duro: saca al cliente del directorio y responde { "deleted": true, "id": "…" }. No se puede deshacer — a diferencia de los productos, que solo se desactivan. Los comprobantes, cotizaciones, notas de venta y reservas ya registrados no cambian: cada uno guarda su propia copia de los datos del cliente y solo pierde el enlace vivo. Si vuelves a emitir a ese documento, el cliente se crea de nuevo.
| HTTP | Código | Cuándo |
|---|---|---|
| 404 | not_found | El cliente no existe o no es de tu empresa. |
| 401 | invalid_token | Token ausente, mal formado, revocado o de una empresa eliminada. |
| 403 | insufficient_scope | El token no tiene el alcance (scope) que exige el endpoint. |
| 403 | module_not_enabled | Tu empresa no tiene habilitado el módulo del endpoint (p. ej. Cotizaciones). |
| 429 | rate_limited | Más de 120 solicitudes por minuto con el mismo token. |
Series, consumo y empresa
Estos tres endpoints aceptan cualquier token válido de la empresa (no exigen scope).
/api/v1/seriescualquier tokenSeries de numeración disponibles por tipo de documento y el próximo correlativo de cada una. Cada serie incluye su owner (company / branch / register): el dueño decide qué serie se elige al emitir desde una caja.
{
"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…" }
]
}| HTTP | Código | Cuándo |
|---|---|---|
| 401 | invalid_token | Token ausente, mal formado, revocado o de una empresa eliminada. |
| 403 | insufficient_scope | El token no tiene el alcance (scope) que exige el endpoint. |
| 403 | module_not_enabled | Tu empresa no tiene habilitado el módulo del endpoint (p. ej. Cotizaciones). |
| 429 | rate_limited | Más de 120 solicitudes por minuto con el mismo token. |
/api/v1/usagecualquier tokenConsumo del mes en curso (calendario America/Lima) contra el límite del plan.
{
"plan": "pro",
"planStatus": "active",
"used": 137,
"limit": 3000,
"remaining": 2863,
"periodStart": "2026-06-01T05:00:00.000Z"
}| HTTP | Código | Cuándo |
|---|---|---|
| 401 | invalid_token | Token ausente, mal formado, revocado o de una empresa eliminada. |
| 403 | insufficient_scope | El token no tiene el alcance (scope) que exige el endpoint. |
| 403 | module_not_enabled | Tu empresa no tiene habilitado el módulo del endpoint (p. ej. Cotizaciones). |
| 429 | rate_limited | Más de 120 solicitudes por minuto con el mismo token. |
/api/v1/companycualquier tokenPerfil público de la empresa autenticada (nunca expone credenciales).
{
"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
}Sirve además de arranque del cliente: igv_rate es la tasa IGV+IPM efectiva de esta empresa (úsala para previsualizar totales, nunca un 0.18 fijo), series_mode dice a qué nivel viven las series (company, per_branch o per_register) y modules lista los módulos activos. membership trae { role, capabilities } solo en llamadas con sesión; con un token sk_live_… es null, porque un token tiene scopes y no rol.
| HTTP | Código | Cuándo |
|---|---|---|
| 401 | invalid_token | Token ausente, mal formado, revocado o de una empresa eliminada. |
| 403 | insufficient_scope | El token no tiene el alcance (scope) que exige el endpoint. |
| 403 | module_not_enabled | Tu empresa no tiene habilitado el módulo del endpoint (p. ej. Cotizaciones). |
| 429 | rate_limited | Más de 120 solicitudes por minuto con el mismo token. |