> ## Documentation Index
> Fetch the complete documentation index at: https://docs.laportenard.com/llms.txt
> Use this file to discover all available pages before exploring further.

# External API

> A key-authenticated HTTP API on the sync hub for integrations: orders, tables, webhooks and plugin events.

A read/write HTTP API on the POS hub for anything that is not a POS terminal: n8n
automations, QR self-ordering pages, delivery integrations, internal dashboards.

Orders you create through this API appear **live** on the restaurant's terminals —
on the floor plan and in the open check — because the hub is the same authority
the terminals sync against. You also get webhooks when orders or tables change.

***

## 1. Overview

### What the hub is

`nu_pos_hub` is a small Node.js server that runs on the restaurant's local
network. POS terminals hold a WebSocket to it; it owns live order and table state
and forwards completed orders to Odoo. When the POS runs in **hub mode**, the hub
is the live source of truth for the shift — not Odoo, which only sees orders once
they are paid and forwarded.

### When the API is available

| Requirement                                 | Why                                                                        |
| ------------------------------------------- | -------------------------------------------------------------------------- |
| The hub is running and reachable on the LAN | It is a local server; it is not exposed to the internet by default         |
| The POS is in hub mode                      | Otherwise terminals talk to Odoo directly and the hub holds no live state  |
| A POS session is open on a terminal         | Order creation is refused with `NO_ACTIVE_SESSION` while the POS is closed |
| At least one API key exists                 | The whole namespace answers `401` until a manager mints one                |

### Base URL

```
http://<hub-host>:8766
```

`8766` is the default (`HUB_HTTP_PORT`). HTTP and the terminals' WebSocket share
this port. The hub speaks plain HTTP on the LAN — if you need TLS, terminate it
in front of the hub.

All external endpoints live under `/external/v1/`. Key administration lives on
the hub's own admin surface at `/external-keys` (see §2.1).

### Server-to-server only

The external namespace deliberately returns **no CORS headers**. A browser page
cannot call it — `fetch()` from a web app will fail the preflight/opaque check no
matter which origin it comes from. Call it from a server, a worker, an n8n node,
or a device you control. This is intentional: an API key that can create orders
must not be shipped to a browser anyway.

Requests are plain JSON:

```
Content-Type: application/json
X-Api-Key: nupos_9f3a2c1d_5b1e…
```

Every response is JSON. Every failure carries the same envelope:

```json theme={null}
{ "error": "human readable sentence", "errorCode": "MACHINE_CODE" }
```

Branch on `errorCode`, never on the sentence.

***

## 2. Authentication

### 2.1 Minting a key

Keys are minted by an **operator with an Odoo manager token** — an API key can
never mint another key. The manager token is the same Bearer token a manager's
POS session uses (the hub validates it against Odoo).

```bash theme={null}
curl -X POST http://192.168.1.50:8766/external-keys \
  -H "Authorization: Bearer <odoo-manager-token>" \
  -H "Content-Type: application/json" \
  -d '{
        "label": "QR ordering",
        "scopes": ["read", "orders:write", "webhooks:manage"]
      }'
```

```json theme={null}
{
  "keyId": "9f3a2c1d",
  "token": "nupos_9f3a2c1d_5b1e0a77c4d93f2168ab5cd0e7194f3a"
}
```

**The token is shown exactly once.** The hub stores only a SHA-256 digest of the
secret half; there is no "show token again" endpoint. Lose it and you mint a new
key.

Token format: `nupos_{keyId}_{secret}`. The `keyId` is public — it identifies the
key in logs, in order references (`EXT/{keyId}/…`) and in the `origin` field of
webhooks. The secret half is not.

### 2.2 Presenting the key

Either header works:

```
X-Api-Key: nupos_9f3a2c1d_5b1e…
Authorization: Bearer nupos_9f3a2c1d_5b1e…
```

`Authorization: Bearer` is only read when the value starts with `nupos_` — the
POS terminals send Odoo session tokens on that header and those are never handed
to the API-key store.

Every endpoint under `/external/v1/` requires a key, including the read-only
GETs. There is no anonymous surface.

### 2.3 Scopes

| Scope             | Grants                                                                                                        |
| ----------------- | ------------------------------------------------------------------------------------------------------------- |
| `read`            | `GET /external/v1/session`, `/tables`, `/floor-plan`, `/orders`, `/orders/:ref`, `/plugins/:pluginId/events…` |
| `orders:write`    | `POST /external/v1/orders`, `PATCH /external/v1/orders/:ref`                                                  |
| `webhooks:manage` | All `/external/v1/webhooks…` routes                                                                           |
| `plugins:write`   | `POST`/`DELETE` on `/external/v1/plugins/:pluginId/events…` (§7)                                              |

Scopes are per key and fixed at mint time; to change them, mint a new key and
delete the old one. A key missing the required scope gets `403 FORBIDDEN` with
the scope it needed:

```json theme={null}
{
  "error": "API key is missing the 'orders:write' scope",
  "errorCode": "FORBIDDEN",
  "requiredScope": "orders:write"
}
```

Give each integration the narrowest set that works. A dashboard needs `read`. A
QR-ordering front end needs `read` + `orders:write`. Only the piece that manages
webhook subscriptions needs `webhooks:manage`.

### 2.4 Rate limits

Two independent budgets, both **120 requests per minute** by default
(`HUB_EXTERNAL_RATE_MAX`):

* **per peer IP** — checked first, before the key is even looked up;
* **per API key** — so one noisy integration cannot starve the others.

Over budget → `429` with `errorCode: "RATE_LIMITED"`. There is no
`Retry-After` header; back off and retry after a few seconds. The window is a
rolling minute.

The key-admin routes (`/external-keys…`) are additionally limited to **30
requests per minute per IP**, because each one costs the hub a token validation
round-trip to Odoo.

Request bodies are capped at **256 KiB** → `413 PAYLOAD_TOO_LARGE`.

### 2.5 Managing keys

All four routes require the Odoo manager token (not an API key).

| Method   | Path                    | Body                                   | Response                                           |
| -------- | ----------------------- | -------------------------------------- | -------------------------------------------------- |
| `GET`    | `/external-keys`        | —                                      | `{ "keys": [ … ] }` — metadata only, never a token |
| `POST`   | `/external-keys`        | `{ "label": "…", "scopes": ["read"] }` | `201 { "keyId", "token" }`                         |
| `PATCH`  | `/external-keys/:keyId` | `{ "enabled": false }`                 | `200 { "keyId", "enabled" }`                       |
| `DELETE` | `/external-keys/:keyId` | —                                      | `200 { "deleted": true }`                          |

`GET /external-keys`:

```json theme={null}
{
  "keys": [
    {
      "keyId": "9f3a2c1d",
      "label": "QR ordering",
      "scopes": ["read", "orders:write", "webhooks:manage"],
      "enabled": true,
      "createdAt": "2026-08-15 18:20:11",
      "lastUsedAt": "2026-08-15 18:44:02"
    }
  ]
}
```

