API de Integración · ExtraLatte Ver crudo Novedades Skill para tu agente OpenAPI (JSON) ← API keys

API de Integración ExtraLatte — v1

Estado: v1.5 — todo lo documentado acá está en producción. Esta guía describe lo que la API HACE, nunca lo que está en camino: si algo aparece documentado, está desplegado y lo puedes llamar hoy. Lo que todavía no existe vive en /integration/novedades, bajo "Lo que todavía no", y el historial de cambios está al final.

Es OpenCore (si instalas ExtraLatte, la tienes; sin gate de tier) y multi-tenant (tu API key solo ve y modifica tu propio negocio).

Siete cosas haces con esta API: 1) mantener tu catálogo en sync, 2) empujar ingredientes y stock, 3) mandar comandas a la cocina, 4) agregar rondas a una mesa abierta (ver §6), 5) pedir la precuenta de un pedido (ver §7), 6) registrar el pago y cerrar el pedido (ver §8), y 7) consultar pedidos, mesas y métodos de pago (ver §9).


1. Autenticación

Cada negocio genera una o más API keys desde Configuración → API keys en ExtraLatte (rol Dueño o Gerente). Al crearla se muestra una sola vez el secreto completo con este formato:

elk_9f3a2b_7c1d…e0
    └key_id┘ └─secret─┘

Guárdalo seguro (no se puede volver a ver; si lo pierdes, rotas la key). Envía la key en cada request:

Authorization: Bearer elk_9f3a2b_7c1d…e0

Scopes (permisos)

Cada key se crea con uno o más scopes. Un request fuera de scope responde 403 forbidden_scope.

Scope Permite
read Leer catálogo, ingredientes y comandas; listar pedidos, mesas y métodos de pago (ver §9)
catalog:write Crear/actualizar/deshabilitar productos
inventory:write Upsert de ingredientes y stock
orders:write Crear comandas; registrar el pago de un pedido y cerrarlo (ver §8 — toda key con este scope ganó esta capacidad al desplegar, ver Changelog v1.2)
printing:write Encolar la impresión de una precuenta (ver §7)

Nunca pongas la API key en el frontend/navegador ni en un repo público. Es una credencial de servidor. Si se filtra, revócala y crea otra.

Verificar la key — GET /ping

curl https://extralatte.cl/api/v1/integration/ping \
  -H "Authorization: Bearer elk_9f3a2b_7c1d…e0"
{ "business_id": "b1a2…", "name": "POS Café Jazmín", "scopes": ["catalog:write","inventory:write","orders:write"] }

2. Convenciones

Formato de error (uniforme)

Todos los errores de esta API tienen la misma forma:

{ "error": { "code": "invalid_unit", "message": "La unidad 'kilo' no es válida.", "field": "unit" } }
HTTP code Cuándo
401 unauthorized Falta/está mal la API key
403 forbidden_scope La key no tiene el scope requerido
404 not_found El sku/recurso no existe en tu negocio
409 idempotency_conflict Mismo Idempotency-Key, distinto body (o la operación original sigue en curso)
409 conflict El recurso está en un estado que no permite la operación (p. ej. pedir la precuenta de una cuenta separada que ya está pagada)
422 validation_error Body inválido (campo faltante/mal tipo/fuera de rango)
422 invalid_unit Unidad no soportada
429 rate_limited Demasiados requests (ver Retry-After)

Idempotencia (evitar duplicados)

En toda escritura (POST/PUT) puedes mandar:

Idempotency-Key: <uuid-o-string-único-por-operación>

Si reintentas con la misma key (p. ej. por un timeout de red), ExtraLatte devuelve el mismo resultado sin volver a ejecutar la operación. Imprescindible para comandas: reintentar no crea dos pedidos. Si el reintento llega mientras el original todavía se está procesando, recibes 409 idempotency_conflict ("operación en curso") — espera y reintenta; nunca se ejecuta dos veces.

Bulk

Los upserts de catálogo e inventario aceptan un objeto o un array. La versión :batch procesa muchos y responde el estado por ítem.

Paginación

Los GET de listas usan ?limit=<n>&cursor=<opaco>; la respuesta trae { "items": [...], "next_cursor": "<opaco|null>" }. El cursor es opaco: no lo parsees ni lo armes tú. Es cursor y no offset a propósito — ver §9, donde está la razón y el detalle.

Ojo: el limit no se comporta igual en todas las listas, y conviene saberlo antes de encontrárselo.

Lista limit fuera de rango
GET /orders 422. Pedir 1000 y recibir 200 sin aviso te haría creer que tienes el día entero
GET /products, GET /ingredients Se recorta en silencio a 500

La diferencia es histórica, no un diseño: las dos listas viejas ya recortaban cuando GET /orders no existía, y cambiarlas ahora rompería a quien hoy manda limit=1000 y recibe 500 sin enterarse. El 422 es el comportamiento que queremos —un recorte callado es una respuesta incompleta que parece completa— así que las dos viejas se van a alinear, avisando antes. Mientras tanto: si paginas catálogo o inventario, fíjate en next_cursor, no en cuántos ítems pediste.

Rate limits

Por API key (activo en la nube ExtraLatte; en self-host solo si el operador setea RATE_LIMIT_ENABLED). Al excederlos: 429 con header Retry-After.


3. Flujo recomendado (orden de integración)

1. Sincroniza el CATÁLOGO    → PUT /products/{sku}              (una vez + en cada cambio)
2. Sincroniza el INVENTARIO  → PUT /ingredients/{sku} + .../stock
3. Manda COMANDAS             → POST /orders                     (en cada venta/pedido)
3b. La mesa pide DE NUEVO      → POST /orders/{order_id}/items     (agrega la ronda al MISMO pedido — ver §6)
4. Pide la PRECUENTA (◑)      → POST /orders/{order_id}/precuenta (antes de cobrar — ver §7)
5. Registra el PAGO            → POST /orders/{order_id}/payment   (al cobrar — ver §8)

En cualquier momento, para saber en qué anda el local (scope `read`, ver §9):
   GET /orders                  (listar y filtrar pedidos)
   GET /tables                  (qué mesa está ocupada y con qué pedido)
   GET /payment-methods         (qué acepta `method` al cobrar)

