Documentación

API REST

Referencia completa de la API de Bloques. Base URL: https://<tu-host>/api/v1. Todas las peticiones y respuestas son JSON UTF-8 (salvo las descargas de archivos y el CSV). También disponible como OpenAPI 3.1 y como referencia para LLMs.

Autenticación

Cada petición lleva un token Bearer creado en Configuración → API:

Authorization: Bearer sk_live_...
  • Los tokens tienen la forma sk_live_… y se muestran una sola vez al crearlos; Bloques guarda solo su hash.
  • La gestión de tokens (crear, listar, revocar) es solo por sesión web, por diseño: no existe endpoint de API para crear tokens, de modo que un token filtrado nunca pueda fabricar más tokens ni escalar sus permisos.
  • Cada token pertenece a una empresa y solo ve los datos de esa empresa.
  • Límite de tasa: 120 solicitudes por minuto por token. Al excederlo recibes 429 rate_limited.
  • CORS habilitado (Access-Control-Allow-Origin: *): puedes llamar a la API desde un navegador. Cabeceras permitidas: Authorization, Content-Type, Idempotency-Key. Aun así, nunca incrustes un 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.
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 create_expense): la API REST no expone gastos.
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).

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 (18%) incluido. Bloques calcula la base (total ÷ 1.18) y el IGV por ti.
  • Fechas YYYY-MM-DD; timestamps ISO 8601 UTC. El calendario operativo (fecha por defecto, meses del plan) es America/Lima.

Documentos

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
typestring"factura" (01, exige cliente con RUC) o "boleta" (03).
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.
issue_datestringNoYYYY-MM-DD. Por defecto hoy (Lima). Máximo 7 días atrás; nunca futura.
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.
itemsarray1 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.
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.

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, 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.
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 S/ 0.50 por unidad sobre el precio. 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",
  "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, etc.
400customer_invalidCliente inconsistente: factura sin RUC, boleta con RUC, documento de identidad inválido o cliente inexistente.
402plan_limitLímite mensual del plan alcanzado. details trae used, limit y plan.
409company_not_readyLa empresa aún no completó el onboarding con el PSE.
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 o de una empresa eliminada.
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).

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).
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",
      "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 o de una empresa eliminada.
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 o pertenece a otra empresa.
401invalid_tokenToken ausente, mal formado, revocado o de una empresa eliminada.
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 o de una empresa eliminada.
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 o de una empresa eliminada.
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 o de una empresa eliminada.
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 o de una empresa eliminada.
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 aún no completó el onboarding con el PSE.
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 o de una empresa eliminada.
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). 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
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 o de una empresa eliminada.
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[]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 o de una empresa eliminada.
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 o de una empresa eliminada.
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 o de una empresa eliminada.
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 o de una empresa eliminada.
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 o de una empresa eliminada.
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 o de una empresa eliminada.
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
codestringTu código interno, único por empresa. Máx. 50.
namestringMáx. 300.
descriptionstringNoMáx. 1000.
unit_codestringNoCatálogo 03. Por defecto NIU.
unit_pricenumberPrecio 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.
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.
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 perfil público de la empresa (módulo perfil-publico). Por defecto false: publicar un producto es un acto explícito.
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.

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: falseLa cantidad calificadora es la quantity de la propia línea — el comportamiento de siempre, sin cambios.
pool_tiers: trueLa 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 } ] }'

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 idActualiza ese modificador. El id sobrevive a los cambios de nombre y precio, así que las líneas ya emitidas lo siguen referenciando.
sin idCrea un modificador nuevo.
ausente del arrayUn modificador activo que ya no aparece se desactiva (borrado suave: la fila sobrevive para la trazabilidad).
campo omitidoOmitir 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",
  "unit_price": "35.00",
  "currency": "PEN",
  "affectation": "10",
  "isc_rate": null,
  "icbper": false,
  "track_stock": true,
  "min_stock": null,
  "barcode": null,
  "cost": null,
  "price_tiers": [],
  "pool_tiers": false,
  "is_public": false,
  "active": true,
  "modifiers": [],
  "created_at": "2026-06-09T15:00:00.000Z",
  "updated_at": "2026-06-09T15:00:00.000Z"
}
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.
401invalid_tokenToken ausente, mal formado, revocado o de una empresa eliminada.
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).

Respuesta 200
{ "data": [ { "id": "…", "code": "CAFE-250", "modifiers": [], … } ], "page": 1, "per_page": 50, "total": 12 }
HTTPCódigoCuándo
401invalid_tokenToken ausente, mal formado, revocado o de una empresa eliminada.
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 o de una empresa eliminada.
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.

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.
401invalid_tokenToken ausente, mal formado, revocado o de una empresa eliminada.
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 o de una empresa eliminada.
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.
namestringMá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 o de una empresa eliminada.
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 o de una empresa eliminada.
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 o de una empresa eliminada.
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 o de una empresa eliminada.
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. Los comprobantes, cotizaciones, notas de venta y reservas ya registrados no cambian: cada uno guarda su propia copia de los datos del cliente y solo pierde el enlace vivo. Si vuelves a emitir a ese documento, el cliente se crea de nuevo.

HTTPCódigoCuándo
404not_foundEl cliente no existe o no es de tu empresa.
401invalid_tokenToken ausente, mal formado, revocado o de una empresa eliminada.
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.

Series, consumo y empresa

Estos tres endpoints aceptan cualquier token válido de la empresa (no exigen scope).

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.

Respuesta 200
{
  "data": [
    { "type": "factura", "doc_type": "01", "code": "F001", "next_number": 43, "active": true,
      "owner": "company", "branch_id": null, "cash_register_id": null },
    { "type": "boleta",  "doc_type": "03", "code": "B002", "next_number": 118, "active": true,
      "owner": "register", "branch_id": null, "cash_register_id": "…uuid…" }
  ]
}
HTTPCódigoCuándo
401invalid_tokenToken ausente, mal formado, revocado o de una empresa eliminada.
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.

Respuesta 200
{
  "plan": "pro",
  "planStatus": "active",
  "used": 137,
  "limit": 3000,
  "remaining": 2863,
  "periodStart": "2026-06-01T05:00:00.000Z"
}
HTTPCódigoCuándo
401invalid_tokenToken ausente, mal formado, revocado o de una empresa eliminada.
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).

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

HTTPCódigoCuándo
401invalid_tokenToken ausente, mal formado, revocado o de una empresa eliminada.
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.