Skip to main content
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

Base URL

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:
Every response is JSON. Every failure carries the same envelope:
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).
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:
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

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:
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 KiB413 PAYLOAD_TOO_LARGE.

2.5 Managing keys

All four routes require the Odoo manager token (not an API key). GET /external-keys:
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. 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:
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.
Closed:

GET /external/v1/tables

Scope read. Every table the hub knows about, with its live status and any terminal lock.
  • 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.
  • 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.
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.
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:
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.
Line fields: 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:
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.
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:
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.
createdBy is the key id that created the subscription.

POST /external/v1/webhooks

Scope webhooks:manage. Subscribe an endpoint.
  • 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:
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.
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.
or
The body your endpoint receives:
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 totals 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:
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:
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:
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 ticketNumberT-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

Every order event carries the same envelope:
table.updated carries table data instead:
  • 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

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:

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.
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.dataBuffer.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. 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

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.
Answers 202 with the stored record:
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

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
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.
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:
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: 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

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:
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

This is the single most common integration mistake. Table “1” on the floor is typically not tableId: 1.
Read the ids from the floor plan:
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:
key must match the plugin event’s key. See §7.6 for how pluginData behaves on updates — and note it never reaches Odoo.