El catálogo va primero: las comandas referencian productos por sku, así que el producto debe existir en ExtraLatte antes de venderse. El precio de la comanda lo pone el servidor desde el catálogo sincronizado (no lo mandas en la orden), por eso mantener el precio en sync es importante.

El ciclo completo de una mesa es catálogo → inventario → comanda → precuenta → pago: la comanda abre el pedido, la precuenta (opcional, paso 4) le muestra a la mesa cuánto debe, y el pago (paso 5) es lo que cierra el ciclo — descuenta inventario, acredita lealtad y, con close_order (default), libera la mesa y saca el pedido del tablero de cocina.


4. Catálogo (sync del menú)

Un "producto" es un ítem vendible de tu carta. Se identifica por tu sku.

PUT /products/{sku} — crear o actualizar (upsert)

Crea el producto si el sku es nuevo; lo actualiza si ya existe (incluye cambios de precio). La categoría se resuelve por nombre (se crea si no existe).

Campos del body

Campo Tipo Req Descripción
name string Nombre visible en la carta
price number Precio de venta (moneda del negocio, sin decimales para CLP)
category string Nombre de la categoría (se crea si no existe)
description string Descripción opcional
active boolean true por defecto; false la oculta de la carta
curl -X PUT https://extralatte.cl/api/v1/integration/products/CAP-001 \
  -H "Authorization: Bearer elk_…" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 6f1c-CAP-001-v3" \
  -d '{ "name": "Capuchino", "price": 3500, "category": "Cafés", "active": true }'

Respuesta 200 OK

{ "sku": "CAP-001", "name": "Capuchino", "price": 3500, "category": "Cafés", "active": true, "created": false }

created: true si se creó, false si se actualizó.

POST /products:batch — upsert masivo

curl -X POST https://extralatte.cl/api/v1/integration/products:batch \
  -H "Authorization: Bearer elk_…" -H "Content-Type: application/json" \
  -d '{ "items": [
        { "sku": "CAP-001", "name": "Capuchino", "price": 3500, "category": "Cafés" },
        { "sku": "MED-001", "name": "Medialuna",  "price": 1200, "category": "Panadería" }
      ] }'
{ "results": [
    { "sku": "CAP-001", "status": "updated" },
    { "sku": "MED-001", "status": "created" }
] }

DELETE /products/{sku} — deshabilitar

No borra histórico; hace active=false (lo saca de la carta). Para reactivarlo, PUT con active: true.

curl -X DELETE https://extralatte.cl/api/v1/integration/products/CAP-001 \
  -H "Authorization: Bearer elk_…"
{ "sku": "CAP-001", "active": false }

GET /products — listar

curl "https://extralatte.cl/api/v1/integration/products?limit=100" \
  -H "Authorization: Bearer elk_…"
{ "items": [ { "sku": "CAP-001", "name": "Capuchino", "price": 3500, "category": "Cafés", "active": true } ], "next_cursor": null }

Agregados con precio — option_groups dentro del producto

Un agregado ("Cappuccino + leche de almendra $800") no es una línea aparte ni un texto en notes: viaja dentro del producto al que pertenece. Por eso no tiene endpoints propios — un grupo de opciones no puede existir sin su producto, así que se sincroniza en el mismo PUT /products/{sku}.

Campos de un grupo (option_groups[])

Campo Tipo Req Descripción
name string Nombre del grupo, p. ej. "Leche". Es la clave: en la próxima sync, el mismo nombre actualiza el mismo grupo
min_selected int Mínimo de opciones a elegir (default 0)
max_selected int Máximo de opciones a elegir (default 1)
is_required boolean true obliga a elegir; requiere min_selected >= 1
options array Los agregados del grupo

Campos de una opción (option_groups[].options[])

Campo Tipo Req Descripción
sku string Tu clave para el agregado, única en tu negocio. Es como lo vas a nombrar al mandar el pedido
name string Nombre visible, p. ej. "Leche de almendra"
price number Recargo que se suma al precio del producto (default 0, no puede ser negativo)
sort_order int Orden de aparición (default 0)
curl -X PUT https://extralatte.cl/api/v1/integration/products/CAP-001 \
  -H "Authorization: Bearer elk_…" -H "Content-Type: application/json" \
  -d '{
        "name": "Cappuccino", "price": 3500, "category": "Cafés",
        "option_groups": [
          { "name": "Leche", "min_selected": 1, "max_selected": 1, "is_required": true,
            "options": [
              { "sku": "LECHE-ENT", "name": "Leche entera",      "price": 0 },
              { "sku": "LECHE-ALM", "name": "Leche de almendra", "price": 800 }
            ] }
        ]
      }'

La respuesta repite option_groups tal como quedó. Un producto sin agregados no trae la llave option_groups: si nunca mandaste agregados, la respuesta es exactamente la misma de siempre.

Reglas de sincronización

DELETE /products/{sku}/options/{option_sku} — sacar un agregado

Un agregado no tiene bandera de "activo", así que esto lo elimina de verdad — pero solo mientras todavía se pueda: si ese agregado ya se vendió, la respuesta es 409 (conflict) y el agregado se queda, porque borrarlo se llevaría un pedazo de esa venta.

{ "sku": "CAP-001", "option_sku": "LECHE-ALM", "deleted": true }

5. Inventario / Ingredientes

Un "ingrediente" es un insumo (materia prima). Se identifica por su sku. El stock vive por ubicación (bodega/cocina/barra).

PUT /ingredients/{sku} — crear o actualizar (upsert)

Campo Tipo Req Descripción
name string Nombre del insumo
unit string Unidad base — una de: kg,g,l,ml,unit
cost number Costo por unidad base (para cost-plus)
category string Categoría libre (p. ej. "Materia Prima")
curl -X PUT https://extralatte.cl/api/v1/integration/ingredients/LECHE \
  -H "Authorization: Bearer elk_…" -H "Content-Type: application/json" \
  -d '{ "name": "Leche entera", "unit": "l", "cost": 950 }'
{ "sku": "LECHE", "name": "Leche entera", "unit": "l", "cost": 950, "created": true }

⚠️ Unidades: la unidad base y las cantidades deben ser coherentes. Si el insumo es kg y una receta lo consume en gramos, va a sobre-consumir. Elige una unidad base y mantén todo en esa escala.

