---
name: extralatte-api
description: Opera una cafetería en ExtraLatte por API — sincronizar catálogo e inventario, mandar comandas a la cocina, agregar rondas a una mesa abierta, consultar pedidos/mesas/métodos de pago, imprimir la precuenta y registrar el cobro. Úsalo cuando tengas que leer o escribir datos de un negocio en ExtraLatte con una API key `elk_…`. Incluye qué errores se reintentan y cuáles no, y qué llamadas tienen efectos físicos irreversibles en el local.
---

# ExtraLatte — API de Integración v1

Este documento es para el programa (o el asistente) que opera una cafetería
real desde afuera de ExtraLatte. No es un tutorial: es el conjunto mínimo de
reglas que hay que tener presentes para no romper el turno de un local.

**El contrato completo, campo por campo, vive en `/integration/guia`**
(`https://extralatte.cl/integration/guia`, o tu propio dominio en self-host).
La versión legible por máquina está en `/integration/openapi.json`. Cuando algo
de este documento y la guía no coincidan, **manda la guía**: es la que se
actualiza en el mismo cambio que el código.

---

## 1. El modelo mental, en una frase

**Catálogo → inventario → comanda → precuenta → pago**, y todo se direcciona
por **el SKU del negocio**, nunca por nuestros UUID.

Consecuencias que conviene interiorizar antes de escribir la primera línea:

- El producto tiene que existir en el catálogo **antes** de venderse.
- **El precio lo pone el servidor** desde el catálogo. Nunca lo mandas en la
  comanda. Si el precio está mal, el arreglo es re-sincronizar el producto.
- La única identidad nuestra que vas a manejar es el `order_id` que devuelve
  `POST /orders`. Guárdalo: es la llave de todo lo que le pase después a esa
  mesa. Mesas por número, productos e ingredientes por SKU, métodos de pago
  por nombre o tipo.
- Tu API key ve **un solo negocio**. Un recurso de otro negocio responde `404`,
  igual que uno que no existe.

---

## 2. Efectos físicos: esto no es una base de datos de pruebas

Léelo antes de llamar cualquier `POST`. Estas llamadas mueven cosas del mundo
real y **no hay deshacer por API**:

| Llamada | Qué pasa de verdad |
|---|---|
| `POST /orders` | **Sale papel** por la impresora de la cocina y aparece una comanda en la pantalla del cocinero. Alguien empieza a preparar comida. |
| `POST /orders/{order_id}/items` | **Sale papel de nuevo**: la ronda nueva se imprime en la estación que corresponde. |
| `POST /orders/{order_id}/precuenta` | **Sale papel en el mostrador**: el ticket de "cuánto se debe" que se le entrega a la mesa. |
| `POST /orders/{order_id}/payment` | **Mueve plata**: registra el cobro en la caja del local, **descuenta inventario**, acredita el sello de fidelidad y cierra el pedido. |
| `PUT /ingredients/{sku}/stock` | Fija el stock en un número absoluto. Un cero mal mandado deja productos sin poder venderse. |

No existe `DELETE /orders`, no existe anular un pago por esta API, y no existe
reimprimir una precuenta por API (pedir la misma dos veces devuelve el mismo
trabajo con `"deduplicated": true`, a propósito: un bug de reintentos no puede
convertirse en mil papeles en el mostrador). Lo que se hace mal se corrige a
mano, dentro del local, por una persona.

Por eso: **si no estás seguro de qué pedido es, consulta antes de escribir.**
Las lecturas de §6 existen exactamente para eso y no tienen ningún efecto.

Y una regla de oro: **manda `Idempotency-Key` en toda escritura.** Es lo único
que hace que un timeout de red no se convierta en dos comandas, dos rondas o
dos cobros.

---

## 3. Autenticación y permisos

```
Authorization: Bearer elk_<client_id>_<secret>
Content-Type: application/json
```

La llave la crea el dueño o el gerente del café desde **Configuración → API
keys**, y el secreto completo se muestra **una sola vez**. Es una credencial de
servidor: nunca en el navegador, nunca en un repo. Si se filtra, se revoca y se
emite otra.

Verifica la llave y sus permisos con `GET /ping` antes de cualquier otra cosa:

```bash
curl https://extralatte.cl/api/v1/integration/ping \
  -H "Authorization: Bearer elk_…"
```

| Scope | Permite |
|---|---|
| `read` | Leer catálogo, ingredientes, pedidos, mesas y métodos de pago |
| `catalog:write` | Crear, actualizar y deshabilitar productos |
| `inventory:write` | Upsert de ingredientes y stock |
| `orders:write` | Crear comandas, agregar rondas y **registrar el cobro** |
| `printing:write` | Encolar la impresión de una **precuenta** |

Un llamado fuera de scope responde `403 forbidden_scope`. **No lo reintentes**:
el arreglo es que el café marque el permiso en su panel. Los permisos se editan
sobre la llave existente, así que el secreto no cambia y tu integración no se
entera: no hay que re-desplegar nada.

---

## 4. Índice de rutas

Base: `https://extralatte.cl/api/v1/integration` (o `https://<tu-dominio>/api/v1/integration`).

| Método y ruta | Scope | Para qué |
|---|---|---|
| `GET /ping` | cualquiera | Verificar la llave y ver sus permisos |
| `GET /products` | `read` | Listar el catálogo sincronizado |
| `PUT /products/{sku}` | `catalog:write` | Crear o actualizar un producto (upsert por SKU), con sus grupos de opciones |
| `POST /products:batch` | `catalog:write` | Upsert masivo de productos |
| `DELETE /products/{sku}` | `catalog:write` | Deshabilitar un producto |
| `DELETE /products/{sku}/options/{option_sku}` | `catalog:write` | Sacar un agregado de un producto (`409` si ya se vendió) |
| `GET /ingredients` | `read` | Listar ingredientes |
| `PUT /ingredients/{sku}` | `inventory:write` | Crear o actualizar un ingrediente |
| `POST /ingredients:batch` | `inventory:write` | Upsert masivo de ingredientes |
| `PUT /ingredients/{sku}/stock` | `inventory:write` | Fijar stock **absoluto** en una ubicación |
| `POST /ingredients/{sku}/stock/adjust` | `inventory:write` | Ajustar stock por **delta** |
| `POST /orders` | `orders:write` | Abrir un pedido y mandarlo a la cocina |
| `POST /orders/{order_id}/items` | `orders:write` | Agregar una ronda al mismo pedido abierto |
| `GET /orders` | `read` | Listar pedidos del negocio, con filtros y cursor |
| `GET /orders/{order_id}` | `read` | Estado y líneas de un pedido |
| `GET /tables` | `read` | Mesas del negocio y qué pedido tiene cada una |
| `GET /payment-methods` | `read` | Métodos de pago activos del negocio |
| `POST /orders/{order_id}/precuenta` | `printing:write` | Imprimir la cuenta previa al cobro |
| `POST /orders/{order_id}/payment` | `orders:write` | Registrar el cobro y cerrar el pedido |

Los `GET` no consumen `Idempotency-Key` y no tienen ningún efecto.

---

## 5. Camino feliz, en curl

### Sincronizar un producto (con agregado propio)

```bash
curl -X PUT https://extralatte.cl/api/v1/integration/products/ICED-AME \
  -H "Authorization: Bearer elk_…" -H "Content-Type: application/json" \
  -d '{
        "name": "Iced Americano", "price": 2500, "category": "Bebidas Frías",
        "option_groups": [
          { "name": "Extras",
            "options": [ { "sku": "SHOT-EXTRA", "name": "Shot extra", "price": 500 } ] }
        ]
      }'
```

### Abrir la mesa

```bash
curl -X POST https://extralatte.cl/api/v1/integration/orders \
  -H "Authorization: Bearer elk_…" -H "Content-Type: application/json" \
  -H "Idempotency-Key: ticket-2026-08-19-000451" \
  -d '{
        "service_type": "dine_in",
        "table": "5",
        "items": [
          { "sku": "ICED-AME", "quantity": 1, "modifiers": ["SHOT-EXTRA"] },
          { "sku": "MED-001", "quantity": 2, "notes": "sin azúcar" }
        ]
      }'
```

