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:

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>

Errors

{ "error": { "code": "INSUFFICIENT_SCOPE", "message": "This key does not have the bills:read scope" } }
StatusCodeMeaning
400INVALID_REQUESTA query parameter or body field is wrong. The message names it.
400IDEMPOTENCY_KEY_REQUIREDA write was sent without an Idempotency-Key header.
401UNAUTHORIZEDMissing, wrong or revoked key.
403INSUFFICIENT_SCOPEThe key lacks the scope this endpoint needs.
403FEATURE_UNAVAILABLEAPI access is not on this restaurant's plan.
404NOT_FOUNDNo such record at this outlet.
409REQUEST_IN_PROGRESSThe first request with this Idempotency-Key is still running. Retry shortly.
422IDEMPOTENCY_KEY_REUSEDThis Idempotency-Key was already used with a different body.
413PAYLOAD_TOO_LARGEA write body is over 64 KB.
429RATE_LIMITEDToo many requests. Wait Retry-After seconds.
503SERVICE_UNAVAILABLETemporary. 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

ScopeEndpointWhat it returns
outlet:readGET /outletName, timezone, currency, service mode, opening hours, payment methods, discounts.
menu:readGET /menuCategories, subcategories, items (price, availability right now, stock), modifier groups and modifiers.
stock:readGET /stockStock level of every stock-tracked item.
stock:readGET /stock/adjustmentsThe stock ledger (since, menu_item_id, paging).
tables:readGET /tablesSections and tables, with the open bill on each table.
orders:readGET /sessionsBills (status=active,bill_requested,paid,closed, updated_since, paging).
orders:readGET /sessions/{id}One bill: each round, its items, modifiers, kitchen state, voids, combos, discounts.
bills:readGET /sessions/{id}/billThe 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:readGET /paymentsPayments taken (since, paging). Includes payments voided by a bill re-open (voided_at).
sales:readGET /day-closuresFinalized end-of-day (Z) reports with tender breakdown (from, to, up to 366 days).
orders:writePOST /ordersPlace a counter order (takeaway or delivery), optionally paid in the same call.
orders:writePOST /sessionsSeat a table (open a bill on it).
orders:writePOST /sessions/{id}/ordersAdd a round to an open table bill.
orders:writePOST /sessions/{id}/bill-requestRequest the bill on a table.
payments:writePOST /sessions/{id}/paymentsRecord one or more payments against a bill.
voids:writePOST /order-items/{id}/voidVoid an item on an open bill.
discounts:writePOST /sessions/{id}/discountsDiscount a whole bill (a preset or your own).
discounts:writeDELETE /sessions/{id}/discounts/{session_discount_id}Remove a bill discount.
stock:writePOST /stock/adjustmentsAdd or take away stock on a stock-tracked item.
menu:writePOST /menu/items/{id}/availabilityMark an item sold out (86) or back on.
menu:writePOST /menu/modifiers/{id}/availabilityMark 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" }
}

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": [ … ] }

Voids

POST /order-items/{order_item_id}/void   { "reason": "Customer changed their mind" }

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}

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 }

POST /sessions/{id}/payments

{ "payments": [ { "payment_method_id": "…", "amount": 1000, "reference": "…" } ] }

The response has the payments recorded, fully_paid, paid, balance and the receipt_number once the bill is closed.

Notes

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:

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

EventWhen
session.openedA table is seated, or a counter order starts
order.createdA round or counter order is placed
order_item.state_changedThe kitchen moves an item (preparing, ready, served)
order_item.voidedAn item is voided
session.bill_requestedThe bill is requested
session.bill_cancelledStaff cancel a bill request (the session is active again)
payment.createdA payment is recorded
session.paidThe bill is fully paid and closed
session.reopenedA paid bill is reopened for a correction
menu_item.availability_changedAn item or a modifier option is sold out or back on
stock.adjustedStock 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:

Eventdata
session.*session_id, table_id (null at a counter), status
order.createdsession_id, order_id, order_number, order_type
order_item.state_changedorder_item_id, order_id, session_id, menu_item_id, state, previous_state
order_item.voidedorder_item_id, order_id, session_id, menu_item_id, quantity
payment.createdpayment_id, session_id, order_id, payment_method_id, amount (smallest unit)
menu_item.availability_changedkind (menu_item or modifier), id, available
stock.adjustedmenu_item_id, delta, reason, quantity_before, quantity_after, order_id
pingendpoint_id, message

New fields may be added to data; ignore the ones you do not know.

Checking the signature

Each request carries these headers:

HeaderValue
Nicework-Signaturet=<unix seconds>,v1=<hex HMAC-SHA256>
Nicework-Event-IdThe event id
Nicework-Event-TypeThe 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

Questions, or found something wrong? hello@nicework.sg