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 → Integraciones:
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.
- Un token actúa en nombre de quien lo creó: en cada petición sus alcances se cruzan con lo que el rol de esa persona permite hoy. Si le quitan un permiso, el token lo pierde (
403 insufficient_scope); si la retiran de la empresa, el token deja de funcionar (401 invalid_token). - El token hereda también las sucursales de quien lo creó, releídas en cada petición: si esa persona solo trabaja en algunas, el token solo ve esas. Las listas, la lectura por id (con sus archivos y el reenvío), el export CSV y
GET /seriessolo muestran lo de esas sucursales; un documento de otra —o sin sucursal— es404 not_found, igual que uno que no existe. Restringirle las sucursales restringe al token en la siguiente petición, sin rotarlo. La emisión sigue el mismo acceso (verbranch_id) yGET /brancheslo lista. Quien ve todas las sucursales ve todo, también lo que no tiene sucursal. - 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, listar sucursales. |
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 bloques_create_expense): la API REST no expone gastos. |
issuers:choose | Emitir con un RUC distinto del que le toca a la venta (issuer_ruc). Sin él, ese pedido es 403 insufficient_permissions. |
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). GET /usage exige además que quien creó el token tenga acceso a todas las sucursales: es un número de toda la empresa.
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 incluido. Bloques calcula la base (total ÷ (1 + tasa)) y el IGV por ti, con la tasa efectiva de tu empresa: el 18 % es la tasa general, pero un restaurante MYPE puede tener una reducida. La lees en
igv_ratedeGET /api/v1/company. - 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). Una empresa del Nuevo RUS solo emite boletas: su factura responde 400 validation. Con varios RUC, lo decide el RUC de la venta. |
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. Tiene que ser de una sucursal del acceso de quien creó el token: la de otra es 422 register_not_found, como una que no existe. |
branch_id | string | Ver abajo | UUID de la sucursal donde ocurre una venta sin caja (lístalas con GET /branches). Activa y dentro del acceso de quien creó el token; si no, 404 branch_not_found, igual que una que no existe. Junto a cash_register_id tiene que ser la sucursal de esa caja (400 validation). En el modo de series por sucursal elige además la serie de esa sucursal. |
issuer_ruc | string | Ver abajo | RUC (11 dígitos) del negocio con el que se emite, cuando emite con varios (lístalos con GET /issuers). Sin él, la venta sale con el RUC que le toca. Uno distinto exige issuers:choose (403 insufficient_permissions). Ver Con qué RUC sale la venta. |
issue_date | string | No | YYYY-MM-DD. Por defecto hoy (Lima). Máximo 7 días atrás; nunca futura. |
sold_at | string | No | Instante ISO 8601 de una venta que un punto de venta registró sin conexión y manda después, con Idempotency-Key. La fecha y la hora de emisión salen de él (hora de Lima), con la misma regla de 7 días; hasta 5 minutos adelantado. No imprime ticket automático ni abre la ventana NFC: el cliente ya no está en la caja. |
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. |
collection_payments | array | No | Pago mixto: la venta cobrada con dos o más medios, como [{ method, amount }] (cada medio una vez; los montos suman exactamente el total del comprobante, o responde 422). Con él, collection_method se deriva: el medio de mayor monto. Interno, como el medio de cobro. |
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. |
En qué sucursal queda la venta
El token actúa con el acceso por sucursal de quien lo creó. La sucursal de la venta (branch_id en la respuesta) sale, en este orden:
- con
cash_register_id, la sucursal de la caja; - si no, con
branch_id, esa sucursal; - si no, si el acceso es una sucursal, esa;
- si no, si son varias (pero no todas),
400 branch_required: mandabranch_idocash_register_id. No se crea nada ni se consume correlativo; - con acceso a todas, como siempre: la única sucursal activa de la empresa si tiene una sola; si no, sin sucursal (
branch_id: null).
Una series explícita cuyo dueño es una sucursal o caja fuera del acceso es 422 series_not_found, como una que no existe; una serie de empresa (sin dueño) sirve siempre.
Con qué RUC sale la venta
Un negocio puede emitir con varios RUC, cada uno con sus series y sus correlativos (dos RUC pueden tener cada uno su F001-1). La respuesta dice siempre con cuál salió el documento (issuer: { ruc, razon_social }). Con un solo RUC no cambia nada: todo sale con el principal.
El RUC por defecto de la venta lo decide el issuer_mode del negocio (ver GET /issuers):
| issuer_mode | RUC por defecto |
|---|---|
single | El principal: el negocio emite con un solo RUC. |
sale | El principal («En cada venta»). |
branch | El asignado a la sucursal de la venta; sin asignación, el principal. |
register | El asignado a la caja de la venta; sin asignación, el principal. |
- En modo
register(«Según la caja») la integración tiene que mandarcash_register_id(o unissuer_ruc): sin ninguno de los dos responde400 register_required. Caer al principal en silencio emitiría con el RUC equivocado. - El modo rige con dos o más RUC activos. Con uno solo (varios RUC encendido y los demás desactivados) toda venta sale con el principal, como en
single: sinregister_required, y una caja o sucursal asignada a un RUC desactivado vende con el principal. issuer_rucelige otro RUC del negocio. Uno distinto del por defecto exigeissuers:choose(403 insufficient_permissions); pedir el mismo por defecto no. Conissuer_mode: "single", cualquiera que no sea el principal es400 validation.- El RUC tiene que poder vender: ser del negocio (
422 issuer_not_found), estar activo (409 issuer_inactive), autorizado ante el PSE (409 issuer_not_ready) y, si es del Nuevo RUS, vender solo en su sucursal (422 issuer_wrong_establishment) y nunca facturas (400 validation). - El RUC se resuelve antes del correlativo: ninguno de estos errores consume número.
- Con un
issuer_rucdistinto del por defecto, la caja y la sucursal se prefieren pero no obligan: la serie de ese RUC de la caja; si no, la de la sucursal de la venta; si no, la de empresa (sin dueño) de ese RUC. Solo falla (422 series_not_found) si ese RUC no tiene ninguna serie activa del tipo.
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. |
note | string | No | Descripción de la venta para ESTA línea ("Dominio hotel.pe, 12 meses"). Se guarda con la línea y se imprime en el PDF y el ticket detrás del nombre, pero no va a SUNAT: el XML lleva solo description. Una línea (los saltos se vuelven espacios); máx. 250. La respuesta la devuelve como note (null = ninguna). |
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 por unidad, sobre el precio, el monto por bolsa que fija la ley (S/ 0.50 desde 2023). 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",
"issuer": { "ruc": "20123456789", "razon_social": "MI EMPRESA S.A.C." },
"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, una factura de una empresa del Nuevo RUS, etc. |
| 400 | customer_invalid | Cliente inconsistente: factura sin RUC, boleta con RUC, documento de identidad inválido o cliente inexistente. |
| 400 | branch_required | Sin cash_register_id ni branch_id, y quien creó el token tiene acceso a varias sucursales: manda uno de los dos (ver GET /branches). |
| 400 | register_required | El negocio decide el RUC «Según la caja» (con dos o más RUC activos) y la venta no trae cash_register_id ni issuer_ruc. |
| 402 | plan_limit | Límite mensual del plan alcanzado. details trae used, limit y plan. |
| 403 | insufficient_permissions | El issuer_ruc no es el RUC por defecto de la venta y quien llama no tiene issuers:choose. |
| 404 | branch_not_found | El branch_id no existe, está desactivado, es de otra empresa o está fuera del acceso de quien creó el token (también si ese acceso no tiene ninguna sucursal activa). |
| 409 | company_not_ready | La empresa no está habilitada para emitir: le falta conectar su RUC y su Clave SOL. |
| 409 | issuer_inactive | El RUC de la venta está desactivado. |
| 409 | issuer_not_ready | El RUC de la venta todavía está pendiente de autorización ante el PSE. |
| 422 | issuer_not_found | El issuer_ruc no es uno de los RUC del negocio (o la sucursal o caja tiene asignado uno que ya no lo es). |
| 422 | issuer_wrong_establishment | Un RUC del Nuevo RUS fuera de su única sucursal. |
| 422 | series_not_found | La serie no existe, está inactiva, su prefijo no corresponde al tipo o su dueño es una sucursal o caja fuera del acceso. |
| 422 | register_not_found | La caja no existe, está desactivada, su sucursal está desactivada o está fuera del acceso. |
| 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, de una empresa eliminada o cuyo creador ya no está en la empresa. |
| 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). Solo los de las sucursales del acceso de quien creó el token (y el total los cuenta igual); con acceso restringido, los documentos sin sucursal no aparecen.
| 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). |
issuer_ruc | Solo los emitidos con ese RUC del negocio (varios RUC). El RUC de otro negocio no trae ninguno. |
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",
"issuer": { "ruc": "20123456789", "razon_social": "MI EMPRESA S.A.C." },
"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, de una empresa eliminada o cuyo creador ya no está en la empresa. |
| 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, pertenece a otra empresa o es de una sucursal fuera del acceso de quien creó el token (igual en sus archivos y en el reenvío). |
| 401 | invalid_token | Token ausente, mal formado, revocado, de una empresa eliminada o cuyo creador ya no está en la empresa. |
| 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, de una empresa eliminada o cuyo creador ya no está en la empresa. |
| 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, de una empresa eliminada o cuyo creador ya no está en la empresa. |
| 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, de una empresa eliminada o cuyo creador ya no está en la empresa. |
| 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, de una empresa eliminada o cuyo creador ya no está en la empresa. |
| 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 no está habilitada para emitir: le falta conectar su RUC y su Clave SOL. |
| 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, de una empresa eliminada o cuyo creador ya no está en la empresa. |
| 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, issuer_ruc) y el mismo acceso por sucursal (con token, el de quien lo creó; desde la app con sesión, el botón «Exportar CSV», la sucursal elegida en la app). 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, idUn negocio que emite con varios RUC recibe además, al final, issuer_ruc e issuer_razon_social: el RUC con el que salió cada documento. Van al final para que quien lee el archivo por posición no se corra; con un solo RUC el archivo es el de siempre.
curl -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, de una empresa eliminada o cuyo creador ya no está en la empresa. |
| 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, de una empresa eliminada o cuyo creador ya no está en la empresa. |
| 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, de una empresa eliminada o cuyo creador ya no está en la empresa. |
| 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, de una empresa eliminada o cuyo creador ya no está en la empresa. |
| 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, de una empresa eliminada o cuyo creador ya no está en la empresa. |
| 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, de una empresa eliminada o cuyo creador ya no está en la empresa. |
| 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, de una empresa eliminada o cuyo creador ya no está en la empresa. |
| 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. |
kind | string | No | product, service o combo. Entre los dos primeros solo decide en KGM, que mide las dos cosas (el lavado por kilo es un servicio); ZZ, HUR y DAY son siempre servicio y cualquier otra unidad, producto, mandes lo que mandes. combo va con components (ver «Combos»). Omitido = lo que diga la unidad (en KGM, producto), o combo si components trae productos. |
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, salvo en un servicio (ZZ, HUR, DAY, o KGM con kind: "service"): ahí se guarda siempre en false, mandes lo que mandes — un servicio no lleva existencias. Lo mismo en un combo: lo cuentan sus componentes. |
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. |
uses_modifiers · uses_price_tiers · uses_extras · uses_agreements | boolean | No | Opciones de precio de la ficha del producto en la app: qué perillas muestra. Solo de visualización — ningún precio las lee. Por defecto false, y mandar los datos ya enciende la suya. 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 catálogo en línea de la empresa (módulo perfil-publico). Por defecto false: publicar un producto es un acto explícito. |
category_ids | array | No | Categorías por id (las verticales del negocio: «Textil», «Cabinas»): ninguna, una o varias, máx. 20. Omitir o [] = sin categoría. Solo clasifican — no tocan precio, impuestos ni stock. Cada id tiene que ser de una categoría de tu empresa: si no, 422 category_not_found. Las categorías se crean en la app. La respuesta las trae en categories (id y nombre) y en category_ids. |
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. |
components | array | No | Convierte el producto en un combo: los productos que lleva y cuántos de cada uno ({ product_id, quantity }, máx. 30). Ver «Combos» más abajo. |
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 } ] }'Opciones de precio
Cuatro booleanos dicen qué perillas de precio muestra la ficha del producto en la app: uses_modifiers (presentaciones), uses_price_tiers (price_tiers y pool_tiers), uses_extras (extras) y uses_agreements (la banda de solo lectura con lo que cobra cada acuerdo). Son solo de visualización: la emisión, las cotizaciones y las notas de venta tarifan con los datos, nunca con estos campos.
Mandar los datos enciende su opción, aunque el mismo request diga false: modifiers no vacío enciende uses_modifiers; escalones (del producto o de un modificador) o pool_tiers: true encienden uses_price_tiers; extras no vacío enciende uses_extras. Casi nunca hace falta mandarlos. La app muestra opción O datos, así que apagar una opción mientras el producto tiene esos datos no los esconde; y uses_agreements apagado solo oculta la banda: los acuerdos siguen aplicando. En el PUT parcial, omitir una clave no la toca.
Combos
Un combo agrupa varios productos con su propio precio: «Combo clásico» = 1 hamburguesa + 1 papas + 2 gaseosas a S/ 25.00. Es un producto con kind: "combo": su unit_price es el precio del combo, se fija al crearlo y se edita como cualquier precio — nunca se recalcula desde sus componentes, y cambiar el precio de uno no lo mueve. En la venta es una línea (por product_id o code), y en el comprobante y el XML sale esa línea con los impuestos del combo. Los componentes solo cuentan para el stock: vender n combos descuenta n × quantity de cada uno (con el módulo de inventario), una nota de crédito por devolución los repone, y el combo nunca lleva stock propio (track_stock se guarda en false). Un combo va siempre en NIU, sin icbper y sin presentaciones ni extras: su precio es unit_price, con price_tiers para precios por volumen.
kind y components van juntos: components con productos y sin kind lo vuelve combo; kind: "combo" sin productos, o product/service con productos, en el mismo request es un 400 validation. components se reemplaza entero, como images: omitirlo no toca nada, [] lo vuelve un producto normal (lo que diga su unidad, o el kind que mandes). Las respuestas lo traen como [{ product_id, quantity, position }] (quantity con 3 decimales), [] si no es combo. Las reglas se comprueban en toda escritura de producto, también en una que no manda components pero cambia algo de lo que el combo depende:
- un componente que no es un producto de la empresa → 422
component_not_found(el id de otra empresa contesta igual); - el combo dentro de sí mismo, un combo dentro de otro, o volver combo un producto que ya va dentro de uno → 422
invalid_combo(sin anidamiento); - todos los componentes llevan la
affectationdel combo y ninguno llevaisc_rateniicbper: la línea del comprobante es una, con los impuestos del combo → 422invalid_combo. Por lo mismo no se puede cambiar la afectación del combo, ni ponerle ISC o ICBPER a un componente, mientras estén juntos; kind: "combo"en un producto sin componentes, oproduct/serviceen un combo sin mandar tambiéncomponents: []→ 422invalid_combo;- presentaciones (
modifiers) oextrasen un combo: en el mismo request, 400validation; si el producto que se vuelve combo ya los tiene, 422invalid_combo; - el mismo
product_iddos veces → 400validation.
curl -X POST https://enbloques.com/api/v1/products \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{ "code": "COMBO-1", "name": "Combo clásico", "kind": "combo", "unit_price": 25.00,
"components": [ { "product_id": "<id hamburguesa>", "quantity": 1 },
{ "product_id": "<id papas>", "quantity": 1 },
{ "product_id": "<id gaseosa>", "quantity": 2 } ] }'Por MCP se arman igual: bloques_create_product y bloques_update_product aceptan components y kind: "combo" con las mismas reglas, y bloques_list_products devuelve los components de cada combo.
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",
"kind": "product",
"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,
"uses_modifiers": false,
"uses_price_tiers": false,
"uses_extras": false,
"uses_agreements": false,
"is_public": false,
"categories": [],
"category_ids": [],
"active": true,
"modifiers": [],
"components": [],
"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. |
| 422 | category_not_found | Un id de category_ids no es una categoría de tu empresa. |
| 401 | invalid_token | Token ausente, mal formado, revocado, de una empresa eliminada o cuyo creador ya no está en la empresa. |
| 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) y sus cuatro uses_* (opciones de precio).
{ "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, de una empresa eliminada o cuyo creador ya no está en la empresa. |
| 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, de una empresa eliminada o cuyo creador ya no está en la empresa. |
| 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. Omitir kind conserva la clase actual en lo que la unidad nueva permita: un servicio en ZZ que pasa a KGM sigue siendo servicio, y uno que pasa a NIU se vuelve producto. Un combo deja de serlo solo con components: [] (ver «Combos»). Las opciones de precio (uses_*) tienen los mismos dos estados. category_ids es la lista completa: omitirla conserva las categorías, [] las quita todas y una lista las reemplaza — para sumar una, manda las actuales más la nueva.
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. |
| 422 | category_not_found | Un id de category_ids no es una categoría de tu empresa. |
| 401 | invalid_token | Token ausente, mal formado, revocado, de una empresa eliminada o cuyo creador ya no está en la empresa. |
| 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, de una empresa eliminada o cuyo creador ya no está en la empresa. |
| 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, de una empresa eliminada o cuyo creador ya no está en la empresa. |
| 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, de una empresa eliminada o cuyo creador ya no está en la empresa. |
| 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, de una empresa eliminada o cuyo creador ya no está en la empresa. |
| 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, de una empresa eliminada o cuyo creador ya no está en la empresa. |
| 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. Lo que es solo del cliente se borra con él: sus medidas de la vista (ópticas) y su etapa e interacciones del CRM. Todo lo demás se queda y solo pierde el enlace vivo: los comprobantes, cotizaciones, notas de venta y reservas guardan su propia copia de los datos del cliente, y las ventas registradas, cuentas por cobrar, envíos, órdenes de trabajo y cuentas del mostrador quedan como están. 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, de una empresa eliminada o cuyo creador ya no está en la empresa. |
| 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. |
Sucursales, RUC, series, consumo y empresa
Series, consumo y empresa aceptan cualquier token válido de la empresa (no exigen scope); las sucursales y los RUC piden documents:read.
/api/v1/branchesdocuments:readLas sucursales activas del acceso de quien creó el token, cada una con sus cajas activas, en el orden de Configuración. Sirve para elegir branch_id o cash_register_id al emitir. branch_access es all si esa persona trabaja en todas las sucursales (una venta sin ninguno de los dos campos queda como siempre) y some si su acceso está restringido (con varias sucursales, la emisión exige uno de los dos). Las sucursales y cajas desactivadas no se listan. Un token con solo documents:write emite igual, pero para listarlas necesita documents:read.
{
"branch_access": "some",
"data": [
{ "id": "…uuid…", "name": "Miraflores", "address": "Av. Larco 123",
"cash_registers": [ { "id": "…uuid…", "name": "Caja 1" } ] }
]
}address es null si la sucursal no tiene dirección.
| HTTP | Código | Cuándo |
|---|---|---|
| 401 | invalid_token | Token ausente, mal formado, revocado, de una empresa eliminada o cuyo creador ya no está en la empresa. |
| 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/issuersdocuments:readLos RUC del negocio, el principal primero: el catálogo de issuer_ruc para emitir. Nunca expone credenciales. Un negocio de un solo RUC lista su principal con issuer_mode: "single".
{
"issuer_mode": "register",
"can_choose": false,
"data": [
{ "ruc": "20123456789", "razon_social": "MI EMPRESA S.A.C.", "is_primary": true,
"active": true, "ready": true, "nuevo_rus": false, "establishment_branch_id": null },
{ "ruc": "10456789012", "razon_social": "ANA PEREZ LOPEZ", "is_primary": false,
"active": true, "ready": true, "nuevo_rus": true, "establishment_branch_id": "…uuid…" }
]
}issuer_mode: de dónde sale el RUC por defecto de cada venta (single,sale,branchoregister; ver Con qué RUC sale la venta). Conregister, mandacash_register_iden cada venta.can_choose: si quien llama puede mandar unissuer_rucdistinto del por defecto (issuers:choose).ready: conectado al PSE y, si es un RUC adicional, con su «Autorizo PSE» hecho. Un RUC que no estáactiveo no estáreadyno vende.nuevo_rus: solo boletas, y solo enestablishment_branch_idcuando lo tiene.
| HTTP | Código | Cuándo |
|---|---|---|
| 401 | invalid_token | Token ausente, mal formado, revocado, de una empresa eliminada o cuyo creador ya no está en la empresa. |
| 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/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. Solo lista las series que el token puede usar: las de empresa siempre; las de una sucursal o caja, solo si esa sucursal está en el acceso de quien lo creó. issuer_ruc es el RUC que numera con esa serie (SUNAT numera por RUC: con varios, dos series pueden tener el mismo código); null en las de nota de venta, que no es fiscal.
{
"data": [
{ "type": "factura", "doc_type": "01", "code": "F001", "issuer_ruc": "20123456789",
"next_number": 43, "active": true, "owner": "company", "branch_id": null, "cash_register_id": null },
{ "type": "boleta", "doc_type": "03", "code": "B002", "issuer_ruc": "20123456789",
"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, de una empresa eliminada o cuyo creador ya no está en la empresa. |
| 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.
Es un número de toda la empresa que no se parte por sucursal: solo lo ve un token cuyo creador tiene acceso a todas las sucursales (el propietario o alguien con «todas»). Si el creador tiene acceso a solo algunas, responde 403 requires_all_branches — y lo mismo en el siguiente request si se lo restringen después, sin tocar el token.
{
"plan": "pro",
"planStatus": "active",
"used": 137,
"limit": 3000,
"remaining": 2863,
"periodStart": "2026-06-01T05:00:00.000Z"
}| HTTP | Código | Cuándo |
|---|---|---|
| 403 | requires_all_branches | Quien creó el token no tiene acceso a todas las sucursales. |
| 401 | invalid_token | Token ausente, mal formado, revocado, de una empresa eliminada o cuyo creador ya no está en la empresa. |
| 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). Con varios RUC, la identidad fiscal (ruc, domicilio, environment) es la del RUC principal; los demás están en GET /issuers.
{
"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",
"can_emit": true,
"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.
ruc puede ser null y can_emit false: un negocio que todavía no se formalizó existe, vende y responde acá, pero no puede emitir. Mira can_emit antes de llamar a POST /api/v1/documents — si no, responde 409 company_not_ready. Pasa a true cuando su dueño conecta el RUC desde la aplicación, y nunca vuelve atrás.
| HTTP | Código | Cuándo |
|---|---|---|
| 401 | invalid_token | Token ausente, mal formado, revocado, de una empresa eliminada o cuyo creador ya no está en la empresa. |
| 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. |
Cambios
Lo que se suma a la API sin romper nada. Los cambios incompatibles solo llegan en hitos deliberados, y se anuncian acá.
- Octubre 2026 — un producto pertenece a varias categorías (incompatible: campos renombrados). En
POST /products,PUT /products/{id}y las herramientas MCP del catálogo,category_ids(lista completa, máx. 20) reemplaza acategory_id; la respuesta traecategories(id y nombre) ycategory_idsen lugar decategoryycategory_id. Uncategory_idenviado ya no se lee. - Octubre 2026 — qué se borra al borrar un cliente (aclaración, nada se rompe).
DELETE /customers/{id}borra también lo que es solo de ese cliente: sus medidas de la vista (ópticas) y su etapa e interacciones del CRM. Comprobantes, cotizaciones, notas de venta, ventas registradas, reservas, cuentas por cobrar, envíos, órdenes de trabajo y cuentas del mostrador se quedan y solo pierden el enlace vivo. - Octubre 2026 — el token OAuth del MCP ya no abre la API REST (incompatible, parte del hito 2.0.0 del MCP). En
/api/v1responde 401unauthorized; los tokenssk_live_…y la sesión de la app no cambian. Se aceptaba solo para abrir las URLs de/api/v1/…que devolvíanget_document_filesyget_quote_files; ahora esas herramientas entregan URLs de descarga firmadas, que no piden cabeceraAuthorization. - Octubre 2026 — varios RUC por negocio. Todo documento trae
issuer({ ruc, razon_social });POST /documentsy la herramienta MCPcreate_documentaceptanissuer_ruc(otro RUC exige el nuevo scopeissuers:choose); nuevosGET /issuersy herramienta MCPlist_issuers;issuer_rucen las series y como filtro del listado, del CSV y delist_documents; dos columnas al final del CSV solo para negocios con varios RUC. Códigos nuevos:insufficient_permissions(403),register_required(400),issuer_not_found(422),issuer_inactive(409),issuer_not_ready(409) eissuer_wrong_establishment(422). En modo «Según la caja» (issuer_mode: "register") las integraciones tienen que mandarcash_register_id. Un negocio de un solo RUC solo ve el campoissuernuevo.