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 /series solo muestran lo de esas sucursales; un documento de otra —o sin sucursal— es 404 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 (ver branch_id) y GET /branches lo 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 token sk_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:

ScopePermite
*Todos los alcances (acceso total de API).
documents:readListar y leer documentos, descargar PDF/XML/CDR, exportar CSV, listar sucursales.
documents:writeEmitir documentos.
quotes:readListar y leer cotizaciones, descargar su PDF.
quotes:writeCrear, actualizar y eliminar cotizaciones.
products:readListar y leer productos.
products:writeCrear, actualizar y desactivar productos.
customers:readListar y buscar clientes.
customers:writeCrear/actualizar clientes.
expenses:writeRegistrar gastos. Solo por MCP (herramienta bloques_create_expense): la API REST no expone gastos.
issuers:chooseEmitir con un RUC distinto del que le toca a la venta (issuer_ruc). Sin él, ese pedido es 403 insufficient_permissions.
crm:readVer el embudo de clientes y la bitácora de cada uno. Solo por MCP: la API REST no expone el CRM.
crm:writeMover 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 (118 o 118.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_rate de GET /api/v1/company.
  • Fechas YYYY-MM-DD; timestamps ISO 8601 UTC. El calendario operativo (fecha por defecto, meses del plan) es America/Lima.

Documentos

POST/api/v1/documentsdocuments:write

Crea 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

CampoTipoRequeridoDescripción
typestringSí"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.
seriesstringNoSerie de 4 caracteres (F001/B001…). Prefijo F para facturas, B para boletas. Por defecto, la serie activa del tipo.
cash_register_idstringNoUUID 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_idstringVer abajoUUID 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_rucstringVer abajoRUC (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_datestringNoYYYY-MM-DD. Por defecto hoy (Lima). Máximo 7 días atrás; nunca futura.
sold_atstringNoInstante 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.
currencystringNo"PEN" (por defecto) o "USD".
customerobjectFactura: 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.
itemsarraySí1 a 100 ítems. Ver tabla de ítems.
paymentobjectNoForma de pago. Por defecto contado. Ver tabla de pago.
notesstringNoHasta 1000 caracteres. Texto libre impreso en el PDF (no forma parte del XML).
send_emailbooleanNoEnviar PDF + XML al email del cliente. Por defecto, la configuración de la empresa.
collection_methodstringNoMedio 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_paymentsarrayNoPago 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_consumoobjectNoRecargo 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.
detractionobjectNoDetracció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: manda branch_id o cash_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_modeRUC por defecto
singleEl principal: el negocio emite con un solo RUC.
saleEl principal («En cada venta»).
branchEl asignado a la sucursal de la venta; sin asignación, el principal.
registerEl asignado a la caja de la venta; sin asignación, el principal.
  • En modo register («Según la caja») la integración tiene que mandar cash_register_id (o un issuer_ruc): sin ninguno de los dos responde 400 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: sin register_required, y una caja o sucursal asignada a un RUC desactivado vende con el principal.
  • issuer_ruc elige otro RUC del negocio. Uno distinto del por defecto exige issuers:choose (403 insufficient_permissions); pedir el mismo por defecto no. Con issuer_mode: "single", cualquiera que no sea el principal es 400 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_ruc distinto 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

CampoTipoRequeridoDescripción
idstring (uuid)NoCliente existente del directorio. Excluyente con los campos en línea.
doc_typestringCon datos en línea"6" RUC · "1" DNI · "4" carnet de extranjería · "7" pasaporte · "0" sin documento.
doc_numberstringCon datos en líneaMá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.
namestringCon datos en líneaRazón social o nombre. Máx. 500.
emailstringNoDestino del PDF + XML.
addressstringNoDirecció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).

CampoTipoRequeridoDescripción
product_idstring (uuid)No*Producto del catálogo por id.
codestringNo*Producto del catálogo por tu código.
modifier_idstring (uuid)NoModificador 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_idsstring[] (uuid)NoExtras 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.
descriptionstringNo*Descripción de línea libre (exige unit_price) o reemplazo de la descripción del producto. Máx. 500.
notestringNoDescripció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).
quantitynumberNo> 0, hasta 3 decimales. Por defecto 1.
unit_pricenumberNo*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_codestringNoCatálogo 03 SUNAT: NIU unidad (por defecto), ZZ servicio, KGM kg, HUR hora, etc.
affectationstringNoIGV (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_ratenumberNoISC 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_ratenumberNoDescuento 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).
icbperbooleanNoAfecto 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_titlestringNoGrupo 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_indexnumberNoOrdinal 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_iduuidNoQuié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