`lastUsedAt` is stamped at most once per key per minute — it is an operator
signal ("is this integration still alive?"), not an audit log.

**Disable vs delete.** `PATCH … {"enabled": false}` keeps the row (and its
history) but makes the token dead immediately — every request with it answers
`401 UNAUTHENTICATED`. `DELETE` removes it entirely. Prefer disabling while you
investigate; delete when the integration is retired.

***

## 3. Endpoint reference

Fifteen external endpoints plus the four admin routes above.

| Method   | Path                                         | Scope             | What it answers                                             |
| -------- | -------------------------------------------- | ----------------- | ----------------------------------------------------------- |
| `GET`    | `/external/v1/session`                       | `read`            | Is the POS open?                                            |
| `GET`    | `/external/v1/tables`                        | `read`            | What is happening on the tables the hub has seen this shift |
| `GET`    | `/external/v1/floor-plan`                    | `read`            | **Which tables exist, with the `tableId` you must send**    |
| `GET`    | `/external/v1/orders`                        | `read`            | Order summaries (`?status=active\|all`)                     |
| `GET`    | `/external/v1/orders/:ref`                   | `read`            | One order, full snapshot                                    |
| `POST`   | `/external/v1/orders`                        | `orders:write`    | Create a check                                              |
| `PATCH`  | `/external/v1/orders/:ref`                   | `orders:write`    | Edit a check                                                |
| `GET`    | `/external/v1/webhooks`                      | `webhooks:manage` | List subscriptions                                          |
| `POST`   | `/external/v1/webhooks`                      | `webhooks:manage` | Subscribe                                                   |
| `DELETE` | `/external/v1/webhooks/:id`                  | `webhooks:manage` | Unsubscribe                                                 |
| `POST`   | `/external/v1/webhooks/:id/test`             | `webhooks:manage` | Send a test delivery                                        |
| `GET`    | `/external/v1/plugins/:pluginId/events`      | `read`            | List a plugin's live records (§7)                           |
| `GET`    | `/external/v1/plugins/:pluginId/events/:key` | `read`            | One record                                                  |
| `POST`   | `/external/v1/plugins/:pluginId/events`      | `plugins:write`   | Create or replace a record                                  |
| `DELETE` | `/external/v1/plugins/:pluginId/events/:key` | `plugins:write`   | Remove a record                                             |

The `/plugins` routes answer `404` unless the operator has enabled that plugin id
(§7.1) — on a hub with none enabled, the whole subtree does not exist.

Throughout, examples use key id `9f3a2c1d` and the order reference
`EXT/9f3a2c1d/ORDER-1042`.

### 3.1 URL-encoding order references

A `posReference` contains slashes — `EXT/9f3a2c1d/ORDER-1042`,
`POS/2026/0007`. **URL-encode it whole** when it appears in a path:

```
GET /external/v1/orders/EXT%2F9f3a2c1d%2FORDER-1042
```

The hub decodes everything after `/orders/` as one reference, so `%2F` is
required — an un-encoded slash is a different path. In JavaScript:
`encodeURIComponent(posReference)`.

***

### `GET /external/v1/session`

Scope `read`. Is the POS open? Check this before creating orders.

```json theme={null}
{ "active": true, "sessionId": "42", "sessionName": "POS/2026/0042" }
```

Closed:

```json theme={null}
{ "active": false }
```

***

### `GET /external/v1/tables`

Scope `read`. Every table the hub knows about, with its live status and any
terminal lock.

```json theme={null}
{
  "tables": [
    {
      "tableId": 12,
      "status": "occupied",
      "posReference": "EXT/9f3a2c1d/ORDER-1042",
      "updatedAt": "2026-08-15 18:30:04",
      "lock": null
    },
    {
      "tableId": 13,
      "status": "available",
      "posReference": null,
      "updatedAt": "2026-08-15 17:02:55",
      "lock": {
        "userName": "Ana",
        "terminalId": "terminal-1",
        "expiresAt": "2026-08-15 18:31:20"
      }
    }
  ]
}
```

* `status` is `available` | `occupied` | `reserved`.
* `posReference` is the last order that touched the table (still meaningful when
  the table is `available` — it is the check that just left).
* `lock` is non-null only while a waiter has the table open on a terminal, and
  expired locks are reported as `null`. Locks are advisory; the API does not let
  you take or release them (out of scope for v1).
* Tables appear here once the hub has seen them. A floor with no activity since
  the hub started can legitimately return an empty list.
* This is *not* the floor plan — a table nobody has touched is missing here.
  Use `/external/v1/floor-plan` to discover tables.

***

### `GET /external/v1/floor-plan`

Scope `read`. **The floor plan as Odoo has it** — every floor, every table, and
the `id` you must send as `tableId` when creating an order. This is the endpoint
that answers "which number do I use?".

The hub reads it from Odoo and caches it (60s by default, `HUB_FLOOR_PLAN_CACHE_MS`),
so polling it is cheap. Live `status` and `lock` are merged in from the hub, so
one call tells you both what exists and what is busy.

```json theme={null}
{
  "stale": false,
  "fetchedAt": "2026-08-15T18:00:00.000Z",
  "floors": [
    {
      "id": 1,
      "name": "Terraza",
      "tables": [
        {
          "id": 12,
          "name": "Mesa 5",
          "seats": 4,
          "floorId": 1,
          "status": "occupied",
          "lock": {
            "userName": "Ana",
            "terminalId": "terminal-1",
            "expiresAt": "2026-08-15 18:31:20"
          }
        },
        {
          "id": 13,
          "name": "Mesa 6",
          "seats": 2,
          "floorId": 1,
          "status": "available",
          "lock": null
        }
      ]
    }
  ]
}
```

* **`id` is the `tableId` to send.** Note the example: the table *named* "Mesa 5"
  is record `12`. That mismatch is the entire reason this endpoint exists.
* `status` is the hub's live state, defaulting to `available` for a table the hub
  has not seen this shift. `lock` follows the same rules as `/tables` (advisory,
  `null` when absent or expired).
* `fetchedAt` is when the plan was last actually read from Odoo, not now.
* `stale: true` means Odoo could not be reached on the last refresh and this is
  the hub's last good copy. Still usable — floor plans change a few times a year.
* `503 UPSTREAM_UNAVAILABLE` when the hub cannot reach Odoo *and* has never
  cached a plan (typically the hub was started while Odoo was down). Retry.
* `503 NOT_CONFIGURED` when this hub build has no Odoo credentials wired for
  floor-plan reads. Operator issue.

***

### `GET /external/v1/orders`

Scope `read`. Order **summaries**, newest activity first.

Query: `?status=active` (default) or `?status=all`. `active` excludes `paid` and
`closed` orders. Anything else → `400 VALIDATION_ERROR`.

