Nice Work POS API (v1)
A REST API for building your own tools on top of your Nice Work POS: dashboards, stock sync, accounting exports, delivery bridges and so on. You can read menus, stock, bills (with running totals) and sales, seat tables, place orders, request bills, record payments, void items, apply bill discounts, adjust stock and mark items sold out. With webhooks we tell your system the moment something happens, so it does not have to keep asking.
Getting a key
The restaurant owner creates keys in Dashboard → Settings → API. Each key:
- works for one outlet (the outlet the dashboard had open when it was created);
- carries scopes that decide which endpoints it can call;
- is shown once. Copy it straight away. If it is lost, revoke it and make a new one.
Everything a key does is recorded against the owner who created it. If that person stops being an owner, their keys stop working.
Calling the API
GET https://api.nicework.sg/api/v1/outlet
Authorization: Bearer nwk_xxxxxxxx-....<secret>- All responses are JSON. Successful responses wrap the payload in
data. - Money is an integer in the currency's smallest unit (cents for SGD, sen for MYR).
1234means $12.34. The currency is onGET /outlet. - Timestamps are ISO 8601 with an offset. Business dates are
YYYY-MM-DD.
Errors
{ "error": { "code": "INSUFFICIENT_SCOPE", "message": "This key does not have the bills:read scope" } }| Status | Code | Meaning |
|---|---|---|
| 400 | INVALID_REQUEST | A query parameter or body field is wrong. The message names it. |
| 400 | IDEMPOTENCY_KEY_REQUIRED | A write was sent without an Idempotency-Key header. |
| 401 | UNAUTHORIZED | Missing, wrong or revoked key. |
| 403 | INSUFFICIENT_SCOPE | The key lacks the scope this endpoint needs. |
| 403 | FEATURE_UNAVAILABLE | API access is not on this restaurant's plan. |
| 404 | NOT_FOUND | No such record at this outlet. |
| 409 | REQUEST_IN_PROGRESS | The first request with this Idempotency-Key is still running. Retry shortly. |
| 422 | IDEMPOTENCY_KEY_REUSED | This Idempotency-Key was already used with a different body. |
| 413 | PAYLOAD_TOO_LARGE | A write body is over 64 KB. |
| 429 | RATE_LIMITED | Too many requests. Wait Retry-After seconds. |
| 503 | SERVICE_UNAVAILABLE | Temporary. Retry after Retry-After seconds. |
Rate limits
120 read requests and 30 writes per minute per key. Every response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (Unix seconds). Poll sensibly: once every 10–30 seconds is plenty for open bills.
Paging
List endpoints return next_cursor. Pass it back as ?cursor= to get the next page; it is null on the last page. limit is 1–200 (default 50). Lists are ordered oldest first, so you can sync forward with since / updated_since.
Scopes and endpoints
| Scope | Endpoint | What it returns |
|---|---|---|
outlet:read | GET /outlet | Name, timezone, currency, service mode, opening hours, payment methods, discounts. |
menu:read | GET /menu | Categories, subcategories, items (price, availability right now, stock), modifier groups and modifiers. |
stock:read | GET /stock | Stock level of every stock-tracked item. |
stock:read | GET /stock/adjustments | The stock ledger (since, menu_item_id, paging). |
tables:read | GET /tables | Sections and tables, with the open bill on each table. |
orders:read | GET /sessions | Bills (status=active,bill_requested,paid,closed, updated_since, paging). |
orders:read | GET /sessions/{id} | One bill: each round, its items, modifiers, kitchen state, voids, combos, discounts. |
bills:read | GET /sessions/{id}/bill | The bill computed live, including running totals on open bills: item total, discounts, service charge, tax, total, paid, balance, per-line and per-charge breakdown, payments. |
payments:read | GET /payments | Payments taken (since, paging). Includes payments voided by a bill re-open (voided_at). |
sales:read | GET /day-closures | Finalized end-of-day (Z) reports with tender breakdown (from, to, up to 366 days). |
orders:write | POST /orders | Place a counter order (takeaway or delivery), optionally paid in the same call. |
orders:write | POST /sessions | Seat a table (open a bill on it). |
orders:write | POST /sessions/{id}/orders | Add a round to an open table bill. |
orders:write | POST /sessions/{id}/bill-request | Request the bill on a table. |
payments:write | POST /sessions/{id}/payments | Record one or more payments against a bill. |
voids:write | POST /order-items/{id}/void | Void an item on an open bill. |
discounts:write | POST /sessions/{id}/discounts | Discount a whole bill (a preset or your own). |
discounts:write | DELETE /sessions/{id}/discounts/{session_discount_id} | Remove a bill discount. |
stock:write | POST /stock/adjustments | Add or take away stock on a stock-tracked item. |
menu:write | POST /menu/items/{id}/availability | Mark an item sold out (86) or back on. |
menu:write | POST /menu/modifiers/{id}/availability | Mark a modifier option sold out or back on. |
New keys start with the read scopes ticked. Write scopes are an extra tick.
Writes
Every write needs an Idempotency-Key header: any unique string up to 255 characters (a UUID, or your own order id). If a write times out, send it again with the same key and the same body. You get the first response back (with Idempotent-Replayed: true) and nothing happens twice. Reusing a key with a different body is refused. Keys are kept for 24 hours.
Request bodies are strict: an unknown field is a 400 that names it, so a typo never silently drops something.
Writes are recorded against the owner who created the key, and the audit log shows the key.
POST /orders
A counter order (takeaway, delivery, kiosk), priced by the POS from its own menu (you send ids, never prices). Works at quick-serve outlets, and at table-service outlets that have takeaway ordering switched on (there it is always takeaway, with no service charge).
{
"type": "takeaway",
"reference": "GF-1001",
"items": [
{ "menu_item_id": "…", "quantity": 2, "modifier_ids": ["…"], "notes": "no chilli" }
],
"payment": { "payment_method_id": "…", "amount": 2140, "reference": "GRAB-889" }
}typeistakeaway(default) ordine_in(quick-serve outlets only).referenceis your id for the order (e.g. the delivery platform's order number). It is echoed back, kept in the audit log, and used as the payment reference if the payment has none.- The kitchen only gets the order once it is fully paid. Same rule as a counter order keyed at the till.
- With
payment(needspayments:writetoo):amountmust equal the order total exactly. If it does not, or the payment is refused, the order is cancelled and nothing is left behind. The response saysstatus: "paid". Use a payment method the owner set up for it (e.g. "GrabFood") fromGET /outlet. - Without
payment: the order waits unpaid (status: "awaiting_payment"), like an order keyed at the till. Pay it withPOST /sessions/{id}/payments. Unpaid counter orders are cancelled after 2 hours. - Sold-out items, unknown modifiers and outlets that are closed for ordering are refused with the reason.
Response 201:
{ "data": { "session_id": "…", "order_id": "…", "order_number": 42, "reference": "GF-1001",
"total": 2140, "status": "paid", "payment": { … } } }Table service
POST /sessions { "table_id": "…", "covers": 2 }
POST /sessions/{id}/orders { "items": [ { "menu_item_id": "…", "quantity": 2 } ] }
POST /sessions/{id}/bill-request {}
POST /sessions/{id}/payments { "payments": [ … ] }- Seating needs an available table (
GET /tables); a seated table is a 409TABLE_NOT_AVAILABLE. The table's "scan to order" QR prints if the outlet auto-prints them. - Each round goes to the kitchen straight away, priced and checked exactly as at the till. Items take the same fields as
POST /orders. On a pay-per-round table the round is held until it is paid (round.status: "awaiting_payment", pay it withround_order_id). GET /sessions/{id}/billshows the running total at any point.- Requesting the bill fixes it: no more rounds (409
SESSION_NOT_ACTIVE) until staff cancel the request at the till. Asking twice is harmless (already_requested: true). Waiving service charge or tax stays at the till. The final payment closes the bill and frees the table.
Voids
POST /order-items/{order_item_id}/void { "reason": "Customer changed their mind" }- Item ids are on
GET /sessions/{id}.reasonis optional and kept in the audit log. - Same as a void at the till: stock comes back for stock-tracked items, the kitchen gets a VOID chit, and voiding any item of a set meal voids the whole set (
voided_item_idslists every line). - Items on a paid bill cannot be voided (422
ORDER_CLOSED); voiding twice is 422ALREADY_VOIDED. - At the till only managers and owners can void. A key with
voids:writestands in for that, so only tick it for software you trust with voids.
Bill discounts
POST /sessions/{id}/discounts { "discount_id": "…" } a preset from GET /outlet
{ "discount_id": "…", "percent": 15 } an open-amount preset
{ "percent": 10, "reason": "Regular" } your own percentage
{ "amount": 500, "reason": "Cold food" } your own amount (cents)
DELETE /sessions/{id}/discounts/{session_discount_id}- The bill engine works out the amount: a preset's excluded items are left out, and a fixed amount is capped at what is left to discount. The response has
amount(what came off),eligible_baseand anyexcluded_order_item_ids. - Works on any open bill, including one whose bill was already requested (its stored totals update). A paid bill is locked (409
INVALID_STATE). - Pay-per-round tables take no bill discounts (409
PAY_PER_ROUND). - At the till, your own discounts and some presets need a manager to approve. On the API the owner who created the key is recorded as the approver, so only tick
discounts:writefor software you trust with discounts. session_discount_id(for removing one) is onGET /sessions/{id}/billunderbill_discount_lines.
Stock and sold out
POST /stock/adjustments { "menu_item_id": "…", "delta": 24, "reason": "delivery_received" }
POST /menu/items/{id}/availability { "available": false }
POST /menu/modifiers/{id}/availability { "available": false }deltais the change, not the new count:24adds 24,-3takes 3 away.reasonisdelivery_received,waste_spoilage,correction,opening_countorother;notesis optional. Sales and voids are recorded by the POS itself. The response hasquantity_beforeandquantity_after.- Only items with stock tracking switched on take adjustments (422
STOCK_NOT_TRACKED). Stock never goes below zero (422INSUFFICIENT_STOCK). - An item that reaches zero is marked sold out automatically, and adding stock to it switches it back on. A stock-tracked item at zero cannot be switched on with
availability(422OUT_OF_STOCK): add stock instead. - Sold out is the same switch as in the dashboard: the POS screens and the QR menu update straight away. Item and option ids are on
GET /menu.
POST /sessions/{id}/payments
{ "payments": [ { "payment_method_id": "…", "amount": 1000, "reference": "…" } ] }- Takes the same path as a payment at the POS: partial payments are fine, overpaying is refused (
OVERPAYMENT), and the final payment closes the bill, issues the receipt and, for counter orders, fires the kitchen. - The bill must have been requested (counter orders always are). Optional
split_idpays one split of a split bill;round_order_idpays one round at a pay-per-round outlet. amount_tendered(cash only) records the cash handed over, so the response carries the change due.- Complimentary tenders need a manager's approval and cannot be used from the API.
The response has the payments recorded, fully_paid, paid, balance and the receipt_number once the bill is closed.
Notes
- A session is one bill: a table visit, or one counter order at a quick-serve outlet (
table_idisnull). - Item
stateis the kitchen state:pending→preparing→ready→served. - On
GET /menu,is_available: falsemeans sold out.schedule_stateisavailable,pending(opens later today, seeavailable_from) orhidden. - On
GET /sessions/{id}/bill, linenetis after item discounts only; the bill-level discount share of each line is inbill_discount_shares. - Reading a bill never changes it.
Webhooks
Instead of polling, give us a URL and we POST an event to it within a few seconds of something happening at the outlet: a table is seated, the kitchen marks an item ready, a bill is paid.
The owner adds URLs in Dashboard → Settings → API, under the keys. Each URL:
- belongs to one outlet and receives only that outlet's events;
- picks the events it wants (at least one);
- must be https on a public address (private, loopback and cloud metadata addresses are refused, and we never follow redirects);
- has its own signing secret (
whsec_…), shown once. "New secret" replaces it; the old one stops working at once.
An outlet can have up to 5 URLs. "Send test event" sends a ping event. The log shows the last 7 days of deliveries for each URL, and any delivered or failed event can be sent again.
Events
| Event | When |
|---|---|
session.opened | A table is seated, or a counter order starts |
order.created | A round or counter order is placed |
order_item.state_changed | The kitchen moves an item (preparing, ready, served) |
order_item.voided | An item is voided |
session.bill_requested | The bill is requested |
session.bill_cancelled | Staff cancel a bill request (the session is active again) |
payment.created | A payment is recorded |
session.paid | The bill is fully paid and closed |
session.reopened | A paid bill is reopened for a correction |
menu_item.availability_changed | An item or a modifier option is sold out or back on |
stock.adjusted | Stock on a tracked item changes |
Payload
Every event has the same envelope. data is deliberately thin: ids and the fact that changed, never a bill total. Fetch what you need with the API (for example GET /sessions/{id}/bill after session.paid).
{
"id": "5b0c8f2e-6a43-4a8e-9d0f-2f1b7c9e4a11",
"type": "payment.created",
"created_at": "2026-11-03T05:12:44.019Z",
"outlet_id": "1f6a…",
"data": {
"payment_id": "9c2d…",
"session_id": "80fe…",
"order_id": null,
"payment_method_id": "a41b…",
"amount": 2420
}
}What data holds per event:
| Event | data |
|---|---|
session.* | session_id, table_id (null at a counter), status |
order.created | session_id, order_id, order_number, order_type |
order_item.state_changed | order_item_id, order_id, session_id, menu_item_id, state, previous_state |
order_item.voided | order_item_id, order_id, session_id, menu_item_id, quantity |
payment.created | payment_id, session_id, order_id, payment_method_id, amount (smallest unit) |
menu_item.availability_changed | kind (menu_item or modifier), id, available |
stock.adjusted | menu_item_id, delta, reason, quantity_before, quantity_after, order_id |
ping | endpoint_id, message |
New fields may be added to data; ignore the ones you do not know.
Checking the signature
Each request carries these headers:
| Header | Value |
|---|---|
Nicework-Signature | t=<unix seconds>,v1=<hex HMAC-SHA256> |
Nicework-Event-Id | The event id |
Nicework-Event-Type | The event type |
The signature is HMAC-SHA256, keyed with the URL's signing secret, over the timestamp, a dot, and the raw request body exactly as received. Check it before trusting the body, and refuse timestamps more than 5 minutes old. In Node.js:
import { createHmac, timingSafeEqual } from 'node:crypto'
function verify(rawBody, header, secret) {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')))
const t = Number(parts.t)
if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex')
const given = Buffer.from(parts.v1 ?? '', 'hex')
const want = Buffer.from(expected, 'hex')
return given.length === want.length && timingSafeEqual(given, want)
}Parse the JSON only after the check: re-serialising it first changes the bytes and the signature will not match.
Answering, retries and order
- Answer with any 2xx within 10 seconds. Do the real work afterwards (put the event on a queue); a slow answer counts as a failure.
- Anything else (a 3xx, 4xx, 5xx, a timeout or a refused connection) is retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours and 24 hours. After the 8th try the delivery is marked failed; it can still be sent again from the log.
- A URL that has not accepted anything for 3 days is paused and the owner is emailed. Resume it from the dashboard once it is fixed.
- Delivery is at least once. The same event can arrive more than once (a retry after a lost answer, or a manual resend); it always has the same
id, so remember the ids you have handled and skip repeats. - Events can arrive out of order. Use
created_at, or fetch the current state from the API, rather than relying on arrival order.
Questions, or found something wrong? hello@nicework.sg