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).
- Base URL (producción):
https://extralatte.cl/api/v1/integration - Base URL (self-host):
https://<tu-dominio>/api/v1/integration - Formato: JSON (
Content-Type: application/json). Campos y códigos en inglés; los mensajes de error vienen en español. - Clave natural: todo se identifica por tu propio
sku(tu código de producto/ingrediente). ExtraLatte hace upsert por(negocio, sku)— nunca necesitas manejar UUIDs internos nuestros. - Lo nuevo (para el dueño del café):
/integration/novedades— qué cambió, dónde se configura en el panel, y la lista honesta de lo que todavía no funciona. - Spec OpenAPI (para generar cliente):
https://extralatte.cl/integration/openapi.json— impórtala en tu generador (openapi-generator, un GPT Action, Postman, etc.) para tener un cliente tipado en minutos.
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
- Si omites
option_groups, no se toca nada. Un sync de precio no borra tus agregados. Esto es lo que hace que una integración escrita antes de esta versión siga funcionando igual. - Si lo mandas, es un upsert: los grupos se emparejan por
namedentro del producto y las opciones por tusku. Lo que no nombres queda como está — nada se borra solo. - Un
skude agregado pertenece a un solo producto. Reusarlo en otro producto es422, no un traslado silencioso. 422también ante:max_selected < min_selected,max_selected < 1,min_selected < 0,is_requiredconmin_selected: 0, grupo sin nombre,skuvacío o repetido en el mismo envío, ypricenegativo.
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
kgy 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 .../stockoPOST .../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": [] }
]
}
comanda_numberes el número diario legible (reinicia cada día por local).- El pedido aparece en la pantalla de cocina (KDS) y se encola su comanda de impresión al instante.
- Si un
skuno existe:404 not_foundconfield: "items[0].sku"— sincroniza el catálogo primero.
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).
- El agregado tiene que pertenecer a ese producto: una opción de otro
producto (o de otro café) es
422 validation_errorconfield: "items[0].modifiers[0]". min_selected/max_selected/is_requireddel grupo se validan acá también: si el grupo es obligatorio y no mandas ninguna opción, es422confield: "items[0].modifiers". Tu pantalla puede validar lo mismo, pero la que decide es esta.
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 repitemodifierspor 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 elline_totalsuman 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 dePOST /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 }] }
]
}
roundes la ronda que acabas de mandar:2es la segunda vez que esa mesa dispara cocina.itemsson solo las líneas de esta llamada. El pedido completo lo lees conGET /orders/{order_id}.totales el del pedido entero, todas las rondas incluidas — eso es exactamente lo que ganas al agregar en vez de abrir otro pedido: una sola precuenta y un solo cobro por la mesa.- El pedido vuelve a
in_kitchenaunque estuvieraready: hay comida nueva que hacer.
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:
Idempotency-Key: reintentar con la misma key devuelve la respuesta original, sin agregar nada. Es la única protección contra duplicar líneas.- El
event_keyde 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: new → in_kitchen → ready → delivered.
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, verdocs/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 campois_precuentaes 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
Idempotency-Key(igual que el resto de la API): reintentar con la misma key devuelve la respuesta original tal cual, sin encolar de nuevo.- La que no vas a esperar: pedir la precuenta de el mismo
order_id+ el mismosplit_number(o ninguno, para el pedido completo) una segunda vez SIEMPRE devuelve el mismoprint_job_idcon"deduplicated": true— aunque no mandesIdempotency-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
split_numberausente → la precuenta es del pedido completo.split_numberpresente → la precuenta es solo de esa persona: sus ítems, su subtotal y su descuento prorrateado (calculados desde la misma lógica que usa la pantalla de caja para dividir la cuenta). Debe estar entre1y la cantidad de cuentas en las que se dividió el pedido.- Pedir la precuenta de una cuenta separada que ya está pagada responde
409 conflict— no es un error silencioso, es tu integración pidiendo el "cuánto debe" de algo que ya se cobró.
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 tú
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 tú 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}: new → in_kitchen → ready → delivered, 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_cursorviene 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.
- Solo los activos. Un método desactivado es justamente el que el cobro
rechaza; listarlo sería una invitación a un
422. namees el nombre que le puso el café ("Efectivo", "Débito");typees la familia (cash,card,transfer,mercadopago, …).methodacepta cualquiera de los dos, y el nombre gana — si hay dos métodoscard, mandar"card"es ambiguo y responde422.- No hay campo
is_default, a propósito. ExtraLatte no tiene un "método predeterminado": no existe la columna ni el interruptor en el panel. Publicar uno derivado (el único método en efectivo, por ejemplo) le diría "tu default es Efectivo" a un café cuyo default real es Débito. Si tu flujo necesita un predeterminado, elígelo tú y guárdalo de tu lado.
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.
numberes lo que mandas comotableenPOST /orders. ExtraLatte guarda un solo rótulo por mesa ("5", "Barra 2"), así que hoynumberynametraen el mismo texto; usanumberpara volver a llamarnos.status:occupiedcuando la mesa tiene un pedido vivo (sin cerrar y sin anular),availablecuando no.reservedyblockedsalen tal como los dejó el café en el panel: son decisiones de una persona, no hechos sobre pedidos.statusse deriva de los pedidos, no se lee de la mesa. La marca de ocupación de la mesa se ha quedado pegada en la práctica (mesas "ocupadas" durante meses porque nunca se cerró su pedido), y una mesa que dijeraoccupiedconcurrent_order_id: nullno le sirve a nadie.- Si una mesa arrastra más de un pedido abierto,
current_order_ides el más reciente.
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
- Multi-tenant: tu key solo alcanza tu negocio. Un
skude otro negocio no existe para ti (404). - Sin CORS del navegador: es una API servidor-a-servidor. No la llames desde JS en el navegador.
- Idempotencia: guarda un
Idempotency-Keypor operación (sobre todo comandas) para que un reintento no duplique. - Errores: revisa siempre
error.code(estable, en inglés) antes que elmessage(español, puede cambiar). - Webhooks (futuro/complemento): ExtraLatte puede además notificarte a ti
(pedido listo, stock bajo) vía webhooks salientes — ver
docs/api.md§Webhooks.
## 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`).