```json theme={null}
{
  "orders": [
    {
      "posReference": "EXT/9f3a2c1d/ORDER-1042",
      "version": 3,
      "tableId": 12,
      "status": "open",
      "orderType": "dine_in",
      "ticketNumber": null,
      "customerName": "QR table 12",
      "itemCount": 3,
      "totals": { "subtotal": 559.32, "tax": 100.68, "total": 660 },
      "updatedAt": "2026-08-15 18:31:00",
      "origin": "ext:9f3a2c1d"
    }
  ]
}
```

`origin` is who wrote the order last: a terminal id, or `ext:{keyId}` for an
API-key writer.

***

### `GET /external/v1/orders/:ref`

Scope `read`. The full stored snapshot. **This is where you get `version` for a
`PATCH`.**

```
GET /external/v1/orders/EXT%2F9f3a2c1d%2FORDER-1042
```

```json theme={null}
{
  "posReference": "EXT/9f3a2c1d/ORDER-1042",
  "version": 3,
  "tableId": 12,
  "terminalId": "ext:9f3a2c1d",
  "createdAt": "2026-08-15 18:30:04",
  "updatedAt": "2026-08-15 18:31:00",
  "snapshot": {
    "posReference": "EXT/9f3a2c1d/ORDER-1042",
    "status": "open",
    "orderType": "dine_in",
    "tableId": 12,
    "customerName": "QR table 12",
    "notes": "birthday table",
    "guestCount": 2,
    "staffName": "QR ordering",
    "checkNumber": 1,
    "createdAt": "2026-08-15T18:30:04.000Z",
    "updatedAt": "2026-08-15T18:31:00.000Z",
    "lines": [
      {
        "id": "ext-0-4f2a",
        "productId": "101",
        "quantity": 3,
        "unitPrice": 150,
        "total": 450,
        "taxIds": ["3"],
        "taxAmount": 68.64,
        "note": "extra cheese",
        "kitchenSentAt": "2026-08-15T18:33:00.000Z",
        "sentQuantity": 3
      }
    ],
    "totals": { "subtotal": 559.32, "tax": 100.68, "total": 660 }
  }
}
```

Unknown reference → `404 NOT_FOUND`.

The snapshot is the POS's own order format, so it can contain more fields than
you sent (kitchen provenance, discount audit, payment lines on a paid order,
fiscal data). Read what you need and ignore the rest; do not assume the set is
closed.

The two timestamp styles are not a bug: the hub's SQLite columns
(`createdAt`/`updatedAt` at the top level) are `YYYY-MM-DD HH:MM:SS` UTC, while
timestamps *inside* the snapshot are ISO-8601 as the POS wrote them.

***

### `POST /external/v1/orders`

Scope `orders:write`. Opens a check. Read §4 before writing this call — the
constraints are where integrations actually get stuck.

Request:

```json theme={null}
{
  "externalRef": "ORDER-1042",
  "orderType": "dine_in",
  "tableId": 12,
  "customerName": "QR table 12",
  "notes": "allergy: peanuts",
  "guestCount": 2,
  "staffName": "QR kiosk",
  "lines": [
    {
      "productId": "101",
      "quantity": 2,
      "unitPrice": 150,
      "total": 300,
      "taxIds": ["3"],
      "taxAmount": 45.76,
      "note": "extra cheese"
    },
    {
      "productId": "205",
      "quantity": 1,
      "unitPrice": 90,
      "total": 90,
      "taxAmount": 13.73,
      "specialRequest": "no ice"
    }
  ],
  "totals": { "subtotal": 330.51, "tax": 59.49, "total": 390 }
}
```

| Field          | Type                                 | Notes                                                                                                                                                                                                                              |
| -------------- | ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `externalRef`  | string, optional                     | Idempotency key. `1–64` chars of `A-Z a-z 0-9 _ -`. Becomes part of the reference.                                                                                                                                                 |
| `orderType`    | `dine_in` \| `takeout` \| `delivery` | Defaults to `dine_in`.                                                                                                                                                                                                             |
| `tableId`      | positive int                         | **Required for `dine_in`**. Optional for takeout/delivery — but if you send it, the order occupies that table exactly like a dine-in check. Odoo `restaurant.table` id, from `GET /external/v1/floor-plan`. See the warning below. |
| `customerName` | string ≤ 120                         | Shown on the check.                                                                                                                                                                                                                |
| `notes`        | string ≤ 1000                        | Order-level note.                                                                                                                                                                                                                  |
| `guestCount`   | positive int                         | Defaults to `1`.                                                                                                                                                                                                                   |
| `staffName`    | string ≤ 120                         | Order taker shown on the POS. Defaults to the API key's **label**.                                                                                                                                                                 |
| `lines`        | array, **min 1**                     | See below.                                                                                                                                                                                                                         |
| `totals`       | `{subtotal, tax, total}`             | All required, all finite numbers.                                                                                                                                                                                                  |

<Warning>
  **`tableId` is the Odoo `restaurant.table` database id — NOT the table's display
  name or number.** "Mesa 5" is almost never record id 5. Get the ids from
  `GET /external/v1/floor-plan` (§3) — that endpoint exists for this.

  The hub validates the id *best-effort*: while it can read Odoo's floor plan, a
  wrong id is refused with `400 UNKNOWN_TABLE`. While it cannot (Odoo down, or the
  hub has never reached it since boot), the check fails **open** — the order is
  accepted, because an Odoo outage must not stop the restaurant selling. In that
  window a wrong id still fails silently the old way: the order is created, stored
  and broadcast, but maps to a table the POS floor plan doesn't have — nothing
  lights up and it only appears in the open-orders list. So: read the ids from the
  floor-plan endpoint, don't rely on the validation catching you.
</Warning>

Line fields:

| Field            | Type                  | Notes                                                                                                   |
| ---------------- | --------------------- | ------------------------------------------------------------------------------------------------------- |
| `id`             | string ≤ 64, optional | Your own line id, echoed as the snapshot line id. The hub mints `ext-{index}-{4 hex}` when you omit it. |
| `productId`      | string, required      | Odoo `product.product` id, **as a string**.                                                             |
| `quantity`       | number > 0            | Fractional quantities are allowed (weighed items).                                                      |
| `unitPrice`      | number ≥ 0            |                                                                                                         |
| `total`          | number ≥ 0            | Line total **you** computed.                                                                            |
| `taxIds`         | string\[]             | Odoo `account.tax` ids as strings.                                                                      |
| `taxAmount`      | number ≥ 0            | Tax contained in `total`. See §4.7 — omitting it costs you the tax breakdown on any later update.       |
| `note`           | string ≤ 500          | Joined modifiers, wire convention shared with the POS.                                                  |
| `specialRequest` | string ≤ 500          | The customer's typed request.                                                                           |

Every object is **strict**: an unknown key (`status`, `isVoided`, a typo like
`guestcount`) is a `400 VALIDATION_ERROR` naming the key, not a silently dropped
field.