POST /ingredients:batch — upsert masivo

Igual que products:batch, con array de ingredientes.

PUT /ingredients/{sku}/stock — fijar stock (absoluto)

Establece el stock exacto (no delta) del insumo en una ubicación. Es un upsert por (sku, ubicación): reenviar re-sincroniza sin duplicar.

Campo Tipo Req Descripción
location string Nombre de la ubicación de stock (bodega/cocina). Se crea si no existe
quantity number Stock actual (en la unidad base del insumo)
min_quantity number Umbral de alerta de stock bajo
curl -X PUT https://extralatte.cl/api/v1/integration/ingredients/LECHE/stock \
  -H "Authorization: Bearer elk_…" -H "Content-Type: application/json" \
  -d '{ "location": "Bodega", "quantity": 48, "min_quantity": 10 }'
{ "sku": "LECHE", "location": "Bodega", "quantity": 48, "min_quantity": 10 }

POST /ingredients/{sku}/stock/adjust — ajustar stock (delta)

Suma/resta al stock actual y deja el movimiento registrado. Úsalo para entradas (recepción de mercadería) o correcciones.

Campo Tipo Req Descripción
location string Ubicación de stock
delta number Cantidad a sumar (+) o restar (−)
reason string Motivo ("recepción", "merma", "conteo")
curl -X POST https://extralatte.cl/api/v1/integration/ingredients/LECHE/stock/adjust \
  -H "Authorization: Bearer elk_…" -H "Content-Type: application/json" \
  -d '{ "location": "Bodega", "delta": 12, "reason": "recepción" }'
{ "sku": "LECHE", "location": "Bodega", "quantity": 60 }

Tu sistema es la fuente de verdad del stock. Las comandas enviadas por esta API no descuentan inventario automáticamente (para no chocar con el descuento que ya hizo tu POS). Sincroniza los niveles con PUT .../stock o POST .../stock/adjust. (El auto-descuento por receta/BOM aplica solo a las órdenes creadas dentro de ExtraLatte — POS/QR —, no a las comandas de la API.)


6. Comandas (mandar pedidos a la cocina)

Crea un pedido y lo manda a la cocina (KDS) en una sola llamada. Los ítems se refieren por sku; el precio lo pone el servidor desde tu catálogo sincronizado (no lo mandas tú).

POST /orders

Campo Tipo Req Descripción
service_type string dine_in (mesa) | takeaway | delivery
table string Nombre/número de mesa. Requerido si dine_in
items array Líneas del pedido (ver abajo)
items[].sku string SKU del producto (debe existir en el catálogo)
items[].quantity int Cantidad (≥ 1)
items[].notes string Nota para cocina ("sin azúcar")
items[].modifiers array Agregados con precio de esa línea, por el sku de la opción (§4). El precio sale del catálogo, nunca de tu llamada
customer_name string Nombre del cliente (para el ticket)
mark_paid boolean true si el pedido ya se pagó en tu sistema (marca el pedido como pagado)
curl -X POST https://extralatte.cl/api/v1/integration/orders \
  -H "Authorization: Bearer elk_…" -H "Content-Type: application/json" \
  -H "Idempotency-Key: ticket-2026-07-28-000451" \
  -d '{
        "service_type": "dine_in",
        "table": "5",
        "customer_name": "Ana",
        "items": [
          { "sku": "CAP-001", "quantity": 2, "notes": "uno descafeinado",
            "modifiers": ["ADD-ALM"] },
          { "sku": "MED-001", "quantity": 3 }
        ]
      }'

Respuesta 201 Created

{
  "order_id": "a1b2…",
  "comanda_number": 12,
  "status": "in_kitchen",
  "service_type": "dine_in",
  "table": "5",
  "total": 12200,
  "currency": "CLP",
  "items": [
    { "sku": "CAP-001", "name": "Capuchino", "quantity": 2, "unit_price": 3500, "line_total": 8600,
      "modifiers": [{ "name": "Leche de almendra", "price": 800 }] },
    { "sku": "MED-001", "name": "Medialuna", "quantity": 3, "unit_price": 1200, "line_total": 3600, "modifiers": [] }
  ]
}

Cómo se cobran los agregados. unit_price sigue siendo el precio base del producto: el agregado nunca se suma adentro. Lo que sí lo incluye es line_total, con la fórmula (base + Σ agregados) × cantidad — en el ejemplo, (3500 + 800) × 2 = 8600. Ese es el número que llega al total del pedido, a la precuenta y al pago, y el agregado se imprime como una línea + Leche de almendra bajo el producto en la comanda y en la cuenta. El precio del agregado queda congelado en la venta: si mañana subes la opción a $2000, la venta de hoy sigue diciendo $800 (igual que el precio del producto).

La llave modifiers está SIEMPRE presente en el eco de cada línea de POST /orders, aunque esa línea no tenga agregados — en ese caso es un array vacío ("modifiers": [], como MED-001 en el ejemplo de arriba), nunca la ausencia de la llave. Si tu parser es estricto, no te va a sorprender: no necesitas manejar "a veces no viene". Y ese eco lee lo que quedó persistido, no lo recalcula — es la misma plata que después vas a ver en la precuenta y en el pago.

Nota de GET /orders/{order_id} (más abajo en esta sección): hoy esa consulta no repite modifiers por línea — agrupa por producto (sku) nomás, así que dos unidades del mismo producto con agregados distintos (p. ej. un capuchino solo y uno con leche de almendra) aparecen como una sola línea combinada: la cantidad y el line_total suman bien (la plata cuadra), pero no vas a ver ahí cuál agregado corresponde a cuál unidad. El desglose de agregados hoy solo existe en el eco de POST /orders.

POST /orders/{order_id}/items — agregar una ronda a un pedido abierto

La mesa pidió de nuevo. Con esto la mesa sigue siendo un pedido y una cuenta: agregas las líneas nuevas al pedido que ya existe y la cocina recibe solo lo nuevo, con el número de ronda en el encabezado (Mesa 5 · ronda 2). No necesitas abrir un pedido por ronda — si lo hacías, era porque esta llamada no existía, y cada pedido nuevo era, de nuestro lado, otra cuenta con su propia precuenta y su propio cobro.