Guarda el `order_id` de la respuesta `201`. Es lo único que necesitas después.

### La mesa pide de nuevo

```bash
curl -X POST https://extralatte.cl/api/v1/integration/orders/$ORDER_ID/items \
  -H "Authorization: Bearer elk_…" -H "Content-Type: application/json" \
  -H "Idempotency-Key: ticket-2026-08-19-000451-r2" \
  -d '{ "items": [ { "sku": "ICED-AME", "quantity": 1 } ] }'
```

Solo lo nuevo sale por la impresora, con la ronda en el encabezado
(`Mesa 5 · ronda 2`). El `total` que devuelve es el del pedido entero.

### La cuenta, y después el cobro

```bash
curl -X POST https://extralatte.cl/api/v1/integration/orders/$ORDER_ID/precuenta \
  -H "Authorization: Bearer elk_…" -H "Content-Type: application/json" \
  -H "Idempotency-Key: precuenta-mesa5-20260819-01" \
  -d '{ "tip_suggested_pct": 10 }'

curl -X POST https://extralatte.cl/api/v1/integration/orders/$ORDER_ID/payment \
  -H "Authorization: Bearer elk_…" -H "Content-Type: application/json" \
  -H "Idempotency-Key: pago-mesa5-20260819-01" \
  -d '{ "method": "efectivo", "tip": 1500 }'
```

`method` se resuelve por **nombre** ("Efectivo", "Débito") y, si ningún nombre
calza, por **tipo** (`cash`, `card`, `transfer`). Si queda ambiguo la API
responde `422` listando las opciones y **no elige por ti**: adivinar mal
archivaría la plata bajo el método equivocado. Para saber los nombres reales
antes de cobrar, consulta `GET /payment-methods`.

`amount` es opcional; si lo omites se cobra el saldo pendiente completo. Si lo
mandas, tiene que **calzar exacto** o es `422`. Es un guardia deliberado: el
servicio de pagos acepta sobrepago, y esta comparación es la única barrera
contra registrar más plata de la que se cobró.

---

## 6. Lecturas: cómo pasar de "cobra la mesa 5" a una llamada

Esta es la parte que un agente usa más y la que evita escribir a ciegas.

```bash
# ¿Qué mesa tiene qué pedido abierto?
curl "https://extralatte.cl/api/v1/integration/tables" -H "Authorization: Bearer elk_…"

# Pedidos abiertos, más reciente primero, paginado por cursor
curl "https://extralatte.cl/api/v1/integration/orders?status=open&limit=50" \
  -H "Authorization: Bearer elk_…"

# Los métodos de pago que este negocio tiene configurados
curl "https://extralatte.cl/api/v1/integration/payment-methods" -H "Authorization: Bearer elk_…"
```

Cómo usarlas bien:

- **"Cobra la mesa 5"** se resuelve con `GET /tables` → `current_order_id` →
  `POST /orders/{order_id}/payment`. Nunca infieras el pedido de una mesa
  listando pedidos y adivinando cuál es el último.
- `GET /orders` acepta `status`, `table`, `since`, `until` y `service_type`, y
  **no** trae las líneas de cada pedido: para el detalle está
  `GET /orders/{order_id}`.
- La paginación es por **cursor opaco** (`next_cursor`), no por offset. Sigue el
  cursor tal cual viene; no lo construyas ni lo interpretes. En un café en hora
  punta entran pedidos mientras paginas, y con offset eso duplica y saltea filas.
- En `GET /orders` el `limit` tiene tope duro y pedir más responde `422`, no un
  recorte silencioso: si pides 1000 y te devolvemos 200, tu código creería que
  llegó al final de la lista cuando no llegó.
- **`GET /products` y `GET /ingredients` todavía recortan en silencio** a 500.
  Es deuda histórica, no diseño, y se va a alinear con aviso previo. Regla que
  funciona en las tres listas: **decide si terminaste mirando `next_cursor`,
  nunca contando cuántos ítems pediste.**
- El contrato exacto de campos y filtros está en `/integration/guia`.

---

## 7. La máquina de estados de un pedido

```
new → in_kitchen → ready → delivered
```