Response `201`:

```json theme={null}
{
  "posReference": "EXT/9f3a2c1d/ORDER-1042",
  "version": 1,
  "snapshot": { "…": "the full order the hub built" }
}
```

A replay of the same `externalRef` answers **`200`** with the stored order (see
§4.4). Failures: `409 NO_ACTIVE_SESSION`, `409 TABLE_OCCUPIED`,
`400 TOTALS_MISMATCH`, `400 VALIDATION_ERROR`.

***

### `PATCH /external/v1/orders/:ref`

Scope `orders:write`. Edits an open check.

```
PATCH /external/v1/orders/EXT%2F9f3a2c1d%2FORDER-1042
```

```json theme={null}
{
  "baseVersion": 2,
  "lines": [
    { "id": "ext-0-4f2a", "productId": "101", "quantity": 3, "unitPrice": 150, "total": 450, "taxAmount": 68.64 },
    { "id": "dessert-1",  "productId": "310", "quantity": 1, "unitPrice": 120, "total": 120, "taxAmount": 18.31 }
  ],
  "notes": "birthday table"
}
```

| Field                                 | Notes                                                                               |
| ------------------------------------- | ----------------------------------------------------------------------------------- |
| `baseVersion`                         | **Required**, positive int. The `version` you last read.                            |
| `lines`                               | Optional. **Full replacement** of the line list, min 1, same line schema as create. |
| `customerName`, `notes`, `guestCount` | Optional.                                                                           |

At least one of `lines` / `customerName` / `notes` / `guestCount` must be
present, or you get `400 VALIDATION_ERROR`.

You cannot change `tableId`, `orderType`, `status`, payments, voids or discounts
through this API.

Response `200`:

```json theme={null}
{
  "posReference": "EXT/9f3a2c1d/ORDER-1042",
  "version": 3,
  "snapshot": { "…": "the merged order" }
}
```

Failures: `404 NOT_FOUND`, `409 ORDER_LOCKED`, `409 SENT_LINES_IMMUTABLE`,
`409 CONFLICT`, `400 VALIDATION_ERROR`.

***

### `GET /external/v1/webhooks`

Scope `webhooks:manage`. Secrets are never included.

```json theme={null}
{
  "webhooks": [
    {
      "id": "ws_7c1d8e3af90b2456",
      "url": "https://n8n.example.com/webhook/pos",
      "events": ["order.created", "order.updated"],
      "enabled": true,
      "createdBy": "9f3a2c1d",
      "createdAt": "2026-08-15 18:22:40"
    }
  ]
}
```

`createdBy` is the key id that created the subscription.

***

### `POST /external/v1/webhooks`

Scope `webhooks:manage`. Subscribe an endpoint.

```json theme={null}
{
  "url": "https://n8n.example.com/webhook/pos",
  "events": ["order.created", "order.updated", "table.updated"]
}
```

* `url` — required, must parse and be `http://` or `https://`.
* `events` — optional. Omit it, send `[]`, or send `["*"]` to receive
  everything. Unknown event names are rejected with `400 VALIDATION_ERROR`.

Response `201`:

```json theme={null}
{
  "id": "ws_7c1d8e3af90b2456",
  "url": "https://n8n.example.com/webhook/pos",
  "events": ["order.created", "order.updated", "table.updated"],
  "secret": "b3d1f0a95c7e284610fa9d3b7c05e8f2"
}
```

**`secret` is returned once and never again.** Store it — it is the HMAC key you
verify deliveries with (§5.4).

***

### `DELETE /external/v1/webhooks/:id`

Scope `webhooks:manage`. Removes the subscription; pending deliveries for it are
parked on the next drain.

```json theme={null}
{ "deleted": true }
```

Unknown id → `404 NOT_FOUND`.

***

### `POST /external/v1/webhooks/:id/test`

Scope `webhooks:manage`. Sends a synchronous ping to the subscription's URL,
signed exactly like a real delivery, and reports what happened. Nothing is
queued and nothing is retried.

```json theme={null}
{ "sent": true, "status": 200 }
```

or

```json theme={null}
{ "sent": false, "error": "fetch failed" }
```

The body your endpoint receives:

```json theme={null}
{
  "id": "test",
  "event": "webhook.test",
  "timestamp": "2026-08-15T18:22:41.000Z",
  "data": { "subscriptionId": "ws_7c1d8e3af90b2456" }
}
```

with header `X-NuPos-Event: webhook.test`. Timeout is 5s (real deliveries get
10s). Note `webhook.test` is **not** in the event catalog — you cannot subscribe
to it, it only ever arrives from this endpoint.

***

## 4. Order creation and update constraints

This is where the API is opinionated. Each rule exists because the alternative
loses money or breaks the terminals.

### 4.1 At least one line

`lines` must contain at least one entry, on create and on any update that sends
`lines`. The POS drops empty-line snapshots when it applies a remote order, so a
lineless order would sit on the hub and never render on a terminal.

### 4.2 `productId` must be a real Odoo catalog product

The hub has no product catalog and cannot validate ids. An unknown `productId`
is accepted, stored, broadcast — and renders on the terminal as a bare id with no
name, then fails or misprices when the order reaches Odoo.

Fetch the catalog from the **Odoo POS API**, not from the hub: the POS bootstrap
payload (`/pos-api/v1/*`) carries products with their `product.product` ids,
names, prices and tax ids. Cache it and map your own SKUs to those ids. Send the
id as a **string** (`"101"`, not `101`).

### 4.3 You supply the prices

The hub has no price list, no tax engine and no pricing rules. `unitPrice`,
`total`, `taxAmount` and `totals` are yours to compute — from the same Odoo
catalog you took `productId` from.

The only integrity check the hub can perform is arithmetic: `totals.total` must
equal the sum of line `total`s within **±0.02**. Off by more →
`400 TOTALS_MISMATCH`, naming both numbers. The tolerance exists for rounding,
not for discounts you forgot to include.

`subtotal` and `tax` are stored as sent on create and are not cross-checked
against each other — keep them honest, they print on the receipt.

### 4.4 Idempotency via `externalRef`

The order reference is minted by the hub:

* with `externalRef`: `EXT/{keyId}/{externalRef}` — e.g. `EXT/9f3a2c1d/ORDER-1042`
* without it: `EXT/{keyId}/{timestamp}-{random}`

Sending the same `externalRef` again returns **`200`** with the order that
already exists — same reference, same version, no second check on the table, no
new webhook. Always send one: it is what makes a timed-out POST safe to retry.
Use your own order id (`ORDER-1042`, a cart uuid), not a random value per attempt.

The uniqueness scope is the key: two different keys using `ORDER-1042` produce
two different references.

### 4.5 One open check per table (`TABLE_OCCUPIED`)

If the table already has an active order — placed by a waiter or by another
integration — creation is refused:

```json theme={null}
{
  "error": "Table 12 already has an open check (POS/2026/0007) — update that order instead",
  "errorCode": "TABLE_OCCUPIED",
  "existing": { "posReference": "POS/2026/0007" }
}
```

The correct flow is to **join the existing check**, not to open a second one:

1. take `existing.posReference` from the 409;
2. `GET /external/v1/orders/{encoded ref}` for its `version` and current lines;
3. `PATCH` with `baseVersion` = that version and the full line list = existing
   lines + your new ones.

That is exactly what a waiter adding a round to an open table does.

Note the idempotent replay in §4.4 runs *before* this check, so retrying your own
POST never trips over the table your first attempt occupied.

**The table has to exist first.** Before the occupancy check, the hub asks Odoo
whether `tableId` is a real `restaurant.table` id (from the same cached floor
plan `GET /external/v1/floor-plan` serves):

* Odoo says no → `400 UNKNOWN_TABLE`, nothing is stored and no table is occupied;
* the hub cannot reach Odoo → the order is **accepted anyway** (fail-open — an
  Odoo outage must not stop the restaurant selling);
* the replay in §4.4 is checked first, so an order already accepted keeps
  replaying even if the floor plan later disagrees.

### 4.6 No control over status, payments or voids

The create schema is strict and has no `status` field: every external order is
created `open`. There is no external path to `paid`, `checkout` or `closed`, no
way to attach payment lines, and no way to void a line or discard a check. Money
is taken on a terminal, by a cashier, full stop.

Consequences you will meet:

* an order in `paid`, `checkout` or `closed` refuses your `PATCH` with
  `409 ORDER_LOCKED`;
* to cancel an external order, ask staff to discard the check on a terminal;
* `order.paid` webhooks are how you learn a check was settled.

### 4.7 Updates: rebase on `baseVersion`

`baseVersion` is the `version` you last read from `GET /orders/:ref` (or the
`version` returned by your own last write). The hub uses it to detect that a
terminal changed the order underneath you:

```json theme={null}
{
  "error": "Order EXT/9f3a2c1d/ORDER-1042 moved on to v2 while you were editing v1",
  "errorCode": "CONFLICT",
  "hubVersion": 2,
  "snapshot": { "…": "the hub's current copy" }
}
```

Nothing was written. The response hands you the hub's copy so you can rebase
without a second round-trip: merge your change onto `snapshot`, then retry with
`baseVersion: hubVersion`.

Only writes from a *different* writer conflict — your own consecutive writes do
not, so you never need to re-read after your own successful `PATCH`.

**Sent lines are immutable downward.** Once the kitchen has an item
(`kitchenSentAt` present on the stored line), your update must still contain that
line id, with `quantity` at least the `sentQuantity` already fired. Otherwise:
`409 SENT_LINES_IMMUTABLE`, naming the line, and nothing is written. Adding items
and increasing quantities is always fine. Kitchen fields (`kitchenSentAt`,
`sentQuantity`, `preparedQuantity`, `kitchenTicketId`, `kitchenItemStatus`) are
copied from the stored line — you cannot set them and you do not need to resend
them.

**Sending `lines` recomputes the totals.** The hub replaces the line list, then
sets:

```
total    = Σ line.total
tax      = Σ line.taxAmount   (a line without taxAmount contributes 0)
subtotal = total − tax
```

So **if you omit `taxAmount` on the lines of an update, the order's tax becomes
0** — the check would print a total that no longer matches its own lines
otherwise. Always send `taxAmount` per line when you send `lines`. An update that
touches only `customerName` / `notes` / `guestCount` leaves the stored totals
untouched.

**A `sent` order goes back to `changed`.** If the kitchen already has the order,
editing its lines flips the status to `changed` — the same cascade a waiter's
edit triggers, so the re-fire shows up on the terminals and the KDS.

### 4.8 Ticket numbers and check numbers

* `takeout` and `delivery` orders get a hub-allocated `ticketNumber` — `T-001`,
  `D-001`, … padded to 3 digits, counted per order type and per **local**
  calendar day (the restaurant's day, not UTC). You cannot set it.
* Table orders get a `checkNumber` (`max(active on the table) + 1`), which keeps
  split checks from colliding with terminal-assigned numbers.
* A `takeout`/`delivery` order needs no `tableId` and never touches table state.
  It shows on the order screen, not on the floor plan.

***

## 5. Webhooks

### 5.1 Subscribing

`POST /external/v1/webhooks` with your URL (§3). Keep the returned `secret`; it
is shown once. Multiple subscriptions are fine — each gets its own secret and its
own independent delivery queue, so a dead receiver never blocks the others.

### 5.2 Event catalog

| Event           | Fires when                                                                               |
| --------------- | ---------------------------------------------------------------------------------------- |
| `order.created` | An order reference is stored for the first time — by a terminal or by this API           |
| `order.updated` | An existing order is written again (line edits, notes, kitchen bump, an operator revert) |
| `order.paid`    | A check is finalized and paid on a terminal                                              |
| `order.deleted` | A check is discarded on a terminal (cashier/manager only)                                |
| `table.updated` | A table becomes `occupied` (order opened) or `available` (order paid or discarded)       |

Every order event carries the same envelope:

```json theme={null}
{
  "id": 8412,
  "event": "order.created",
  "timestamp": "2026-08-15T18:30:04.000Z",
  "data": {
    "posReference": "EXT/9f3a2c1d/ORDER-1042",
    "version": 1,
    "origin": "ext:9f3a2c1d",
    "snapshot": { "…": "the full order snapshot" }
  }
}
```

`table.updated` carries table data instead:

```json theme={null}
{
  "id": 8413,
  "event": "table.updated",
  "timestamp": "2026-08-15T18:30:04.000Z",
  "data": {
    "tableId": 12,
    "status": "occupied",
    "posReference": "EXT/9f3a2c1d/ORDER-1042",
    "origin": "ext:9f3a2c1d"
  }
}
```

* `id` is the delivery id (also in `X-NuPos-Delivery`): one per subscription per
  event, stable across retries of that delivery. Use it for de-duplication.
* `origin` is the writer: a terminal id (`terminal-1`), `ext:{keyId}` for an API
  key, or `hub` for an operator action performed on the hub itself.
* `version` matches the hub's version counter — the same number you pass back as
  `baseVersion`.
* **Your own writes produce webhooks too.** If you don't want to react to them,
  filter on `data.origin === "ext:{your keyId}"`.

### 5.3 Delivery semantics

| Property  | Value                                                                                                     |
| --------- | --------------------------------------------------------------------------------------------------------- |
| Method    | `POST`, `Content-Type: application/json`                                                                  |
| Success   | Any `2xx`. Everything else (and any network error/timeout) is a failure                                   |
| Timeout   | 10 seconds                                                                                                |
| Guarantee | **At least once** — a delivery whose ack you lost will be retried                                         |
| Ordering  | **Not guaranteed.** Deliveries are independent; compare `data.version` rather than assuming arrival order |
| Dispatch  | Queued synchronously with the write, sent by a drain loop every 5s (`HUB_WEBHOOK_DISPATCH_MS`)            |
| Retries   | 8 attempts total, then dead-lettered                                                                      |
| Backoff   | 5s → 30s → 2m → 10m → 30m → 1h → 2h (then 2h between the remaining attempts)                              |
| Retention | Delivery rows older than 7 days are pruned at hub startup, whatever their state                           |

Ack fast: return `200` as soon as you have the payload and do your work
asynchronously. A receiver that takes longer than 10s is treated as failed and
the event is re-sent.

If a subscription is deleted or disabled while deliveries are pending, those
deliveries are parked rather than sent. Queue depth and dead-letter counts show
up on the hub's `GET /health` as `webhookPending` / `webhookDeadLettered`.

Headers on every delivery:

| Header              | Value                                  |
| ------------------- | -------------------------------------- |
| `X-NuPos-Event`     | `order.created`, `table.updated`, …    |
| `X-NuPos-Delivery`  | Delivery id (same as `id` in the body) |
| `X-NuPos-Signature` | `t={unixSeconds},v1={hex hmac}`        |

### 5.4 Verifying the signature

The signature is Stripe-style: HMAC-SHA256 over `"{t}.{rawBody}"`, keyed with the
subscription secret. The timestamp is inside the signed material, so a captured
delivery cannot be replayed under a fresh `t`.

**Verify over the raw body bytes**, before any JSON parse/re-serialize round-trip
— re-encoding changes the bytes and the MAC will not match.

```js theme={null}
const crypto = require("node:crypto");

/** @param rawBody the exact request body as a string/Buffer */
function verifyNuPosSignature(rawBody, signatureHeader, secret, toleranceSeconds = 300) {
  const parts = Object.fromEntries(
    String(signatureHeader)
      .split(",")
      .map((p) => [p.slice(0, p.indexOf("=")), p.slice(p.indexOf("=") + 1)]),
  );
  const { t, v1 } = parts;
  if (!t || !v1) return false;

  // Reject deliveries signed too long ago (replay window).
  if (Math.abs(Math.floor(Date.now() / 1000) - Number(t)) > toleranceSeconds) return false;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${t}.${rawBody}`)
    .digest("hex");

  const a = Buffer.from(v1, "hex");
  const b = Buffer.from(expected, "hex");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// Express example — note express.raw(), not express.json()
