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:
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).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:
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.
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).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
AposReference contains slashes — EXT/9f3a2c1d/ORDER-1042,
POS/2026/0007. URL-encode it whole when it appears in a path:
/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.
GET /external/v1/tables
Scope read. Every table the hub knows about, with its live status and any
terminal lock.
statusisavailable|occupied|reserved.posReferenceis the last order that touched the table (still meaningful when the table isavailable— it is the check that just left).lockis non-null only while a waiter has the table open on a terminal, and expired locks are reported asnull. 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-planto 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.
idis thetableIdto send. Note the example: the table named “Mesa 5” is record12. That mismatch is the entire reason this endpoint exists.statusis the hub’s live state, defaulting toavailablefor a table the hub has not seen this shift.lockfollows the same rules as/tables(advisory,nullwhen absent or expired).fetchedAtis when the plan was last actually read from Odoo, not now.stale: truemeans 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_UNAVAILABLEwhen the hub cannot reach Odoo and has never cached a plan (typically the hub was started while Odoo was down). Retry.503 NOT_CONFIGUREDwhen 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.
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:
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:
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:
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 behttp://orhttps://.events— optional. Omit it, send[], or send["*"]to receive everything. Unknown event names are rejected with400 VALIDATION_ERROR.
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.
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.
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}
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:
- take
existing.posReferencefrom the 409; GET /external/v1/orders/{encoded ref}for itsversionand current lines;PATCHwithbaseVersion= that version and the full line list = existing lines + your new ones.
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 nostatus 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,checkoutorclosedrefuses yourPATCHwith409 ORDER_LOCKED; - to cancel an external order, ask staff to discard the check on a terminal;
order.paidwebhooks 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:
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:
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
takeoutanddeliveryorders get a hub-allocatedticketNumber—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/deliveryorder needs notableIdand 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:
idis the delivery id (also inX-NuPos-Delivery): one per subscription per event, stable across retries of that delivery. Use it for de-duplication.originis the writer: a terminal id (terminal-1),ext:{keyId}for an API key, orhubfor an operator action performed on the hub itself.versionmatches the hub’s version counter — the same number you pass back asbaseVersion.- 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.
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 onX-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
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 /healthforwebhookPendingandwebhookDeadLettered: 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 insidepayload, 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 setsHUB_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 onpayload:
- 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.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 apluginData object — namespaced the same
way, so a plugin’s data can ride along with the check it belongs to:
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 thepayload 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 includereservations 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: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).
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
Read the ids from the floor plan:
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.