Scope: orders:write (el mismo de POST /orders, sin scope nuevo).

Campo Tipo Req Descripción
items array Las líneas que agregas. Misma forma exacta que en POST /orders: sku, quantity, notes, modifiers
print_full_order boolean false (default): la comanda trae solo las líneas nuevas. true: imprime el pedido completo, marcando cada línea nueva con NUEVO — para el local que prefiere que el cocinero vea la mesa entera
curl -X POST https://extralatte.cl/api/v1/integration/orders/a1b2…/items \
  -H "Authorization: Bearer elk_…" -H "Content-Type: application/json" \
  -H "Idempotency-Key: ticket-2026-08-18-000451-r2" \
  -d '{
        "items": [
          { "sku": "CAP-001", "quantity": 1, "modifiers": ["ADD-ALM"] }
        ]
      }'

Respuesta 201 Created

{
  "order_id": "a1b2…",
  "comanda_number": 12,
  "round": 2,
  "status": "in_kitchen",
  "total": 16500,
  "currency": "CLP",
  "comanda_printed": true,
  "printed_full_order": false,
  "items": [
    { "sku": "CAP-001", "name": "Capuchino", "quantity": 1, "unit_price": 3500, "line_total": 4300,
      "modifiers": [{ "name": "Leche de almendra", "price": 800 }] }
  ]
}

Qué sale por la impresora. Solo lo nuevo. La comanda de la ronda 2 no repite la ronda 1, y la ronda 3 no repite la 2: cada línea queda marcada como despachada cuando entra en una comanda, y eso es lo que la ronda siguiente excluye. Si prefieres el contexto completo, print_full_order: true imprime todo el pedido con las líneas nuevas marcadas NUEVO.

Cuándo NO se puede agregar (409 conflict)

Situación Mensaje
El pedido ya está pagado El pedido ya está pagado. Abre uno nuevo.
El pedido ya está cerrado El pedido ya está cerrado. Abre uno nuevo.
El pedido ya tiene pagos registrados (cuenta separada cobrada) El pedido ya tiene pagos registrados. Abre uno nuevo.

La razón es la misma en los tres casos: alguien ya pagó contra un total que esta línea movería. Un pedido anulado responde 422 (El pedido está anulado.), y un sku inexistente o un agregado ilegal responden igual que en POST /orders (404 / 422 con el field apuntando a la línea).

Idempotencia — lee esto antes de reintentar. Dos capas, y la primera es la que importa:

  1. Idempotency-Key: reintentar con la misma key devuelve la respuesta original, sin agregar nada. Es la única protección contra duplicar líneas.
  2. El event_key de la comanda de la ronda (ronda:<pedido>:r<n>:<estación>:comanda_sector) garantiza que una misma ronda nunca se imprime dos veces ni pisa el papel de la anterior.

Sin Idempotency-Key, llamar dos veces agrega las líneas dos veces — a propósito. Agregar ítems no es una operación naturalmente idempotente: una mesa puede pedir el mismo café dos veces en dos minutos, y adivinar que el segundo es un reintento sería inventarle una intención al garzón. Manda la key.

GET /orders/{order_id} — estado del pedido

curl https://extralatte.cl/api/v1/integration/orders/a1b2… \
  -H "Authorization: Bearer elk_…"
{
  "order_id": "a1b2…", "comanda_number": 12, "status": "ready",
  "payment_status": "unpaid", "service_type": "dine_in",
  "total": 10600, "currency": "CLP",
  "items": [
    { "sku": "CAP-001", "name": "Capuchino", "quantity": 2, "unit_price": 3500, "line_total": 7000 }
  ]
}

Estados: newin_kitchenreadydelivered.


7. Precuenta (imprimir la cuenta antes de cobrar)

Una precuenta es el ticket de "cuánto se debe" que se le muestra a la mesa antes de cobrar — no es una boleta ni una factura. Sirve para pedir la cuenta desde tu propio sistema (una app de mesero, un totem, etc.) sin pasar por la caja de ExtraLatte.

Requisito operativo, antes de usar esto: el local necesita un conector de impresión (cafe-printerd) pareado y con su impresora de tickets de cliente configurada — hoy eso se hace desde Configuración → Impresoras en el panel del negocio (config-sync, ver docs/printer_daemon_admin.md), sin tener que tocar la PC del local. Esto requiere el conector v0.3.0 o superior. Un conector más viejo igual imprime la precuenta (el campo is_precuenta es aditivo y un daemon anterior lo ignora), pero con el formato legacy de boleta: sin encabezado "PRECUENTA" y sin la leyenda "Este documento no es boleta ni factura." — actualiza el conector antes de depender de esto.

POST /orders/{order_id}/precuenta

Requiere el scope printing:write. Encola un trabajo de impresión en la impresora de tickets de cliente del local; no imprime de forma síncrona (la respuesta es 202, no 200).

El servidor es dueño de todos los montos. No mandas contenido ni precios — solo pides la impresión y, como mucho, porcentajes. El total, el desglose y los montos de propina/descuento salen del pedido ya sincronizado en ExtraLatte.

Campos del body (todos opcionales — un POST vacío imprime la precuenta del pedido completo, sin propina sugerida ni descuento)

Campo Tipo Rango Descripción
split_number int ≥ 1, ≤ cuentas separadas del pedido Ausente = precuenta del pedido completo. Presente = la cuenta de esa persona (ver "Cuentas separadas" abajo)
tip_suggested_pct number 0–30 Imprime "Propina sugerida (N%)" + el monto ya calculado + "La propina es voluntaria." Fuera de rango → 422
discount_pct number 0–100 Descuento aplicado sobre lo que esa cuenta efectivamente debe (no sobre el precio de carta bruto). Fuera de rango → 422
note string ≤ 120 caracteres, sin caracteres de control Línea libre impresa en el ticket (p. ej. "Feliz cumpleaños"). Un caracter de control (byte < 0x20 o 0x7F) es 422, no se limpia en silencio: esos bytes son comandos ESC/POS (corte de papel, apertura de cajón, reset de la impresora) para una impresora térmica, no texto, y un llamador externo con printing:write no debe poder inyectarlos
curl -X POST https://extralatte.cl/api/v1/integration/orders/a1b2c3d4-.../precuenta \
  -H "Authorization: Bearer elk_…" -H "Content-Type: application/json" \
  -H "Idempotency-Key: precuenta-mesa5-20260812-01" \
  -d '{ "split_number": 2, "tip_suggested_pct": 10, "note": "Feliz cumpleaños" }'