app.post("/webhook/pos", express.raw({ type: "application/json" }), (req, res) => {
  const raw = req.body.toString("utf8");
  if (!verifyNuPosSignature(raw, req.header("X-NuPos-Signature"), process.env.NUPOS_WEBHOOK_SECRET)) {
    return res.status(401).end();
  }
  res.status(200).end();          // ack first
  handle(JSON.parse(raw));        // then work
});
```

The 5-minute tolerance above is your choice, not the hub's — the hub signs with
the time of the *attempt*, so a delivery that took six retries over two hours
carries a fresh `t` each time.

**n8n hint.** In a Webhook node, set *Response Mode* to "Immediately" and enable
*Raw Body* so the binary/raw property holds the exact bytes; then verify with a
Code node using the snippet above (`$input.item.binary.data` → `Buffer.from(…,
"base64").toString("utf8")`), and stop the workflow when it does not match. If
your receiver sits behind a proxy that rewrites JSON, the signature will not
verify — terminate TLS/JSON rewriting after verification, or accept only from the
LAN and skip the check knowingly.

### 5.5 Rotating a secret

There is no rotate endpoint. Create a second subscription pointing at the same
URL, switch your verifier to accept either secret, then delete the old
subscription. (You will receive each event twice while both exist —
de-duplicate on `X-NuPos-Delivery` or `data.version`.)

***

## 6. Error codes

Every error body is `{ "error": "…", "errorCode": "…" }`, plus the extra fields
noted below.

| `errorCode`            | HTTP | Meaning                                                                                                                               | What to do                                                                                 |
| ---------------------- | ---- | ------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| `UNAUTHENTICATED`      | 401  | No key presented, or the key is unknown/disabled/deleted. (On `/external-keys`: missing or invalid Odoo manager token.)               | Check the header and that the key is still enabled. Do not retry in a loop.                |
| `FORBIDDEN`            | 403  | The key is valid but lacks the scope (`requiredScope` names it). On admin routes: the token is not a manager's.                       | Mint a key with the right scopes. Not retryable.                                           |
| `RATE_LIMITED`         | 429  | Per-IP or per-key budget exhausted (120/min default).                                                                                 | Back off a few seconds, then retry. Batch your polling.                                    |
| `VALIDATION_ERROR`     | 400  | Malformed JSON, a bad query value, a missing/invalid field, or an unknown key (schemas are strict). `error` names the offending path. | Fix the payload. Never retry unchanged.                                                    |
| `TOTALS_MISMATCH`      | 400  | `totals.total` differs from the sum of line totals by more than ±0.02.                                                                | Recompute the totals from the lines you are sending.                                       |
| `NO_ACTIVE_SESSION`    | 409  | No POS session is open on any terminal.                                                                                               | Retry once staff open the session; surface it as "the restaurant is closed".               |
| `TABLE_OCCUPIED`       | 409  | The table already has an open check. `existing.posReference` names it.                                                                | `GET` that order and `PATCH` it instead (§4.5).                                            |
| `UNKNOWN_TABLE`        | 400  | `tableId` is not a `restaurant.table` id Odoo has — usually the table's *number* was sent instead of its record id.                   | Take the id from `GET /external/v1/floor-plan`. Never retry unchanged.                     |
| `NOT_FOUND`            | 404  | No such order reference, webhook subscription, API key, or route.                                                                     | Check the reference and that it is URL-encoded (§3.1).                                     |
| `ORDER_LOCKED`         | 409  | The order is `paid`, `checkout` or `closed` — external edits are over.                                                                | Stop editing. If the change is real, staff must handle it on a terminal.                   |
| `SENT_LINES_IMMUTABLE` | 409  | The update removed a line the kitchen already has, or dropped its quantity below `sentQuantity`. `error` names the line.              | Re-send the sent lines with quantity ≥ what was sent. Reductions are a terminal-side void. |
| `CONFLICT`             | 409  | Someone else wrote the order after your `baseVersion`. Body carries `hubVersion` and the hub's `snapshot`.                            | Rebase onto the returned snapshot and retry with `baseVersion: hubVersion` (§4.7).         |
| `PAYLOAD_TOO_LARGE`    | 413  | Request body over 256 KiB.                                                                                                            | Split the order; a check with thousands of lines is not a check.                           |
| `NOT_CONFIGURED`       | 503  | This hub build has the namespace but not that subsystem wired (order writes, webhook storage, floor-plan reads).                      | Operator issue — update/restart the hub. Not fixable by the caller.                        |
| `UPSTREAM_UNAVAILABLE` | 503  | `/floor-plan` only: the hub cannot reach Odoo and has no cached floor plan to serve.                                                  | Retry with backoff. Order creation still works — the `tableId` check fails open.           |
| `METHOD_NOT_ALLOWED`   | 405  | Right path, wrong verb (e.g. `PUT /external/v1/orders`).                                                                              | Use the verb in §3.                                                                        |
| `INTERNAL_ERROR`       | 500  | The hub failed unexpectedly.                                                                                                          | Retry with backoff; if it persists, check the hub logs (`hub_external_error`).             |

Retry policy in one line: retry `429`, `503` and `500` with backoff; fix and
resend `4xx` validation errors; handle `409` by reading the state and rebasing.

***

## 7. A complete flow

```bash theme={null}
HUB=http://192.168.1.50:8766
KEY=nupos_9f3a2c1d_5b1e0a77c4d93f2168ab5cd0e7194f3a