`payment_status` es un eje **aparte** (`unpaid` / `paid`): un pedido puede estar
`ready` y sin pagar, o pagado y todavía en la cocina.

Agregar una ronda devuelve el pedido a `in_kitchen` aunque estuviera `ready`:
hay comida nueva que hacer.

Un pedido deja de admitir líneas nuevas en **tres** casos, y responden todos
`409 conflict` con el mismo mensaje de fondo:

| Situación | Por qué |
|---|---|
| Ya está pagado | Alguien pagó contra un total que la línea nueva movería |
| Ya está cerrado | Igual: el cierre ocurre tras el pago completo |
| Ya tiene pagos registrados (una cuenta separada cobrada) | Igual, aunque falte cobrar el resto |

La razón es una sola: **la plata ya se movió contra un total anterior.** La
respuesta correcta nunca es reintentar; es abrir un pedido nuevo. Un pedido
anulado es distinto: responde `422`.

Registrar el pago con `close_order` (default `true`) estampa el cierre y **saca
el pedido del tablero de cocina**. Nunca marca `delivered`: nadie de este lado
vio la comida llegar a la mesa, y la API no afirma lo que no observó.

---

## 8. Errores: qué se reintenta y qué no

Todos los errores tienen la misma forma:

```json
{ "error": { "code": "invalid_unit", "message": "La unidad 'kilo' no es válida.", "field": "unit" } }
```

**Decide siempre sobre `code`** (estable, en inglés). El `message` es español
para humanos y puede cambiar sin aviso.

| HTTP | `code` | ¿Reintentar? | Qué hacer |
|---|---|---|---|
| 401 | `unauthorized` | **No** | La llave falta, está mal escrita, fue revocada o la IP no está autorizada. Reintentar no la arregla. |
| 403 | `forbidden_scope` | **No** | Falta un permiso. Pídele al café que lo marque en su panel; la llave no cambia. |
| 404 | `not_found` | **No** | El SKU o el pedido no existe en este negocio. Sincroniza el catálogo o revisa el `order_id`. |
| 409 | `conflict` | **Nunca** | El recurso está en un estado que no permite la operación (pedido ya pagado, cuenta ya cobrada). Reintentar solo repite el mismo `409`. Cambia de plan: abre un pedido nuevo, o lee el estado. |
| 409 | `idempotency_conflict` | **Depende** | Si el mensaje dice que la operación original **sigue en curso**: espera y reintenta con **la misma** `Idempotency-Key`. Si es "mismo key, distinto body": **no** reintentes — tu código reusó una key para otra operación; es un bug tuyo. |
| 422 | `validation_error` | **No** | El body está mal (campo faltante, fuera de rango, `method` ambiguo, `amount` que no calza). Mira `field`, arregla, y vuelve a mandar **una operación corregida**, no el mismo request. |
| 422 | `invalid_unit` | **No** | Unidad no soportada. Corrige la unidad. |
| 429 | `rate_limited` | **Sí** | Espera lo que dice el header `Retry-After` y reintenta con backoff exponencial. Es el único código que se reintenta tal cual. |
| 5xx | sin código estable (o timeout de red) | **Sí** | Reintenta con **la misma** `Idempotency-Key`. Sin ese header no sabes si la operación alcanzó a ejecutarse, y reintentar puede duplicar una comanda o un cobro. |

Regla corta: **solo se reintentan `429`, los 5xx y los timeouts**, y solo con la
misma `Idempotency-Key`. Todo lo demás es una decisión que tienes que cambiar
antes de volver a llamar.

---

## 9. Los cinco errores que ya pasaron en producción

Ninguno es hipotético. Cada uno se observó en un café real.

### 1. Un pedido por ronda

**Síntoma:** la mesa 5 tiene tres `order_id`, tres totales, tres precuentas y
tres cobros. Pedir "la cuenta de la mesa 5" es imposible sin sumar a mano.

**Causa:** llamar `POST /orders` cada vez que la mesa pide de nuevo.

**Correcto:** el primer pedido abre la mesa; las rondas siguientes van con
`POST /orders/{order_id}/items`. Una mesa es **un pedido y una cuenta**.

### 2. Mandar el precio en la comanda