CampoTipoRequeridoDescripción
typestringNo"contado" (por defecto) o "credito".
installmentsarrayCon creditoHasta 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

Petición
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"
  }'
Respuesta 202 (encolado) — con Prefer: wait sería 201 y status accepted
{
  "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

HTTPSignificado
202Documento creado y encolado. status es processing: el número ya está consumido y el veredicto llega después. Location apunta a su detalle.
201Solo 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.
200Replay 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

HTTPCódigoCuándo
400invalid_jsonEl cuerpo no es JSON válido.
400validationDatos 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.
400customer_invalidCliente inconsistente: factura sin RUC, boleta con RUC, documento de identidad inválido o cliente inexistente.
400branch_requiredSin cash_register_id ni branch_id, y quien creó el token tiene acceso a varias sucursales: manda uno de los dos (ver GET /branches).
400register_requiredEl 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.
402plan_limitLímite mensual del plan alcanzado. details trae used, limit y plan.
403insufficient_permissionsEl issuer_ruc no es el RUC por defecto de la venta y quien llama no tiene issuers:choose.
404branch_not_foundEl 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).
409company_not_readyLa empresa no está habilitada para emitir: le falta conectar su RUC y su Clave SOL.
409issuer_inactiveEl RUC de la venta está desactivado.
409issuer_not_readyEl RUC de la venta todavía está pendiente de autorización ante el PSE.
422issuer_not_foundEl issuer_ruc no es uno de los RUC del negocio (o la sucursal o caja tiene asignado uno que ya no lo es).
422issuer_wrong_establishmentUn RUC del Nuevo RUS fuera de su única sucursal.
422series_not_foundLa serie no existe, está inactiva, su prefijo no corresponde al tipo o su dueño es una sucursal o caja fuera del acceso.
422register_not_foundLa caja no existe, está desactivada, su sucursal está desactivada o está fuera del acceso.
422product_not_foundUn ítem referencia un product_id o code inexistente o inactivo.
422modifier_not_foundUn ítem referencia un modifier_id inexistente, inactivo, de otro producto o de otra empresa.
422extra_not_foundUn í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.
500internalError interno inesperado.
401invalid_tokenToken ausente, mal formado, revocado, de una empresa eliminada o cuyo creador ya no está en la empresa.
403insufficient_scopeEl token no tiene el alcance (scope) que exige el endpoint.
403module_not_enabledTu empresa no tiene habilitado el módulo del endpoint (p. ej. Cotizaciones).
429rate_limitedMá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.

GET/api/v1/documentsdocuments:read

Lista 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ámetroDescripción
fromFecha de emisión mínima, YYYY-MM-DD (inclusive).
toFecha de emisión máxima, YYYY-MM-DD (inclusive).
typefactura o boleta.
statusaccepted · rejected · error · processing.
qBúsqueda libre por número completo, nombre o documento del cliente (máx. 100 caracteres).
issuer_rucSolo los emitidos con ese RUC del negocio (varios RUC). El RUC de otro negocio no trae ninguno.
pagePágina, desde 1.
per_pagePor defecto 25, máximo 100.
Petición
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_..."
Respuesta 200
{
  "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
}
HTTPCódigoCuándo
401invalid_tokenToken ausente, mal formado, revocado, de una empresa eliminada o cuyo creador ya no está en la empresa.
403insufficient_scopeEl token no tiene el alcance (scope) que exige el endpoint.
403module_not_enabledTu empresa no tiene habilitado el módulo del endpoint (p. ej. Cotizaciones).
429rate_limitedMás de 120 solicitudes por minuto con el mismo token.
GET/api/v1/documents/{id}documents:read

Devuelve un documento por su UUID, incluyendo el arreglo items (misma forma que la respuesta de creación).

Petición
curl https://enbloques.com/api/v1/documents/9f1b2c3d-4e5f-4a7b-8c9d-0e1f2a3b4c5d \
  -H "Authorization: Bearer sk_live_..."