# 0. Is the POS open?
curl -s -H "X-Api-Key: $KEY" $HUB/external/v1/session
# {"active":true,"sessionId":"42","sessionName":"POS/2026/0042"}

# 1. Which tables exist, and which are free?
#    `id` here is the tableId to send — "Mesa 5" is not id 5.
curl -s -H "X-Api-Key: $KEY" $HUB/external/v1/floor-plan

# 2. Open a check on table 12 (idempotent on ORDER-1042)
curl -s -X POST $HUB/external/v1/orders \
  -H "X-Api-Key: $KEY" -H "Content-Type: application/json" \
  -d '{"externalRef":"ORDER-1042","tableId":12,
       "lines":[{"productId":"101","quantity":2,"unitPrice":150,"total":300,"taxAmount":45.76}],
       "totals":{"subtotal":254.24,"tax":45.76,"total":300}}'
# 201 {"posReference":"EXT/9f3a2c1d/ORDER-1042","version":1,"snapshot":{…}}
#     → the check is now on the waiters' floor plan

# 3. Read it back before editing (this is where baseVersion comes from)
REF=$(printf 'EXT/9f3a2c1d/ORDER-1042' | jq -sRr @uri)
curl -s -H "X-Api-Key: $KEY" $HUB/external/v1/orders/$REF

# 4. Add a dessert (full line list, taxAmount on every line)
curl -s -X PATCH $HUB/external/v1/orders/$REF \
  -H "X-Api-Key: $KEY" -H "Content-Type: application/json" \
  -d '{"baseVersion":1,
       "lines":[{"id":"ext-0-4f2a","productId":"101","quantity":2,"unitPrice":150,"total":300,"taxAmount":45.76},
                {"id":"dessert-1","productId":"310","quantity":1,"unitPrice":120,"total":120,"taxAmount":18.31}]}'