Respuesta 202 Accepted

{ "print_job_id": "c9f1a2b3-...", "status": "queued", "deduplicated": false }

Idempotencia — dos capas, y una que no vas a esperar

  1. Idempotency-Key (igual que el resto de la API): reintentar con la misma key devuelve la respuesta original tal cual, sin encolar de nuevo.
  2. La que no vas a esperar: pedir la precuenta de el mismo order_id + el mismo split_number (o ninguno, para el pedido completo) una segunda vez SIEMPRE devuelve el mismo print_job_id con "deduplicated": true — aunque no mandes Idempotency-Key, o mandes una distinta cada vez. Esta garantía no vive en el header: vive en un índice único de la base de datos sobre (negocio, pedido, cuenta), así que ni siquiera dos requests simultáneos pueden colar dos trabajos.

No hay reimpresión por esta API, y es a propósito. El dedupe de arriba hace que pedir "otra vez" la misma precuenta sea, por diseño, un no-op — no existe un parámetro ni una variante del endpoint que fuerce un segundo trabajo. La decisión del dueño del producto fue explícita: sin esa barrera, un bug de reintentos en tu integración podría convertirse en mil impresiones en el mostrador. Si la mesa necesita un segundo papel (se manchó, se lo llevó el viento), el camino es humano: el botón "Reimprimir" en /pos/print-queue, desde el propio ExtraLatte.

Cuentas separadas

Errores propios de este endpoint

HTTP code Cuándo
403 forbidden_scope La key no tiene el scope printing:write
404 not_found El pedido no existe o pertenece a otro negocio — misma respuesta para ambos casos, a propósito: esta API nunca confirma la existencia de un recurso ajeno
409 conflict La cuenta separada pedida ya está pagada
409 idempotency_conflict Mismo Idempotency-Key, distinto body (o la operación original sigue en curso)
422 validation_error tip_suggested_pct fuera de 0–30, discount_pct fuera de 0–100, un número no finito (NaN/Infinity), note con más de 120 caracteres o con caracteres de control, split_number fuera de rango, o el pedido está anulado

8. Pago (registrar el cobro y cerrar el pedido)

Le informas a ExtraLatte que un pedido ya se cobró en tu caja y, en el mismo llamado, lo cierras. Es lo que le faltaba a una mesa que se paga al final: hasta hoy POST /orders solo aceptaba mark_paid al crear (sirve para pagar-primero — para llevar, delivery), y no existía ningún endpoint para informar el pago después. Sin este endpoint, un pedido de mesa se quedaba unpaid para siempre y nunca salía del tablero de cocina.

Antes de leer el resto, una aclaración de marco importante: tu sistema no tiene su propia impresión — nosotros somos tu infraestructura de impresión. Las comandas, la precuenta y el ruteo a estaciones salen por ExtraLatte a través de tu conector (cafe-printerd). Lo único que hoy emites tú es la boleta, y solo porque ExtraLatte todavía no emite DTE (la landing lo dice sin vueltas: "Por ahora lee tus facturas SII… La emisión de boletas y facturas está en nuestra hoja de ruta"). Por eso, al registrar un pago, el documento que importa es la boleta que emites — y por defecto ExtraLatte no imprime nada de su lado: ver print_receipt más abajo.

Requiere el scope orders:write — el mismo que ya usas para crear comandas (ver la nota de la tabla de scopes en §1: si tu key ya lo tenía, ganó esta capacidad al desplegar).

POST /orders/{order_id}/payment

Campos del body

Campo Tipo Req Descripción
method string El método de pago por nombre o por tipo — nunca un UUID nuestro. Ver "Resolución de method" abajo
amount number Ausente = el saldo pendiente completo (el mismo número que imprime la precuenta). Presente = debe calzar exacto con lo pendiente — ver "Por qué amount es un guardia" abajo
tip number Propina efectivamente dejada (no la sugerida de la precuenta). Default 0
split_number int Cobra solo esa cuenta separada (1..N). Ver "Cuentas separadas" abajo
card_token string Token de tarjeta de lealtad para acreditar sellos/puntos en el mismo cobro. Ver "card_token es fail-soft" abajo
close_order boolean Default true. Ver "Semántica de cierre" abajo
print_receipt boolean Default false. Ver "Por qué no imprimimos por defecto" abajo
curl -X POST https://extralatte.cl/api/v1/integration/orders/a1b2c3d4-.../payment \
  -H "Authorization: Bearer elk_…" -H "Content-Type: application/json" \
  -H "Idempotency-Key: pago-mesa5-20260813-01" \
  -d '{ "method": "efectivo", "tip": 1500 }'

Respuesta 200 OK

{
  "order_id": "a1b2c3d4-...",
  "payment_id": "9f0e1d2c-...",
  "payment_status": "paid",
  "closed": true,
  "total_paid": 15500,
  "tip": 1500
}

Si mandaste card_token, la respuesta suma dos campos:

{ "..." : "...", "reward_card_credited": true }

o, si el token no pudo acreditarse (ver "card_token es fail-soft"):

{ "..." : "...", "reward_card_credited": false, "reward_card_error": "..." }

Resolución de method — por nombre o tipo, nunca por UUID

Igual que el resto de esta API: nunca necesitas un id interno nuestro. method se resuelve contra los métodos de pago de tu propio negocio, sin distinguir mayúsculas ni acentos ("Débito" == "debito" == "DEBITO"), primero por nombre ("Efectivo", "Débito") y, si no matchea ningún nombre, por tipo (cash/card/transfer/…). El nombre gana sobre el tipo porque es el desempate real: un café con "Crédito" y "Débito" tiene dos métodos card, así que "card" a secas es ambiguo. Ante la ambigüedad, la API responde 422 listando las opciones — nunca elige por ti, porque adivinar mal archivaría la plata bajo el método equivocado y te corrompería el reporte en silencio.

