Order and payment flow
A Table QR order goes from the guest’s phone to the store’s POS on the right table, through the kitchen, and back to your app as a bill the guest pays. The store drives the kitchen side: accept, prepare, mark ready, close the bill. Your job is to send a correct order, record each payment exactly once, and show the guest where things stand.
- 1Link the merchant Once per merchant · OAuth
- Merchant Clicks Connect LithosPOS in your merchant panel
- Merchant Approves your app my.lithospos.com/oauth/authorize
- You Exchanges the code for tokens POST /v1/oauth/token
- 2Map stores and tables Once, then whenever the floor plan changes
- You List stores GET /v1/stores
- You List tables GET /v1/tables?storeId=
- You Print a QR code per table keyed on tableId
- 3Take the order Each time a guest orders
- You Place the order POST /v1/table-orders
- Merchant Accepts and prepares it POS · kitchen screens
- You Poll its status GET /v1/table-orders/:orderId/status
- 4Collect the payment When the guest pays
- You Read the open bill GET /v1/tables/:tableId/bill
- You Record the payment POST /v1/table-orders/:orderId/payments
- POS closes the paid order
Link the merchant
Section titled “Link the merchant”Each merchant links your app once, through the OAuth consent flow: your
Connect LithosPOS button sends the merchant to https://my.lithospos.com/oauth/authorize,
their account owner approves, and your backend exchanges the returned code at
POST /v1/oauth/token for an access token and a refresh token.
One token is one merchant. Store the refresh token against the merchant in your system, refresh
before the 900-second access token expires, and save the new refresh token each time. If a
refresh fails with invalid_grant, the merchant has disconnected you: stop sending orders for
their tables and ask them to reconnect.
Map stores and tables
Section titled “Map stores and tables”Match your outlets to the merchant’s stores once with GET /v1/stores, then list each store’s
tables:
curl -s "$API_BASE_URL/tables?storeId=3" \ -H "Authorization: Bearer $ACCESS_TOKEN"{ "data": [ { "tableId": 41, "name": "T12", "storeId": 3, "sessionId": 2, "sessionName": "Terrace", "chairs": 4 }, { "tableId": 42, "name": "T14", "storeId": 3, "sessionId": 2, "sessionName": "Terrace", "chairs": 2 } ], "meta": { "page": 1, "limit": 10, "total": 18 }}| Field | Type | Notes |
|---|---|---|
tableId | number | Stable id. Key your QR codes on it |
name | string | The table’s name on the merchant’s floor plan. Merchants rename tables; the id stays |
storeId | number | The store the table belongs to |
sessionId | number | The floor plan (dining area) the table sits in |
sessionName | string | That area’s name, such as Terrace or Main hall |
chairs | number | Seats at the table |
Leave out storeId to list the tables of every store on your grant. The list pages with
page and limit like every other list. Re-read it daily: a table the merchant deletes stops
accepting orders with 404 table_orders.table_not_found.
Read the menu
Section titled “Read the menu”The guest’s menu comes from the same reads the Online Order API uses: GET /v1/menus?storeId=…,
then GET /v1/menus/:id/full for items, variants and modifiers. See
Menu sync for keeping it current. Send back the
item, variant and modifier ids those reads return.
Meals and combos cannot be ordered through a table order in this version and return
422 table_orders.combo_unsupported. Leave them off the guest’s menu.
Place the order
Section titled “Place the order”curl -s "$API_BASE_URL/table-orders" \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H 'Content-Type: application/json' \ -d '{ "storeId": 3, "tableId": 41, "tableName": "T12", "externalOrderId": "NOON-8F2K1", "items": [ { "itemId": 120, "variantId": 0, "qty": 2, "note": "no onion", "modifiers": [{ "modifierId": 55, "qty": 1 }] } ], "customer": { "name": "Sara", "phone": "+971500000000" }, "note": "Birthday", "guests": 4 }'| Field | Type | Required | Notes |
|---|---|---|---|
storeId | number | Yes | Must be a store on your grant |
tableId | number | One of the two | The table from GET /v1/tables. Wins when both are sent |
tableName | string | One of the two | For QR codes that carry the table’s name rather than its id |
externalOrderId | string | Yes | Your own order id, up to 100 characters. The idempotency key, scoped to your app |
items | array | Yes | One entry per line |
items[].itemId | number | Yes | Item id from the menu |
items[].variantId | number | The variant id from the menu; 0 for an item without variants | |
items[].qty | number | Yes | Quantity |
items[].note | string | Kitchen note for the line | |
items[].modifiers | array | { "modifierId", "qty" } per selected option | |
customer | object | { "name", "phone" } of the guest | |
note | string | Note for the whole order | |
guests | number | Party size |
The table must belong to storeId, or the call returns 404 table_orders.table_not_found.
{ "data": { "orderId": 51231, "externalOrderId": "NOON-8F2K1", "storeId": 3, "tableId": 41, "tableName": "T12", "status": "new", "paymentStatus": "unpaid", "subtotal": 173.81, "taxAmount": 8.69, "totalAmount": 182.50, "currency": "AED", "duplicate": false }}The order is on the store’s POS and kitchen screens, on table T12, as soon as this returns.
Keep orderId: every later call uses it.
Prices are computed by LithosPOS, from the store’s menu prices. The body has no price
fields. Show the guest subtotal, taxAmount and totalAmount from the response rather than
a total you worked out yourself, so the amount the guest pays is the amount the bill expects.
Retries and duplicates
Section titled “Retries and duplicates”externalOrderId makes the create safe to retry. If a request times out, send the same body
again. A repeat returns 200 with the order that already exists and "duplicate": true;
a new order returns 201 with "duplicate": false. Either way the kitchen makes it once.
A repeat returns the existing order as it is, not the basket you just sent. A second round of
drinks at the same table is a new order with its own externalOrderId. The table’s
bill groups the rounds together.
Follow the order
Section titled “Follow the order”curl -s "$API_BASE_URL/table-orders/51231/status" \ -H "Authorization: Bearer $ACCESS_TOKEN"{ "data": { "orderId": 51231, "externalOrderId": "NOON-8F2K1", "status": "preparing", "paymentStatus": "unpaid", "billStatus": 0, "accepted": true, "foodReady": false, "totalAmount": 182.50, "paidAmount": 0, "outstanding": 182.50 }}The order carries two independent ladders. status is the kitchen side, moved by the store.
paymentStatus is the money side, moved by the payments you record.
| Field | Values | Meaning |
|---|---|---|
status | new | Waiting for the store to accept it |
preparing | Accepted; the kitchen is working on it | |
ready | The kitchen marked it done | |
completed | The store closed the bill | |
cancelled | Cancelled by you or by the store | |
paymentStatus | unpaid | No payment recorded |
partially_paid | Payments cover part of totalAmount | |
paid | Payments cover totalAmount | |
billStatus | 0 · 1 · 2 | The POS bill: unbilled, billed, paid |
accepted, foodReady | boolean | The raw flags behind status |
status uses the same buckets as the Online Order API,
so one order reads the same through both products. Paying does not complete an order: a paid
order stays preparing or ready until the till settles the bill, then becomes completed.
GET /v1/table-orders/:orderId returns the whole order: its lines as priced by the store, the
payments recorded so far and the running totals.
{ "data": { "orderId": 51231, "externalOrderId": "NOON-8F2K1", "storeId": 3, "tableId": 41, "tableName": "T12", "status": "ready", "paymentStatus": "partially_paid", "accepted": true, "foodReady": true, "items": [ { "itemId": 120, "variantId": 0, "name": "Chicken shawarma plate", "qty": 2, "rate": 91.25, "amount": 182.50, "note": "no onion", "modifiers": [{ "modifierId": 55, "name": "Extra spicy", "qty": 1, "rate": 0 }] } ], "subtotal": 173.81, "taxAmount": 8.69, "tipAmount": 0, "totalAmount": 182.50, "paidAmount": 100.00, "outstanding": 82.50, "currency": "AED", "payments": [ { "paymentId": 9012, "paymentRef": "noon-txn-77401", "amount": 100.00, "tipAmount": 0, "currency": "AED", "method": "card", "paidAt": "2026-10-16T12:58:40Z" } ], "createdAt": "2026-10-16T12:31:05Z" }}A polling cadence that fits the rate limit
Section titled “A polling cadence that fits the rate limit”There are no webhooks in this version, so you follow an order by reading its status.
| Phase | Interval |
|---|---|
Waiting for acceptance (new) | Every 10 seconds, for up to 5 minutes |
In the kitchen (preparing, ready) | Every 30 seconds while the guest is at the table |
paid, waiting for the store to close the bill | Every 60 seconds, or read once later |
completed or cancelled | Stop |
The partner API allows 300 requests per minute per app, across every merchant you serve. One order polled every 10 seconds costs 6 requests a minute. Back off as soon as an order is accepted, and stop the moment it is terminal.
Read the bill
Section titled “Read the bill”When the guest opens the payment screen, read what is outstanding on their table:
curl -s "$API_BASE_URL/tables/41/bill" \ -H "Authorization: Bearer $ACCESS_TOKEN"{ "data": { "tableId": 41, "tableName": "T12", "storeId": 3, "currency": "AED", "orders": [ { "orderId": 51231, "externalOrderId": "NOON-8F2K1", "status": "ready", "paymentStatus": "partially_paid", "totalAmount": 182.50, "paidAmount": 100.00, "outstanding": 82.50 }, { "orderId": 51240, "externalOrderId": "NOON-8F2K9", "status": "new", "paymentStatus": "unpaid", "totalAmount": 42.00, "paidAmount": 0, "outstanding": 42.00 } ], "totalAmount": 224.50, "paidAmount": 100.00, "outstanding": 124.50 }}The bill lists each open order and the table’s totals. Payments are recorded per order, so
to settle the whole table, record one payment against each order that has an outstanding
amount.
Record the payment
Section titled “Record the payment”Once your payment provider has captured the guest’s money, record it against the order:
curl -s "$API_BASE_URL/table-orders/51231/payments" \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H 'Content-Type: application/json' \ -d '{ "paymentRef": "noon-txn-77412", "amount": 82.50, "tipAmount": 10.00, "currency": "AED", "paidAt": "2026-10-16T13:04:11Z", "method": "card" }'| Field | Type | Required | Notes |
|---|---|---|---|
paymentRef | string | Yes | Your transaction id, up to 100 characters. The idempotency key: one ref, one payment |
amount | number | Yes | What the guest paid towards the order, excluding the tip. More than 0 and no more than outstanding |
tipAmount | number | The guest’s tip, on top of amount | |
currency | string | ISO 4217 code. If you send it, it must equal the store’s currency | |
paidAt | string | Recommended | ISO 8601 time the guest paid |
method | string | How the guest paid, such as card. Informational |
{ "data": { "orderId": 51231, "paymentId": 9013, "paymentRef": "noon-txn-77412", "paymentStatus": "paid", "paidAmount": 182.50, "outstanding": 0, "duplicate": false }}- Partial payments are allowed. A guest can pay part of an order now and the rest later, or
a group can split it: record each payment with its own
paymentRef. The order becomespaidonce its payments covertotalAmount. - Retries are safe. Repeating a
paymentRefreturns200with the original result and"duplicate": true. Nothing is booked twice. ApaymentRefbelongs to one order: sending it against a different order returns409 table_orders.payment_ref_conflict. - Amounts are major units in the store’s currency:
82.50, not8250.
When an order is fully paid, the POS receives it as prepaid, settled to the merchant’s
Noon Food aggregator account, and closes it when the store bills the table. status then
becomes completed.
Cancel an order
Section titled “Cancel an order”curl -s "$API_BASE_URL/table-orders/51240/cancel" \ -X POST \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H 'Content-Type: application/json' \ -d '{ "reason": "Guest left" }'{ "data": { "orderId": 51240, "status": "cancelled" }}reason is optional. You can cancel only while the store has not accepted the order
(accepted is false) and no payment is recorded on it. After that the call returns
409 table_orders.cancel_not_allowed: the kitchen may already be cooking, so ask the store to
cancel it at the POS. The store can cancel an order at any time, which shows up as
cancelled on your next read.
Errors
Section titled “Errors”Every failure uses the partner error envelope. Branch on
code, not on the message.
| Status | Code | When | What to do |
|---|---|---|---|
400 | none | A field is missing, malformed or unknown | Fix the request. The message names the field |
403 | partner.store_not_granted | storeId is outside the stores on your grant | Use a store from GET /v1/stores |
403 | partner.scope_denied | The app’s product does not carry the route’s scopes | Use a Table QR Payment or Full API app |
403 | partner.grant_missing | The merchant disconnected your app | Stop sending; ask the merchant to reconnect |
400 | table_orders.table_required | Neither tableId nor tableName was sent | Send one of them |
404 | table_orders.not_found | The order does not exist, is not a table order your app placed, or is at a store outside your grant | Check the orderId returned at creation |
404 | table_orders.table_not_found | The table is not in that store | Re-read GET /v1/tables; the table may have been deleted |
409 | table_orders.not_configured | The merchant switched the Noon Food aggregator off (it is created automatically on the first order) | Ask the merchant to turn it back on under Settings → Aggregators |
409 | table_orders.order_cancelled | Payment on a cancelled order | Do not charge the guest; show the order as cancelled |
409 | table_orders.already_paid | Payment on an order that is already fully paid | Read the order; the earlier payments already cover it |
409 | table_orders.order_closed | The store already closed the bill on the till with its own tender | Do not charge the guest; the bill is settled at the counter |
409 | table_orders.cancel_not_allowed | Cancel after the store accepted the order, or after a payment | Ask the store to cancel at the POS |
409 | table_orders.payment_ref_conflict | The paymentRef is already used on another order | Use a new paymentRef per payment |
422 | table_orders.item_not_found | An item, variant or modifier is unknown or inactive | Re-sync the menu and rebuild the basket |
422 | table_orders.variant_required | The item is sold in variants and no valid variantId was sent, or a variantId was sent for an item without variants | Use a variant id from the menu; 0 for items without variants |
422 | table_orders.modifier_not_allowed | The modifier option is not offered on that item | Only send options listed under the item’s modifier groups |
422 | table_orders.combo_unsupported | A line is a meal or combo | Remove it; hide meals from the guest’s menu |
422 | table_orders.amount_exceeds_outstanding | amount is more than the order’s outstanding | Read the order and pay at most outstanding |
422 | table_orders.currency_mismatch | currency is not the store’s currency | Send the store’s currency, or leave currency out |
422 | table_orders.amount_invalid | amount is zero or negative | Send the amount paid towards the bill |
503 | table_orders.payments_unavailable | Payments are not yet enabled on that merchant’s database | Retry later with the same paymentRef |
429 | partner.rate_limited | More than 300 requests this minute | Wait for X-RateLimit-Reset |
When to retry
Section titled “When to retry”| Situation | Action |
|---|---|
Network error or 5xx on a create or a payment | Send the same body again. externalOrderId and paymentRef make it safe |
401 | Refresh the access token once, then retry. If it fails again, stop |
429 | Wait until X-RateLimit-Reset, then retry |
Any other 4xx | Do not retry unchanged. Fix the request, or tell the guest |
Show the merchant’s own message to your operations team, not to the guest. It is written for the person fixing the integration.