**Síntoma:** el precio se ignora y la venta queda con otro número; o el ticket
no cuadra con lo que el cliente vio.

**Causa:** creer que la comanda lleva precios.

**Correcto:** el precio siempre sale del catálogo sincronizado. Si cambió,
`PUT /products/{sku}` con el precio nuevo **antes** de vender.

### 3. El agregado como producto suelto, o metido en `notes`

**Síntoma:** salen **dos comandas** por un solo café (una por el producto, otra
por el "agregado-producto", que cae en la impresora por defecto); o el agregado
va en `notes`, no se cobra, y el local lo regala todo el día.

**Causa:** modelar "leche de almendra" como un producto aparte, o como texto.

**Correcto:** el agregado vive **dentro** del producto (`option_groups` en
`PUT /products/{sku}`, con su propio SKU) y se manda **dentro de la línea**
(`items[].modifiers`). Una comanda, el precio cobrado solo:
`line_total = (base + Σ agregados) × cantidad`.

### 4. Reintentar sin `Idempotency-Key`

**Síntoma:** dos comandas idénticas en la cocina, o una ronda cobrada dos veces.

**Causa:** un timeout de red, un reintento automático del cliente HTTP, y
ninguna llave de idempotencia.

**Correcto:** una `Idempotency-Key` única **por operación** (no por reintento).
Ojo con `POST /orders/{order_id}/items`: agregar líneas **no** es idempotente
por naturaleza — una mesa puede pedir el mismo café dos veces en dos minutos y
eso es legítimo, así que sin la llave el servidor agrega las líneas dos veces,
a propósito. No puede adivinar tu intención.

### 5. Creer que `mark_paid` al crear es la única forma de cobrar

**Síntoma:** los pedidos de mesa quedan `unpaid` para siempre y nunca salen del
tablero de cocina; las ventas de mesa no aparecen en los reportes.

**Causa:** `mark_paid` solo existe **al crear** y sirve para pagar-primero
(para llevar, delivery). Para una mesa que paga al final no aplica.

**Correcto:** `POST /orders/{order_id}/payment` cuando efectivamente se cobró.
Eso es lo que descuenta inventario, acredita lealtad, libera la mesa y drena el
tablero.

---

## 10. Qué NO hacer

- **No adivines precios.** Si no está en el catálogo, sincronízalo primero.
- **No inventes SKU.** Un SKU que no existe es `404`, y crear uno al vuelo para
  "salir del paso" ensucia el catálogo del café para siempre.
- **No reintentes un `409`.** Ni `conflict` ni un `idempotency_conflict` de
  "distinto body". El estado no va a cambiar porque insistas.
- **No trates `POST /orders/{order_id}/precuenta` como una consulta.** Es una
  impresión: sale papel en el mostrador. Para saber cuánto debe una mesa,
  `GET /orders/{order_id}`.
- **No pongas la API key en el navegador.** Es servidor-a-servidor, sin CORS.
- **No mandes `print_receipt: true` junto con `split_number`**: es `422`, no un
  no-op. Y en general no lo mandes: por defecto ExtraLatte no imprime nada al
  registrar un pago, porque la boleta la emite el sistema del café y dos papeles
  para un mismo cobro confunden al cliente.
- **No borres ni "limpies" catálogo para arreglar un error de sync.**
  `DELETE /products/{sku}` deshabilita el producto del menú del local, en vivo.
- **No supongas que un `200` que nunca llegó no ocurrió.** Ante un timeout,
  reintenta con la misma `Idempotency-Key` o consulta con un `GET`.

---

## 11. Dónde seguir

- **Contrato completo (campos, respuestas, errores por endpoint):**
  `/integration/guia` — o `/integration/guia.md` en texto plano.
- **Spec OpenAPI (para generar un cliente tipado):** `/integration/openapi.json`.
- **Qué cambió y qué todavía no existe:** `/integration/novedades`.
- **Este documento, siempre al día:** `/integration/skill.md`.

Lo que **no** tenemos, para que no lo busques: no hay servidor MCP, no hay
webhooks salientes de "pedido listo" (hoy eso se resuelve con polling sobre
`GET /orders`), y ExtraLatte todavía no emite boleta electrónica.