HTTPCódigoCuándo
404not_foundEl 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).
401invalid_tokenToken ausente, mal formado, revocado, de una empresa eliminada o cuyo creador ya no está en la empresa.
403insufficient_scopeEl token no tiene el alcance (scope) que exige el endpoint.
403module_not_enabledTu empresa no tiene habilitado el módulo del endpoint (p. ej. Cotizaciones).
429rate_limitedMá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.

GET/api/v1/documents/{id}/pdfdocuments:read

Descarga el PDF imprimible (application/pdf, adjunto {file_name}.pdf).

ParámetroDescripción
templateOpcional: 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).
Petición
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_..."
HTTPCódigoCuándo
404not_foundEl documento no existe o pertenece a otra empresa.
500pdf_failedNo se pudo generar el PDF.
401invalid_tokenToken ausente, mal formado, revocado, de una empresa eliminada o cuyo creador ya no está en la empresa.
403insufficient_scopeEl token no tiene el alcance (scope) que exige el endpoint.
403module_not_enabledTu empresa no tiene habilitado el módulo del endpoint (p. ej. Cotizaciones).
429rate_limitedMás de 120 solicitudes por minuto con el mismo token.
GET/api/v1/documents/{id}/xmldocuments:read

Descarga 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ámetroDescripción
unsignedtrue devuelve el XML UBL crudo sin firmar (application/xml, {file_name}-sin-firmar.xml), útil para depurar.
HTTPCódigoCuándo
404not_foundEl documento no existe o pertenece a otra empresa.
409document_processingSmartPSE aún no devolvió este archivo; el comprobante no tiene veredicto.
404file_not_foundEl archivo no está disponible (p. ej. el documento nunca llegó a firmarse).
401invalid_tokenToken ausente, mal formado, revocado, de una empresa eliminada o cuyo creador ya no está en la empresa.
403insufficient_scopeEl token no tiene el alcance (scope) que exige el endpoint.
403module_not_enabledTu empresa no tiene habilitado el módulo del endpoint (p. ej. Cotizaciones).
429rate_limitedMás de 120 solicitudes por minuto con el mismo token.
GET/api/v1/documents/{id}/cdrdocuments:read

Descarga 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.

HTTPCódigoCuándo
404not_foundEl documento no existe o pertenece a otra empresa.
409document_processingSmartPSE aún no devolvió este archivo; el comprobante no tiene veredicto.
404file_not_foundEste documento no tiene CDR.
401invalid_tokenToken ausente, mal formado, revocado, de una empresa eliminada o cuyo creador ya no está en la empresa.
403insufficient_scopeEl token no tiene el alcance (scope) que exige el endpoint.
403module_not_enabledTu empresa no tiene habilitado el módulo del endpoint (p. ej. Cotizaciones).
429rate_limitedMás de 120 solicitudes por minuto con el mismo token.
GET/api/v1/documents/{id}/zipdocuments:read

Descarga 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.

HTTPCódigoCuándo
404not_foundEl documento no existe o pertenece a otra empresa.
409document_processingSmartPSE aún no devolvió este archivo; el comprobante no tiene veredicto.
404file_not_foundEste documento no tiene XML firmado ni CDR.
401invalid_tokenToken ausente, mal formado, revocado, de una empresa eliminada o cuyo creador ya no está en la empresa.
403insufficient_scopeEl token no tiene el alcance (scope) que exige el endpoint.
403module_not_enabledTu empresa no tiene habilitado el módulo del endpoint (p. ej. Cotizaciones).
429rate_limitedMás de 120 solicitudes por minuto con el mismo token.
POST/api/v1/documents/{id}/resenddocuments:write

Corrige 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).

Petición
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 } ]
  }'
HTTPCódigoCuándo
400invalid_jsonEl cuerpo no es JSON válido.
400validationDatos inválidos (con details por campo).
400customer_invalidCliente inconsistente: factura sin RUC, boleta con RUC o documento inválido.
402plan_limitLímite mensual del plan alcanzado. details trae used, limit y plan.
404not_foundEl documento no existe o pertenece a otra empresa.
409not_resendableEl documento ya fue aceptado (SUNAT 1033) o está en proceso; su número no puede reusarse.
409company_not_readyLa empresa no está habilitada para emitir: le falta conectar su RUC y su Clave SOL.
422series_not_foundLa serie no existe, está inactiva o su prefijo no corresponde al tipo.
422product_not_foundUn ítem referencia un product_id o code inexistente o inactivo.
422modifier_not_foundUn ítem referencia un modifier_id inexistente, inactivo, de otro producto o de otra empresa.
422extra_not_foundUn í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.
500internalError interno inesperado.
401invalid_tokenToken ausente, mal formado, revocado, de una empresa eliminada o cuyo creador ya no está en la empresa.
403insufficient_scopeEl token no tiene el alcance (scope) que exige el endpoint.
403module_not_enabledTu empresa no tiene habilitado el módulo del endpoint (p. ej. Cotizaciones).
429rate_limitedMás de 120 solicitudes por minuto con el mismo token.
GET/api/v1/documents/exportdocuments:read