Por qué amount es un guardia, no un adorno

amount es opcional; si lo omites, se cobra el saldo pendiente completo. Si lo mandas, debe calzar exacto con lo pendiente o la API responde 422. Esto no es un capricho: el servicio de pagos de ExtraLatte acepta sobrepago (solo rechaza amount <= 0), así que esta comparación es la única barrera contra registrar más plata de la que realmente se cobró — o menos. Esta API no acepta pagos parciales; existen en el POS de mostrador y están fuera de alcance a propósito.

print_receipt — por qué NO imprimimos nada de nuestro lado por defecto

print_receipt es false por defecto. Esto suprime solo dos cosas: la boleta térmica (ticket_cliente) y el aviso a la pantalla de cliente. Todo lo demás del cobro ocurre igual (inventario, cupones, lealtad, venta en el cajón si corresponde).

La razón, dicho sin vueltas por el marco de arriba: como nosotros somos tu infraestructura de impresión, un print_receipt: true sacaría un segundo papel no fiscal (ticket_cliente) al lado de la boleta que ya emitiste — dos comprobantes para el mismo cobro. Por eso el default es no imprimir nada de nuestro lado cuando registras un pago — no es una falla ni un silencio accidental, es la decisión de diseño. Si tu integración necesita igual el ticket de ExtraLatte (por ejemplo, no emites boleta propia para ese canal), manda print_receipt: true.

Excepción dura: print_receipt: true junto con split_number responde 422, no se ignora en silencio. El camino de cuentas separadas (pay_split) no encola ningún trabajo de impresión — nunca lo hizo y no lo hace ahora —, así que honrar true ahí sería prometer un papel que jamás sale de la impresora. Preferimos rechazar la combinación a mentir. Si necesitas el papel de una cuenta separada, pídelo por separado con la precuenta (§7, split_number), que sí es un documento por-persona.

Técnicamente esto usa un parámetro nuevo en el servicio (suppress_physical_outputs), NO el flag backdated que ya existía. Son dos conceptos distintos que comparten dos de tres efectos: backdated además decide si la venta postea al cajón según la fecha del pedido (una venta de ayer no debe caer en la caja de hoy), y esta API nunca quiso tocar esa regla — el cobro de un pedido abierto desde ayer sigue posteando al cajón con normalidad.

Semántica de cierre — close_order (default true)

Pago no es entrega. close_order: true estampa closed_at, que es lo único que las dos consultas del tablero de cocina, la de expedición y la pantalla de cliente filtran además del estado — así que cerrar el pedido drena el tablero. En ningún momento se escribe preparation_status = "delivered": nadie de este lado vio la comida llegar a la mesa, y la API no afirma algo que no observó.

close_order rechaza pedidos no pagados, así que el cierre solo aplica tras un pago completo — consistente con que esta API no acepta pagos parciales.

card_token es fail-soft

Si mandas card_token, ExtraLatte intenta acreditar el sello/punto de lealtad antes de registrar el pago. Si el token no existe, la tarjeta está inactiva, ya está adherida a otro pedido, o el pedido ya tiene algún pago — el crédito de lealtad no se acredita, pero el pago se registra igual. La respuesta te lo dice explícitamente (reward_card_credited: false + reward_card_error). La alternativa — dejar que un token mal tipeado aborte el cobro entero — convertiría "no se acreditó el sello" en "no se registró el pago", que es mucho peor.

Cuentas separadas

split_number (1..N, según cuántas cuentas tenga el pedido) cobra solo esa división, reusando el mismo camino que usa la pantalla de caja para dividir la cuenta. El pedido completo se cierra (si close_order: true) solo cuando todas las cuentas están pagadas — cobrar la cuenta 1 de 3 nunca cierra el pedido ni saca nada del tablero de cocina todavía. Encadena naturalmente con la precuenta: pides la precuenta de la persona 2 (§7, split_number: 2), cobras, informas el pago de la persona 2 con el mismo split_number.

Idempotencia

Igual patrón que el resto de la API, doblado porque es plata: el header Idempotency-Key reproduce la respuesta original ante un reintento, y por debajo el servicio toma un SELECT ... FOR UPDATE sobre el pedido — un segundo cobro sobre un pedido ya pagado responde 409 conflict, incluso sin mandar el header. Para cuentas separadas, el guardia es por split_number: cobrar dos veces la misma división también responde 409.

Errores propios de este endpoint

HTTP code Cuándo
403 forbidden_scope La key no tiene el scope orders:write
404 not_found El pedido no existe o pertenece a otro negocio — misma respuesta para ambos casos, a propósito (sin oráculo de existencia cross-tenant)
409 conflict El pedido (o la cuenta separada) ya está pagado; el pedido no tiene saldo pendiente; o un cobro sobre la misma orden está en curso
409 idempotency_conflict Mismo Idempotency-Key, distinto body (o la operación original sigue en curso)
422 validation_error method no existe o es ambiguo (lista las opciones); amount no calza con el saldo pendiente, es negativo o no es un número finito; split_number fuera de rango o inexistente; print_receipt: true junto con split_number; el pedido está anulado

Aviso operativo, heredado de cómo funciona la caja de ExtraLatte: si el local tiene dos cajas abiertas a la vez en la misma sucursal, una venta en efectivo postea a la sesión abierta más nueva, y un integrador headless no es "un cajero" que la sesión pueda identificar — con dos cajas abiertas, tu cobro puede caer en una sesión que el cajero que cierra el día no está mirando. La plata está en la caja del local de todos modos y el total del día cuadra igual; lo que puede cambiar es quién de los dos cajeros la ve en su arqueo. Preexistente al POS, mencionado acá porque este endpoint hace más fácil toparse con el caso (un integrador cobra sin ser "nadie" del lado de caja).


9. Lecturas (recorrer lo que ya existe)

Hasta acá la API se podía escribir pero no recorrer: existía GET /orders/{order_id}, pero para usarlo había que tener guardado el order_id. Un programa que los guarda no lo notaba; un asistente al que le dicen "cobra la mesa 5" quedaba ciego. Estos tres GET cierran ese hueco.

Los tres usan el scope read, devuelven solo tu negocio y hablan en tus propias claves: la mesa por su número, el método de pago por su nombre. El único identificador nuestro que aparece es order_id, que ya es parte del contrato desde POST /orders.