# 200 {"version":2,…}   — or 409 CONFLICT with hubVersion+snapshot to rebase on
```

The check is settled by a cashier on a terminal; you learn about it from the
`order.paid` webhook and the matching `table.updated` (`available`).

***

## 8. Operator checklist

* Mint one key per integration, with the narrowest scopes, and label it after the
  integration (the label is what shows as the order taker on external orders).
* Keep the hub off the public internet. The namespace is 401-dead until a key
  exists, but a key is a bearer credential on a plain-HTTP LAN service.
* Disable a key the moment an integration misbehaves —
  `PATCH /external-keys/:keyId {"enabled": false}` takes effect on the next
  request.
* Watch `GET /health` for `webhookPending` and `webhookDeadLettered`: a growing
  backlog means an integration is silently missing events.
* Every external write is attributed as `ext:{keyId}` in the hub's order history
  and in Odoo's audit trail, so "who added that line?" always has an answer.

***

## 7. Plugin events

Orders and tables are things the POS already understands. A plugin event is
anything else an integration wants a terminal to know about — a table booking, a
courier assignment, a loyalty flag — carried through the hub to the frontend
plugin that knows how to read it.

The hub is a broker here, not a participant. It validates the *envelope* and
enforces who may write, how much, and for how long. It never validates what is
inside `payload`, because meaning belongs to the frontend plugin, not to the hub.

This section is the generic channel. For the contract of the plugin that ships
today — table bookings — see §8.

### 7.1 Enabling a plugin

The operator sets `HUB_ENABLED_PLUGINS` (comma-separated plugin ids) on the hub.
**This is the only switch.** A plugin id not on that list gets `404` from every
route below, and with the variable unset the whole `/plugins` surface is `404` —
the same "an unconfigured hub exposes nothing" posture as a hub with no API keys.

The `404` is deliberate and comes *before* authentication, so a caller cannot use
this API to discover which plugins a restaurant runs.

### 7.2 `POST /external/v1/plugins/:pluginId/events`

Scope: `plugins:write`. Creates or replaces one record.

```json theme={null}
{
  "key": "resy-8891",
  "payload": {
    "tableId": 42,
    "at": "2026-08-16T19:30:00-04:00",
    "guestName": "Espinal",
    "partySize": 4
  },
  "ttlSeconds": 86400
}
```

| Field        | Required | Notes                                                                                 |
| ------------ | -------- | ------------------------------------------------------------------------------------- |
| `key`        | yes      | Your idempotency key, 1–64 of `A-Z a-z 0-9 _ -`. Upsert key together with `pluginId`. |
| `payload`    | yes      | Free-form JSON object, subject to the caps in §7.5.                                   |
| `ttlSeconds` | no       | Relative expiry, max 7 days. Mutually exclusive with `expiresAt`.                     |
| `expiresAt`  | no       | Absolute ISO 8601 expiry. Must be in the future.                                      |

Answers `202` with the stored record:

```json theme={null}
{
  "event": {
    "pluginId": "reservations",
    "key": "resy-8891",
    "payload": { "tableId": 42, "…": "…" },
    "version": 1,
    "updatedAt": "2026-08-16T18:55:00.000Z",
    "expiresAt": "2026-08-17T18:55:00.000Z",
    "origin": "ext:k_7f3a"
  }
}
```

`202`, not `201`: the hub has stored the record and pushed it to every connected
terminal, but whether a terminal understands the payload is the frontend plugin's
business, not something the hub can promise. See §7.5.

Re-posting the same `key` **updates** the record and bumps `version` — a retry is
safe and never produces a duplicate.

### 7.3 Reading and deleting

| Method   | Path                                         | Scope           |
| -------- | -------------------------------------------- | --------------- |
| `GET`    | `/external/v1/plugins/:pluginId/events`      | `read`          |
| `GET`    | `/external/v1/plugins/:pluginId/events/:key` | `read`          |
| `DELETE` | `/external/v1/plugins/:pluginId/events/:key` | `plugins:write` |

`GET` returns only live records — anything past its expiry is already invisible,
whether or not the sweep has run. `DELETE` answers `404` if the record was
already gone, and pushes the removal to the terminals.

### 7.4 Expiry

A record with no expiry lives until deleted. One with an expiry stops being
served the moment it passes, and a background sweep then removes it and tells the
terminals so they stop rendering it.

Set a TTL on anything time-bound. A booking for tonight that never expires is a
booking that is still on the floor plan next week.

### 7.5 Validation is at the consumer

The hub checks the envelope and these caps on `payload`:

* at most **8 KiB** serialized, and the request body limit of 256 KiB still applies
* nesting at most **5** levels deep
* strings at most 2,000 characters, arrays at most 100 entries, object keys at most 64
* numbers must be finite

<Note>
  It does **not** check that `tableId` is present, that `at` is a date, or that any
  field your plugin needs exists. A payload the frontend plugin cannot parse is
  dropped silently on the terminal — you will still have received `202`.
</Note>

This is a deliberate trade. If the hub knew each plugin's schema, shipping a
plugin would mean upgrading every hub in the field first, and a plugin rollout
would become a fleet operation. Use `GET …/events` to reconcile what the hub
actually holds.

If you are sending a `tableId`, note the same warning as order creation: it is
the Odoo `restaurant.table` **record id**, not the number painted on the table.
Get it from `GET /external/v1/floor-plan`.

### 7.6 Attaching plugin data to an order

Order create and update also accept a `pluginData` object — namespaced the same
way, so a plugin's data can ride along with the check it belongs to:

```json theme={null}
{
  "externalRef": "resy-8891-order",
  "lines": [ { "…": "…" } ],
  "totals": { "…": "…" },
  "pluginData": {
    "reservations": { "ref": "resy-8891" }
  }
}
```

On `PATCH`, `pluginData` is merged **per namespace**, not replaced wholesale — a
delivery integration patching its own namespace cannot blank a reservation
plugin's. To clear one namespace, send it as an empty object.

`pluginData` is **frontend and hub only**. It reaches every terminal and survives
reconnects, and it is deliberately stripped before anything is forwarded to Odoo:
it will never appear on the accounting record, on a receipt, or in a report.
Anything that must survive the shift belongs in a real order field, not here.

***

## 8. The reservations plugin

The first plugin shipped on the channel above, and the reference for what a
plugin contract looks like. It puts table bookings from an external reservation
platform onto the POS floor plan.

Everything here is the `payload` of a `reservations` plugin event — see §7.2 for
the envelope that wraps it.

### 8.1 What the POS does with a booking

A booking is **not** an order. It never occupies a table, never creates a check,
and never appears in accounting. It changes what staff *see*:

| Where                       | What appears                                                                       |
| --------------------------- | ---------------------------------------------------------------------------------- |
| The table on the floor plan | A dashed border, a tint, and the sitting time                                      |
| The start-order modal       | Guest name, party size, phone, notes — shown as staff are about to seat that table |
| The floor-plan header       | "3 reservations · next 19:30"                                                      |

**Nothing is blocked.** A waiter can still seat a walk-in on a booked table. The
POS informs; it does not enforce. Do not rely on this API to hold a table.

### 8.2 Enabling

The operator must include `reservations` in the hub's `HUB_ENABLED_PLUGINS`.
Until then every route in §7 answers `404` for this plugin id.

### 8.3 Payload

```json theme={null}
{
  "key": "resy-8891",
  "payload": {
    "tableId": 29,
    "at": "2026-08-16T19:30:00-04:00",
    "guestName": "Espinal",
    "partySize": 4,
    "phone": "809-555-0111",
    "notes": "Cumpleaños — mesa junto a la ventana",
    "status": "booked"
  },
  "ttlSeconds": 86400
}
```

| Field          | Required | Rules                                                                                                       |
| -------------- | -------- | ----------------------------------------------------------------------------------------------------------- |
| `tableId`      | yes      | Positive integer. **The Odoo `restaurant.table` record id, not the number painted on the table** — see §8.5 |
| `at`           | yes      | ISO 8601 **with a UTC offset**. A naive `2026-08-16T19:30:00` is rejected                                   |
| `guestName`    | yes      | 1–120 chars                                                                                                 |
| `partySize`    | no       | 1–50. Also announced to screen readers                                                                      |
| `phone`        | no       | ≤40 chars                                                                                                   |
| `notes`        | no       | ≤500 chars. Shown in italic on the note panel                                                               |
| `status`       | no       | `booked` (default) · `seated` · `cancelled` · `no_show`                                                     |
| `leadMinutes`  | no       | 0–240, default **45**. How early the table starts showing as booked                                         |
| `graceMinutes` | no       | 0–240, default **20**. How long past the sitting before it reads as late                                    |

A payload that fails any of these is **dropped silently by the POS**. The hub
still answers `202`, because it validates shape and size, not meaning (§7.5).
Use `GET /external/v1/plugins/reservations/events` to confirm what was stored,
and check a terminal to confirm it renders.

### 8.4 Lifecycle

The table shows a booking only inside its window, so the same record moves
through phases on its own as the clock advances — you do not need to send
updates for that:

```
        at − leadMinutes          at            at + graceMinutes
  ─────────────┼─────── shown ────┼──── shown ────┼──── shown ────►
   not shown        "upcoming"       "due"          "running late"
```

Before `at − leadMinutes` the booking is stored but invisible: a 19:30 table
must not be tinted at 11:00.

To change a booking, **re-POST with the same `key`** — it upserts and bumps
`version`. To retract one, either:

* re-POST it with `status: "cancelled"` or `"no_show"` (keeps the record
  readable on your side), or
* `DELETE /external/v1/plugins/reservations/events/{key}` (removes it entirely).

Set `status: "seated"` once the party arrives. The booking then stops showing —
there is a real order on the table at that point, and the order is what the
floor plan should display.

Always set a `ttlSeconds`. A booking for tonight with no expiry is a booking
still on the floor plan next week.

### 8.5 `tableId` is the record id

<Warning>
  This is the single most common integration mistake. Table "1" on the floor is
  typically **not** `tableId: 1`.
</Warning>

Read the ids from the floor plan:

```bash theme={null}
curl -s http://<hub>:8766/external/v1/floor-plan -H "X-Api-Key: $KEY"
```

Read the ids from there and cache them; a floor plan changes a few times a year.
A booking sent with the wrong id decorates the wrong table, or no table at all —
and unlike order creation, a plugin event is **not** validated against the floor
plan, so you get a `202` either way.

### 8.6 Linking an order to a booking

When your platform creates the check for a booking, stamp the link on the order
so the order screen can show which booking it came from:

```json theme={null}
{
  "externalRef": "resy-8891-order",
  "tableId": 29,
  "lines": [ { "…": "…" } ],
  "totals": { "…": "…" },
  "pluginData": { "reservations": { "key": "resy-8891" } }
}
```

`key` must match the plugin event's `key`. See §7.6 for how `pluginData` behaves
on updates — and note it never reaches Odoo.