Exporta 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, id

Un 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.

Petición
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_..."
HTTPCódigoCuándo
401invalid_tokenToken ausente, mal formado, revocado, de una empresa eliminada o cuyo creador ya no está en la empresa.
403insufficient_scopeEl token no tiene el alcance (scope) que exige el endpoint.
403module_not_enabledTu empresa no tiene habilitado el módulo del endpoint (p. ej. Cotizaciones).
429rate_limitedMá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.

POST/api/v1/quotesquotes:write

Crea una cotización. Devuelve 201 con la cotización y sus ítems.

CampoTipoRequeridoDescripción
titlestringNoTítulo de la propuesta (máx. 120). Encabeza el PDF y el detalle; se busca con q. En PUT, omitirlo lo borra.
issue_datestringNoYYYY-MM-DD. Por defecto hoy (Lima). Puede ser futura o pasada.
valid_untilstringNoYYYY-MM-DD. Fecha límite de validez, impresa en el PDF.
currency"PEN" | "USD"NoPor defecto PEN.
customerobjectNoMismo objeto que en documentos. Vacío → CLIENTES VARIOS.
itemsitem[]Sí1 a 100 líneas. Precios finales con IGV incluido.
paymentobjectNoMismo objeto payment que en documentos.
notesstringNoTexto libre impreso en el PDF.
recargo_consumoobjectNoRecargo al consumo (sin IGV, se suma al total).
Petición
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 }]
  }'
HTTPCódigoCuándo
401invalid_tokenToken ausente, mal formado, revocado, de una empresa eliminada o cuyo creador ya no está en la empresa.
403insufficient_scopeEl token no tiene el alcance (scope) que exige el endpoint.
403module_not_enabledTu empresa no tiene habilitado el módulo del endpoint (p. ej. Cotizaciones).
429rate_limitedMás de 120 solicitudes por minuto con el mismo token.
GET/api/v1/quotesquotes:read

Lista cotizaciones (más recientes primero) con filtros y paginación.

ParámetroDescripción
from / toRango por issue_date (YYYY-MM-DD).
convertedtrue o false para filtrar por estado de conversión.
qBusca por referencia (COT-…), título, nombre o documento del cliente.
page / per_pagePaginación estándar (máx. 100).
HTTPCódigoCuándo
401invalid_tokenToken ausente, mal formado, revocado, de una empresa eliminada o cuyo creador ya no está en la empresa.
403insufficient_scopeEl token no tiene el alcance (scope) que exige el endpoint.
403module_not_enabledTu empresa no tiene habilitado el módulo del endpoint (p. ej. Cotizaciones).
429rate_limitedMás de 120 solicitudes por minuto con el mismo token.
GET/api/v1/quotes/{id}quotes:read

Devuelve una cotización con sus ítems, totales, estado derivado y la ruta del PDF.

HTTPCódigoCuándo
404not_foundLa cotización no existe o es de otra empresa.
401invalid_tokenToken ausente, mal formado, revocado, de una empresa eliminada o cuyo creador ya no está en la empresa.
403insufficient_scopeEl token no tiene el alcance (scope) que exige el endpoint.
403module_not_enabledTu empresa no tiene habilitado el módulo del endpoint (p. ej. Cotizaciones).
429rate_limitedMás de 120 solicitudes por minuto con el mismo token.
PUT/api/v1/quotes/{id}quotes:write

Reemplaza 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.

HTTPCódigoCuándo
404not_foundLa cotización no existe.
400validationYa convertida o datos inválidos.
401invalid_tokenToken ausente, mal formado, revocado, de una empresa eliminada o cuyo creador ya no está en la empresa.
403insufficient_scopeEl token no tiene el alcance (scope) que exige el endpoint.
403module_not_enabledTu empresa no tiene habilitado el módulo del endpoint (p. ej. Cotizaciones).
429rate_limitedMás de 120 solicitudes por minuto con el mismo token.
DELETE/api/v1/quotes/{id}quotes:write

