Skip to content

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.

  1. 1
    Link the merchant Once per merchant · OAuth
    1. Merchant Clicks Connect LithosPOS in your merchant panel
    2. Merchant Approves your app my.lithospos.com/oauth/authorize
    3. You Exchanges the code for tokens POST /v1/oauth/token
  2. 2
    Map stores and tables Once, then whenever the floor plan changes
    1. You List stores GET /v1/stores
    2. You List tables GET /v1/tables?storeId=
    3. You Print a QR code per table keyed on tableId
  3. 3
    Take the order Each time a guest orders
    1. You Place the order POST /v1/table-orders
    2. Merchant Accepts and prepares it POS · kitchen screens
    3. You Poll its status GET /v1/table-orders/:orderId/status
  4. 4
    Collect the payment When the guest pays
    1. You Read the open bill GET /v1/tables/:tableId/bill
    2. You Record the payment POST /v1/table-orders/:orderId/payments
    3. POS closes the paid order
Merchant You

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.

Match your outlets to the merchant’s stores once with GET /v1/stores, then list each store’s tables:

Terminal window
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 }
}
FieldTypeNotes
tableIdnumberStable id. Key your QR codes on it
namestringThe table’s name on the merchant’s floor plan. Merchants rename tables; the id stays
storeIdnumberThe store the table belongs to
sessionIdnumberThe floor plan (dining area) the table sits in
sessionNamestringThat area’s name, such as Terrace or Main hall
chairsnumberSeats 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.

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.

Terminal window
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
}'
FieldTypeRequiredNotes
storeIdnumberYesMust be a store on your grant
tableIdnumberOne of the twoThe table from GET /v1/tables. Wins when both are sent
tableNamestringOne of the twoFor QR codes that carry the table’s name rather than its id
externalOrderIdstringYesYour own order id, up to 100 characters. The idempotency key, scoped to your app
itemsarrayYesOne entry per line
items[].itemIdnumberYesItem id from the menu
items[].variantIdnumberThe variant id from the menu; 0 for an item without variants
items[].qtynumberYesQuantity
items[].notestringKitchen note for the line
items[].modifiersarray{ "modifierId", "qty" } per selected option
customerobject{ "name", "phone" } of the guest
notestringNote for the whole order
guestsnumberParty size

The table must belong to storeId, or the call returns 404 table_orders.table_not_found.

201 Created
{
"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.

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.

Terminal window
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.

FieldValuesMeaning
statusnewWaiting for the store to accept it
preparingAccepted; the kitchen is working on it
readyThe kitchen marked it done
completedThe store closed the bill
cancelledCancelled by you or by the store
paymentStatusunpaidNo payment recorded
partially_paidPayments cover part of totalAmount
paidPayments cover totalAmount
billStatus0 · 1 · 2The POS bill: unbilled, billed, paid
accepted, foodReadybooleanThe 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.

PhaseInterval
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 billEvery 60 seconds, or read once later
completed or cancelledStop

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.

When the guest opens the payment screen, read what is outstanding on their table:

Terminal window
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.

Once your payment provider has captured the guest’s money, record it against the order:

Terminal window
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"
}'
FieldTypeRequiredNotes
paymentRefstringYesYour transaction id, up to 100 characters. The idempotency key: one ref, one payment
amountnumberYesWhat the guest paid towards the order, excluding the tip. More than 0 and no more than outstanding
tipAmountnumberThe guest’s tip, on top of amount
currencystringISO 4217 code. If you send it, it must equal the store’s currency
paidAtstringRecommendedISO 8601 time the guest paid
methodstringHow the guest paid, such as card. Informational
201 Created
{
"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 becomes paid once its payments cover totalAmount.
  • Retries are safe. Repeating a paymentRef returns 200 with the original result and "duplicate": true. Nothing is booked twice. A paymentRef belongs to one order: sending it against a different order returns 409 table_orders.payment_ref_conflict.
  • Amounts are major units in the store’s currency: 82.50, not 8250.

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.

Terminal window
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.

Every failure uses the partner error envelope. Branch on code, not on the message.

StatusCodeWhenWhat to do
400noneA field is missing, malformed or unknownFix the request. The message names the field
403partner.store_not_grantedstoreId is outside the stores on your grantUse a store from GET /v1/stores
403partner.scope_deniedThe app’s product does not carry the route’s scopesUse a Table QR Payment or Full API app
403partner.grant_missingThe merchant disconnected your appStop sending; ask the merchant to reconnect
400table_orders.table_requiredNeither tableId nor tableName was sentSend one of them
404table_orders.not_foundThe order does not exist, is not a table order your app placed, or is at a store outside your grantCheck the orderId returned at creation
404table_orders.table_not_foundThe table is not in that storeRe-read GET /v1/tables; the table may have been deleted
409table_orders.not_configuredThe 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
409table_orders.order_cancelledPayment on a cancelled orderDo not charge the guest; show the order as cancelled
409table_orders.already_paidPayment on an order that is already fully paidRead the order; the earlier payments already cover it
409table_orders.order_closedThe store already closed the bill on the till with its own tenderDo not charge the guest; the bill is settled at the counter
409table_orders.cancel_not_allowedCancel after the store accepted the order, or after a paymentAsk the store to cancel at the POS
409table_orders.payment_ref_conflictThe paymentRef is already used on another orderUse a new paymentRef per payment
422table_orders.item_not_foundAn item, variant or modifier is unknown or inactiveRe-sync the menu and rebuild the basket
422table_orders.variant_requiredThe item is sold in variants and no valid variantId was sent, or a variantId was sent for an item without variantsUse a variant id from the menu; 0 for items without variants
422table_orders.modifier_not_allowedThe modifier option is not offered on that itemOnly send options listed under the item’s modifier groups
422table_orders.combo_unsupportedA line is a meal or comboRemove it; hide meals from the guest’s menu
422table_orders.amount_exceeds_outstandingamount is more than the order’s outstandingRead the order and pay at most outstanding
422table_orders.currency_mismatchcurrency is not the store’s currencySend the store’s currency, or leave currency out
422table_orders.amount_invalidamount is zero or negativeSend the amount paid towards the bill
503table_orders.payments_unavailablePayments are not yet enabled on that merchant’s databaseRetry later with the same paymentRef
429partner.rate_limitedMore than 300 requests this minuteWait for X-RateLimit-Reset
SituationAction
Network error or 5xx on a create or a paymentSend the same body again. externalOrderId and paymentRef make it safe
401Refresh the access token once, then retry. If it fails again, stop
429Wait until X-RateLimit-Reset, then retry
Any other 4xxDo 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.