Un GET no consume Idempotency-Key: mandarlo no hace daño, pero tampoco hace nada. Leer dos veces es leer dos veces, no un reintento.

GET /orders — listar pedidos

curl "https://extralatte.cl/api/v1/integration/orders?status=open&limit=50" \
  -H "Authorization: Bearer elk_…"
{
  "items": [
    {
      "order_id": "a1b2…", "comanda_number": 12, "table": "5",
      "status": "in_kitchen", "payment_status": "unpaid",
      "total": 16500, "round": 2,
      "created_at": "2026-08-19T13:42:07.113000", "closed_at": null
    }
  ],
  "next_cursor": "MjAyNi0wOC0xOVQxMzo0MjowNy4xMTMwMDB8YTFiMg"
}

Sin líneas, a propósito. Las líneas del pedido están en GET /orders/{order_id}. Una lista que las trajera invitaría a bajar 500 pedidos completos para responder "qué mesas están abiertas".

Campo Qué es
order_id El id del pedido — el mismo que devolvió POST /orders
comanda_number El número de comanda del día (null si el pedido nunca entró a cocina)
table El número de mesa tuyo, tal cual lo mandaste en table. null si no es de mesa
status El estado de preparación, el mismo campo y los mismos valores que GET /orders/{order_id}: newin_kitchenreadydelivered, o cancelled
payment_status unpaid / partial / paid / refunded
total Total del pedido, todas las rondas incluidas
round En qué ronda va la mesa: 2 significa que disparó cocina dos veces (ver §6). Mínimo 1
created_at Cuándo se abrió el pedido — UTC, ISO-8601 sin zona
closed_at Cuándo se cerró, o null si sigue abierto

Filtros (todos opcionales, todos combinables, todos dentro de tu negocio):

Parámetro Valores Nota
status open, paid, closed, cancelled o new, in_kitchen, ready, delivered Dos vocabularios que no se pisan: los primeros cuatro son el ciclo de vida de la cuenta, los otros el estado de preparación — o sea, el mismo valor que trae el campo status de cada fila. Un valor desconocido es 422 con la lista de los válidos
table Tu número de mesa ("5") Un número que no es de tu negocio devuelve lista vacía, no 404
since / until Fecha ISO-8601 (2026-08-19T00:00:00) Sobre created_at, ambos inclusive. Con offset (…-04:00 o …Z) se convierte; sin offset se interpreta UTC
service_type dine_in, takeaway, delivery Un valor desconocido es 422
limit 1 a 200, default 50 Fuera de rango es 422, nunca un recorte silencioso: pedir 1000 y recibir 200 sin aviso te haría creer que tienes el día entero
cursor El next_cursor de la respuesta anterior Opaco: no lo parsees ni lo construyas. Uno inválido es 422

Orden: del más nuevo al más viejo.

Por qué cursor y no offset

Porque offset pagina por posición, y la posición se mueve. Si entra una comanda entre tu página 1 y tu página 2 — que es exactamente lo que pasa en un café a la hora punta — todas las filas bajan un lugar: ves un pedido dos veces y otro no lo ves nunca.

El cursor es la clave de orden de la última fila que te entregamos (created_at + order_id), y esa no se mueve. Un pedido nuevo ordena por encima del cursor y simplemente no está en las páginas que te faltan. Lleva las dos partes porque created_at solo no es único: dos comandas marcadas en el mismo milisegundo son de lo más normal al mediodía, y una clave no única saltea filas sin avisar.

Recorrer todo es pedir mientras next_cursor no sea null:

cursor = None
while True:
    qs = "limit=200" + (f"&cursor={cursor}" if cursor else "")
    page = GET(f"/orders?{qs}")
    for order in page["items"]:
        ...
    cursor = page["next_cursor"]
    if cursor is None:
        break

next_cursor viene con valor siempre que la página salió llena, así que la última página puede ser vacía. Es una llamada de más, no un error.

GET /payment-methods — los métodos que acepta el cobro

curl https://extralatte.cl/api/v1/integration/payment-methods \
  -H "Authorization: Bearer elk_…"
{
  "items": [
    { "name": "Crédito", "type": "card" },
    { "name": "Débito",  "type": "card" },
    { "name": "Efectivo", "type": "cash" }
  ]
}

Esto es lo que method acepta en POST /orders/{order_id}/payment (§8). Hasta ahora esa información existía pero solo se descubría equivocándose: mandabas un método cualquiera y leías el 422 que venía con la lista.

GET /tables — las mesas y qué pedido tienen encima

curl https://extralatte.cl/api/v1/integration/tables \
  -H "Authorization: Bearer elk_…"
{
  "items": [
    { "number": "5", "name": "5", "status": "occupied", "current_order_id": "a1b2…" },
    { "number": "6", "name": "6", "status": "available", "current_order_id": null }
  ]
}

current_order_id es el campo que convierte "cobra la mesa 5" en una llamada posible: lees la mesa, tomas el pedido y lo cobras con §8. Sin él había que adivinar.


10. Ejemplo completo (pseudocódigo)

# 1. Al abrir el turno: sync catálogo + inventario
for producto in mi_pos.productos_activos:
    PUT /products/{producto.sku}   { name, price, category, active:true }
for producto in mi_pos.productos_discontinuados:
    DELETE /products/{producto.sku}

for insumo in mi_pos.insumos:
    PUT /ingredients/{insumo.sku}  { name, unit, cost }
    PUT /ingredients/{insumo.sku}/stock { location:"Bodega", quantity, min_quantity }

# 2. Cada venta:
POST /orders  (Idempotency-Key: mi_ticket_id)
      { service_type:"dine_in", table:"5", items:[{sku, quantity}] }

# 3. Cambio de precio → re-upsert el producto:
PUT /products/{sku}  { name, price: nuevo_precio, category }

11. Notas y límites


## Changelog