Elimina una cotización. Falla con 409 si ya fue convertida en comprobante.

HTTPCódigoCuándo
404not_foundLa cotización no existe.
409validationYa convertida — no se puede eliminar.
401invalid_tokenToken ausente, mal formado, revocado, de una empresa eliminada o cuyo creador ya no está en la empresa.
403insufficient_scopeEl token no tiene el alcance (scope) que exige el endpoint.
403module_not_enabledTu empresa no tiene habilitado el módulo del endpoint (p. ej. Cotizaciones).
429rate_limitedMás de 120 solicitudes por minuto con el mismo token.
GET/api/v1/quotes/{id}/pdfquotes:read

Descarga el PDF de la cotización (se genera al vuelo, no se cachea). ?template=clasica (por defecto), ?template=minimal, ?template=lino o ?template=oscura.

Petición
curl -L -o COT-0001.pdf \
  "https://enbloques.com/api/v1/quotes/{id}/pdf?template=minimal" \
  -H "Authorization: Bearer sk_live_..."
HTTPCódigoCuándo
404not_foundLa cotización no existe.
401invalid_tokenToken ausente, mal formado, revocado, de una empresa eliminada o cuyo creador ya no está en la empresa.
403insufficient_scopeEl token no tiene el alcance (scope) que exige el endpoint.
403module_not_enabledTu empresa no tiene habilitado el módulo del endpoint (p. ej. Cotizaciones).
429rate_limitedMá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.).

Petición
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

POST/api/v1/productsproducts:write

Crea un producto del catálogo.

CampoTipoRequeridoDescripción
codestringSíTu código interno, único por empresa. Máx. 50.
namestringSíMáx. 300.
descriptionstringNoMáx. 1000.
unit_codestringNoCatálogo 03. Por defecto NIU.
kindstringNoproduct, 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_pricenumberSíPrecio FINAL — lo que paga el cliente, con IGV incluido cuando es gravado.
currencystringNoPEN (por defecto) o USD.
affectationstringNo"10" (por defecto) · "20" · "30".
isc_ratenumberNoISC al valor por defecto como fracción 0–1 (ej. 0.10); solo productos gravados.
icbperbooleanNoMarca ICBPER por defecto (bolsa plástica) para las líneas que usen este producto.
track_stockbooleanNoSi 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_stocknumberNoUmbral de alerta de stock bajo (suma entre almacenes); omitido = sin alerta.
barcodestringNoCódigo de barras (EAN del fabricante o interno). Único por empresa.
costnumberNoÚltimo costo de compra, FINAL (IGV incluido). Informativo.
activebooleanNoPor defecto true.
price_tiersarrayNoPrecios 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_tiersbooleanNoCuenta 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_agreementsbooleanNoOpciones 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.
imagesarrayNoHasta 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_publicbooleanNoVisible 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_idsarrayNoCategorí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.
modifiersarrayNoVariaciones 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.
extrasarrayNoExtras 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.
componentsarrayNoConvierte 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.

Producto con escalones
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:

CampoTipoRequeridoDescripció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.

Dos productos con la cuenta encendida
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 affectation del combo y ninguno lleva isc_rate ni icbper: la línea del comprobante es una, con los impuestos del combo → 422 invalid_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, o product/service en un combo sin mandar también components: [] → 422 invalid_combo;
  • presentaciones (modifiers) o extras en un combo: en el mismo request, 400 validation; si el producto que se vuelve combo ya los tiene, 422 invalid_combo;
  • el mismo product_id dos veces → 400 validation.
Crear un combo
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:

Forma de una imagen
"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:

CampoTipoRequeridoDescripció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).

Petición
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 }'
Petición con modificadores
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 }
    ]
  }'
Respuesta 201
{
  "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"
}
Respuesta 201 (con modificadores)
{
  "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"
}
HTTPCódigoCuándo
400invalid_jsonEl cuerpo no es JSON válido.
400validationCampos inválidos (incluye un modificador cuyo id no pertenece a este producto).
409duplicate_codeYa existe un producto con ese código.
409duplicate_barcodeYa existe un producto con ese código de barras.
409duplicate_modifier_nameEl producto ya tiene un modificador activo con ese nombre.
409duplicate_modifier_codeEl producto ya tiene un modificador activo con ese código.
422category_not_foundUn id de category_ids no es una categoría de tu empresa.
401invalid_tokenToken ausente, mal formado, revocado, de una empresa eliminada o cuyo creador ya no está en la empresa.
403insufficient_scopeEl token no tiene el alcance (scope) que exige el endpoint.
403module_not_enabledTu empresa no tiene habilitado el módulo del endpoint (p. ej. Cotizaciones).
429rate_limitedMás de 120 solicitudes por minuto con el mismo token.
GET/api/v1/productsproducts:read

Lista el catálogo (solo activos por defecto), ordenado por código.

ParámetroDescripción
qBúsqueda por nombre o código.
include_inactivetrue incluye productos desactivados.
pagePágina, desde 1.
per_pagePor 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).

Respuesta 200
{ "data": [ { "id": "…", "code": "CAFE-250", "modifiers": [], … } ], "page": 1, "per_page": 50, "total": 12 }
HTTPCódigoCuándo
401invalid_tokenToken ausente, mal formado, revocado, de una empresa eliminada o cuyo creador ya no está en la empresa.
403insufficient_scopeEl token no tiene el alcance (scope) que exige el endpoint.
403module_not_enabledTu empresa no tiene habilitado el módulo del endpoint (p. ej. Cotizaciones).
429rate_limitedMás de 120 solicitudes por minuto con el mismo token.
GET/api/v1/products/{id}products:read

Devuelve un producto por UUID, incluyendo sus modifiers activas ordenadas por position.

HTTPCódigoCuándo
404not_foundEl producto no existe o pertenece a otra empresa.
401invalid_tokenToken ausente, mal formado, revocado, de una empresa eliminada o cuyo creador ya no está en la empresa.
403insufficient_scopeEl token no tiene el alcance (scope) que exige el endpoint.
403module_not_enabledTu empresa no tiene habilitado el módulo del endpoint (p. ej. Cotizaciones).
429rate_limitedMás de 120 solicitudes por minuto con el mismo token.
PUT/api/v1/products/{id}products:write

Actualizació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.

Petición
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 }'
Petición: subir el precio de un modificador y quitar otra
# 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 }
    ]
  }'
HTTPCódigoCuándo
400invalid_jsonEl cuerpo no es JSON válido.
400validationCampos inválidos (incluye un modificador cuyo id no pertenece a este producto).
404not_foundEl producto no existe o pertenece a otra empresa.
409duplicate_codeEl nuevo código ya pertenece a otro producto.
409duplicate_barcodeEl nuevo código de barras ya pertenece a otro producto.
409duplicate_modifier_nameEl producto ya tiene un modificador activo con ese nombre.
409duplicate_modifier_codeEl producto ya tiene un modificador activo con ese código.
422category_not_foundUn id de category_ids no es una categoría de tu empresa.
401invalid_tokenToken ausente, mal formado, revocado, de una empresa eliminada o cuyo creador ya no está en la empresa.
403insufficient_scopeEl token no tiene el alcance (scope) que exige el endpoint.
403module_not_enabledTu empresa no tiene habilitado el módulo del endpoint (p. ej. Cotizaciones).
429rate_limitedMás de 120 solicitudes por minuto con el mismo token.
DELETE/api/v1/products/{id}products:write

Borrado 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 }.

HTTPCódigoCuándo
404not_foundEl producto no existe o pertenece a otra empresa.
401invalid_tokenToken ausente, mal formado, revocado, de una empresa eliminada o cuyo creador ya no está en la empresa.
403insufficient_scopeEl token no tiene el alcance (scope) que exige el endpoint.
403module_not_enabledTu empresa no tiene habilitado el módulo del endpoint (p. ej. Cotizaciones).
429rate_limitedMás de 120 solicitudes por minuto con el mismo token.

Clientes

POST/api/v1/customerscustomers:write

Crea 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.