- **v1.5 (2026-08-19):** **Lecturas** (§9) — `GET /orders`, `GET /tables` y
  `GET /payment-methods`, las tres con el scope existente `read`, sin scope
  nuevo y sin migración. Hasta acá la API se podía escribir pero no recorrer:
  existía `GET /orders/{order_id}` pero había que tener guardado el `order_id`,
  así que un asistente al que le dicen "cobra la mesa 5" no tenía por dónde
  empezar. `GET /orders` pagina **por cursor y no por offset** (un pedido nuevo
  entre página y página duplicaba una fila y escondía otra), filtra por
  `status` / `table` / `since` / `until` / `service_type`, y **no** trae las
  líneas del pedido — para eso sigue estando `GET /orders/{order_id}`. Su
  `limit` tiene tope duro de 200 y responde `422` en vez de recortar en
  silencio. `GET /tables` publica `current_order_id`, que es lo que convierte
  "cobra la mesa 5" en una llamada posible, y deriva `status` de los pedidos en
  vez de leer la marca de la mesa (que se queda pegada). `GET /payment-methods`
  publica lo que `method` acepta al cobrar, que antes solo se descubría
  equivocándose y leyendo el `422`; deliberadamente **no** trae un
  `is_default`, porque esa noción no existe en el producto. Plan:
  `.github/plans/skill-servido-y-lecturas-plan.md` §1.
- **v1.4 (2026-08-18):** **Mesa abierta** — `POST /orders/{order_id}/items`
  (scope existente `orders:write`) agrega una ronda a un pedido que sigue
  abierto e imprime **solo las líneas nuevas**, con la ronda en el encabezado
  ("Mesa 5 · ronda 2"); `print_full_order: true` imprime el pedido completo
  marcando lo nuevo. Acepta `modifiers` por línea igual que `POST /orders`.
  Con esto una mesa que pide tres veces vuelve a ser **un pedido y una
  cuenta** (una precuenta, un cobro) en vez de tres. Detrás hay una columna
  nueva, `order_items.dispatched_at` (migración `orderitemdispatch20260818`),
  que marca la línea cuando entra en una comanda: es lo que permite decir
  "solo lo nuevo". Agregar a un pedido pagado, cerrado o con pagos
  registrados es `409`. Plan:
  `.github/plans/mesa-abierta-y-modificadores-plan.md` §3.
- **v1.3 (2026-08-18, no desplegado — implementado en rama
  `feat/modificadores-mesa`):** **Agregados con precio** — el catálogo ahora
  acepta grupos de opciones y opciones dentro del `PUT /products/{sku}`
  (`option_groups`), cada opción con **tu propio `sku`** (nuevo
  `MenuOption.external_sku`, único por negocio, migración
  `menuoptsku20260818`). Sin scope nuevo: usa el existente `catalog:write`.
  Omitir `option_groups` no toca nada, así que una integración anterior sigue
  funcionando sin cambios, y un producto sin agregados conserva exactamente la
  forma de respuesta de siempre. Se agrega
  `DELETE /products/{sku}/options/{option_sku}`, que devuelve `409` si el
  agregado ya se vendió. Y la otra mitad, la que faltaba: `POST /orders`
  acepta `items[].modifiers` (por `sku` de opción) y **cobra** el agregado —
  `line_total = (base + Σ agregados) × cantidad`, precio congelado al momento
  del pedido, validación del grupo del lado del servidor. Plan:
  `.github/plans/mesa-abierta-y-modificadores-plan.md` §2.
- **v1.2 (2026-08-13, no desplegado — implementado en rama
  `feat/integration-pago`):** **Pago** — `POST
  /orders/{order_id}/payment` (scope existente `orders:write`, sin scope
  nuevo) registra el cobro de un pedido y opcionalmente lo cierra:
  resolución de `method` por nombre/tipo (nunca UUID), `amount` opcional
  que debe calzar exacto con el saldo pendiente, `tip`, cuentas separadas
  vía `split_number`, `card_token` fail-soft para lealtad, `close_order`
  (default `true`, estampa `closed_at` y **nunca** `delivered`), y
  `print_receipt` (default **`false`** — ExtraLatte no imprime nada de su
  lado al registrar un pago porque el integrador ya es dueño de su propia
  boleta; combinarlo con `split_number` es `422`, no un no-op silencioso).
  Idempotencia doblada (header + `SELECT ... FOR UPDATE`). **Cambio
  importante para keys existentes: toda API key ya emitida con el scope
  `orders:write` gana esta capacidad al desplegar, sin que el tenant vuelva
  a consentir** — ver la fila actualizada en §1. Sección "Flujo
  recomendado" (§3) actualizada: el ciclo ya no termina en la comanda,
  ahora es catálogo → inventario → comanda → precuenta → **pago**. Además
  en esta rama (documentado en `docs/modules.md`, no en esta guía porque
  no son parte del contrato de la API): `pay_split` alcanzó paridad con
  `process_payment` (Fase 1 — hasta ahora una cuenta dividida no descontaba
  inventario ni acreditaba lealtad, un defecto vivo del POS de mostrador,
  no solo de esta API) y el tablero de cocina se drena solo a las 24h
  (Fase 3). Rama `feat/integration-pago`, plan
  `.github/plans/integration-pago-cierre-plan.md`. Gate 337/337 PASS,
  hallazgo BLOCKING de `cafe-quality-review` remediado en el mismo arco
  (piso de activación del barrido automático — ver el plan). **Pendiente
  de merge a `dev` y de deploy.**
- **v1.1 (2026-08-12):** **Precuenta** — `POST /orders/{order_id}/precuenta`
  (scope nuevo `printing:write`) encola la impresión de la cuenta previa al
  cobro, con propina sugerida y descuento opcionales, cuentas separadas, y
  doble idempotencia (header + índice único en base de datos: pedir la misma
  precuenta dos veces nunca imprime dos veces). Sin reprint por API a
  propósito. Requiere el conector `cafe-printerd` v0.3.0+ para el formato
  "PRECUENTA" completo (un conector más viejo imprime con la boleta legacy).
  Rama `feat/precuenta-integration`, plan
  `.github/plans/precuenta-integration-plan.md`.
- **v1 (2026-07-28):** API keys OpenCore (bearer `elk_…`, scopes aplicados),
  catálogo (`/products`), inventario (`/ingredients`, `/stock`), comandas
  (`/orders`); upsert por SKU, idempotencia, errores uniformes. Desplegada en
  producción (`v0.30.0-integration-api`).