CampoTipoRequeridoDescripción
doc_typestringNo"1" DNI · "4" CE · "6" RUC · "7" pasaporte · "0" sin documento. Omítelo (sin número) para una ficha solo con nombre.
doc_numberstringNoValidado 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.
namestringSíMáx. 500.
nicknamestringNoApodo 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.
emailstringNoEmail válido, máx. 320.
phonestringNoDígitos, +, (), espacios y guiones; 6–20 caracteres.
addressstringNoMáx. 500.
custom_fieldsobjectNoCampos 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_iduuidNoAcuerdo 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.
Petición
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" }'
Respuesta 201
{
  "id": "7a8b9c0d-0000-4000-8000-000000000002",
  "doc_type": "6",
  "doc_number": "20512345678",
  "name": "ACME PERU S.A.C.",
  "nickname": "ACME",
  "email": "compras@acme.pe",
  "phone": "987654321",
  "address": null,
  "custom_fields": { "segmento": "Corporativo", "vip": true },
  "agreement_id": "3c4d5e6f-0000-4000-8000-000000000009",
  "created_at": "2026-06-09T15:10:00.000Z"
}
HTTPCódigoCuándo
400invalid_jsonEl cuerpo no es JSON válido.
400validationCampos o documento de identidad inválidos.
400custom_fields_invalidCampos personalizados inválidos (detalle por clave en details).
403module_not_enabledTu plan no incluye el módulo Campos personalizados (solo si envías custom_fields).
401invalid_tokenToken ausente, mal formado, revocado, de una empresa eliminada o cuyo creador ya no está en la empresa.
403insufficient_scopeEl token no tiene el alcance (scope) que exige el endpoint.
403module_not_enabledTu empresa no tiene habilitado el módulo del endpoint (p. ej. Cotizaciones).
429rate_limitedMás de 120 solicitudes por minuto con el mismo token.
GET/api/v1/customerscustomers:read

Lista o busca clientes, ordenados por nombre.

ParámetroDescripción
qBúsqueda por nombre, apodo o número de documento.
pagePágina, desde 1.
per_pagePor defecto 50, máximo 100.
Respuesta 200
{ "data": [ { "id": "…", "doc_type": "6", "doc_number": "20512345678", "name": "ACME PERU S.A.C.", … } ],
  "page": 1, "per_page": 50, "total": 1 }
HTTPCódigoCuándo
401invalid_tokenToken ausente, mal formado, revocado, de una empresa eliminada o cuyo creador ya no está en la empresa.
403insufficient_scopeEl token no tiene el alcance (scope) que exige el endpoint.
403module_not_enabledTu empresa no tiene habilitado el módulo del endpoint (p. ej. Cotizaciones).
429rate_limitedMás de 120 solicitudes por minuto con el mismo token.
GET/api/v1/customers/{id}customers:read

Un cliente por id, con la misma forma que la respuesta del POST (incluye phone y custom_fields).

HTTPCódigoCuándo
404not_foundEl cliente no existe o no es de tu empresa.
401invalid_tokenToken ausente, mal formado, revocado, de una empresa eliminada o cuyo creador ya no está en la empresa.
403insufficient_scopeEl token no tiene el alcance (scope) que exige el endpoint.
403module_not_enabledTu empresa no tiene habilitado el módulo del endpoint (p. ej. Cotizaciones).
429rate_limitedMás de 120 solicitudes por minuto con el mismo token.
PATCH/api/v1/customers/{id}customers:write

Edita 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).

Petición
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 } }'
Agregarle el DNI a una ficha sin documento
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" }'
HTTPCódigoCuándo
400validationCampos inválidos, media identidad (uno de los dos campos sin el otro), tipo "0" o documento mal formado.
400custom_fields_invalidCampos personalizados inválidos (detalle por clave en details).
403module_not_enabledTu plan no incluye el módulo Campos personalizados (solo si envías custom_fields).
404not_foundEl cliente no existe o no es de tu empresa.
409identity_lockedLa ficha ya tiene documento: no se cambia ni se quita.
409document_takenOtra ficha tuya ya tiene ese documento.
401invalid_tokenToken ausente, mal formado, revocado, de una empresa eliminada o cuyo creador ya no está en la empresa.
403insufficient_scopeEl token no tiene el alcance (scope) que exige el endpoint.
403module_not_enabledTu empresa no tiene habilitado el módulo del endpoint (p. ej. Cotizaciones).
429rate_limitedMás de 120 solicitudes por minuto con el mismo token.
DELETE/api/v1/customers/{id}customers:write

Borrado 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.

HTTPCódigoCuándo
404not_foundEl cliente no existe o no es de tu empresa.
401invalid_tokenToken ausente, mal formado, revocado, de una empresa eliminada o cuyo creador ya no está en la empresa.
403insufficient_scopeEl token no tiene el alcance (scope) que exige el endpoint.
403module_not_enabledTu empresa no tiene habilitado el módulo del endpoint (p. ej. Cotizaciones).
429rate_limitedMá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.

GET/api/v1/branchesdocuments:read

Las 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.

Respuesta 200
{
  "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.

HTTPCódigoCuándo
401invalid_tokenToken ausente, mal formado, revocado, de una empresa eliminada o cuyo creador ya no está en la empresa.
403insufficient_scopeEl token no tiene el alcance (scope) que exige el endpoint.
403module_not_enabledTu empresa no tiene habilitado el módulo del endpoint (p. ej. Cotizaciones).
429rate_limitedMás de 120 solicitudes por minuto con el mismo token.
GET/api/v1/issuersdocuments:read

Los 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".

Respuesta 200
{
  "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, branch o register; ver Con qué RUC sale la venta). Con register, manda cash_register_id en cada venta.
  • can_choose: si quien llama puede mandar un issuer_ruc distinto 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á active o no está ready no vende.
  • nuevo_rus: solo boletas, y solo en establishment_branch_id cuando lo tiene.
HTTPCódigoCuándo
401invalid_tokenToken ausente, mal formado, revocado, de una empresa eliminada o cuyo creador ya no está en la empresa.
403insufficient_scopeEl token no tiene el alcance (scope) que exige el endpoint.
403module_not_enabledTu empresa no tiene habilitado el módulo del endpoint (p. ej. Cotizaciones).
429rate_limitedMás de 120 solicitudes por minuto con el mismo token.
GET/api/v1/seriescualquier token

Series 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.

Respuesta 200
{
  "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…" }
  ]
}
HTTPCódigoCuándo
401invalid_tokenToken ausente, mal formado, revocado, de una empresa eliminada o cuyo creador ya no está en la empresa.
403insufficient_scopeEl token no tiene el alcance (scope) que exige el endpoint.
403module_not_enabledTu empresa no tiene habilitado el módulo del endpoint (p. ej. Cotizaciones).
429rate_limitedMás de 120 solicitudes por minuto con el mismo token.
GET/api/v1/usagecualquier token

Consumo 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.

Respuesta 200
{
  "plan": "pro",
  "planStatus": "active",
  "used": 137,
  "limit": 3000,
  "remaining": 2863,
  "periodStart": "2026-06-01T05:00:00.000Z"
}
HTTPCódigoCuándo
403requires_all_branchesQuien creó el token no tiene acceso a todas las sucursales.
401invalid_tokenToken ausente, mal formado, revocado, de una empresa eliminada o cuyo creador ya no está en la empresa.
403insufficient_scopeEl token no tiene el alcance (scope) que exige el endpoint.
403module_not_enabledTu empresa no tiene habilitado el módulo del endpoint (p. ej. Cotizaciones).
429rate_limitedMás de 120 solicitudes por minuto con el mismo token.
GET/api/v1/companycualquier token

Perfil 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.

Respuesta 200
{
  "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.

HTTPCódigoCuándo
401invalid_tokenToken ausente, mal formado, revocado, de una empresa eliminada o cuyo creador ya no está en la empresa.
403insufficient_scopeEl token no tiene el alcance (scope) que exige el endpoint.
403module_not_enabledTu empresa no tiene habilitado el módulo del endpoint (p. ej. Cotizaciones).
429rate_limitedMá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 a category_id; la respuesta trae categories (id y nombre) y category_ids en lugar de category y category_id. Un category_id enviado 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/v1 responde 401 unauthorized; los tokens sk_live_… y la sesión de la app no cambian. Se aceptaba solo para abrir las URLs de /api/v1/… que devolvían get_document_files y get_quote_files; ahora esas herramientas entregan URLs de descarga firmadas, que no piden cabecera Authorization.
  • Octubre 2026 — varios RUC por negocio. Todo documento trae issuer ({ ruc, razon_social }); POST /documents y la herramienta MCP create_document aceptan issuer_ruc (otro RUC exige el nuevo scope issuers:choose); nuevos GET /issuers y herramienta MCP list_issuers; issuer_ruc en las series y como filtro del listado, del CSV y de list_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) e issuer_wrong_establishment (422). En modo «Según la caja» (issuer_mode: "register") las integraciones tienen que mandar cash_register_id. Un negocio de un solo RUC solo ve el campo issuer nuevo.