Skip to content
PosiTack developers

Build your own ordering page

For sites that are not WordPress, or need a design the plugin cannot give. Everything the plugin does, done by your own code.

Before you start

If the site runs WordPress, the plugin already does all of this and is the quicker route — see Getting started. This page is for everything else. You need:

  • A venue on a PosiTack plan that includes API access (for the website key calls) and QR ordering.
  • A website key the venue creates in Settings → Websites → Add website:
    • Website address — exactly the address your ordering page is served from, e.g. https://www.thevenue.co.za. It is what allows a diner who pays by card to come back to your page; see card payments.
    • Menu is always on (it is what looks up the venue's address). Tick Ordering (needed for the table list and QR images), and Customer accounts too if diners will sign up or sign in on your site — that is how the venue records that it agreed to it.
  • At least one way to pay switched on at the venue — see payments.
  • The venue's table QR codes pointed at your page — see table QR codes.

Rule one: call PosiTack from your server, never from the browser

PosiTack does not accept calls made by a web page on another domain. A fetch from the diner's phone to PosiTack is blocked by the browser before it arrives, and shows up in the console as:

Access to fetch at 'https://thevenue.positack.co.za/api/...' from origin 'https://www.thevenue.co.za'
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present

That is deliberate, and it will not be switched on for your domain. Instead, your page calls your own server, and your server calls PosiTack:

diner's phone  →  https://www.thevenue.co.za/your-api/...  →  https://thevenue.positack.co.za/api/...
                   (same domain as the page: no CORS)            (server to server: no CORS)

Build that relay small and strict:

  • Forward only the paths on this page. Keep an allow-list and answer 404 to anything else, so your relay can never be used to reach the rest of PosiTack.
  • Pass through Content-Type, Idempotency-Key, X-Qr-Session and the diner's Authorization header. Drop cookies and everything else.
  • Return PosiTack's status code and body unchanged, so your page can show the messages PosiTack writes for diners (“QR ordering is closed today.”).
  • Never send the website key to the browser. The ordering calls on this page do not need it at all.

Which address to call

Diner-facing calls go to the venue's own PosiTack address, of the form https://{venue}.positack.co.za. Do not guess it — read it once with the website key and store it:

GET https://positack.co.za/api/integration/site
X-Positack-Site-Key: pt_site_...

→ 200 { "company": {...}, "tenantHost": "thevenue.positack.co.za", "scopes": ["menu","ordering","customers"], ... }

Use that address for every call on this page. The table and order calls also happen to work on positack.co.za, because the table identifies the venue — but diner accounts do not, and they fail in ways that look like something else:

  • Sign-up and sign-in answer 404 {"error":"No tenant resolved for this request."}.
  • Every signed-in call, /me included, answers 401 — exactly as if the diner had been signed out.
  • A forgotten-password request is accepted and no email is ever sent.

Table QR codes

Each table has an id (a uuid) and its QR code holds a full web address ending in ?table= and that id. Your ordering page reads table from its own address; every call below takes it.

Out of the box the codes open PosiTack's own ordering page. To make them open yours, email support@positack.co.za with the venue's name and the full https:// address of your ordering page (e.g. https://www.thevenue.co.za/order, not just the domain).

Codes already on the tables keep their old address

A printed code cannot change. Codes made before the change still open PosiTack's ordering page (they keep working there); only codes made afterwards open your page. Reprint the venue's codes once support confirms the change.

The website key gives you the list of tables, and each table's code as a PNG:

GET /api/integration/tables                       (Ordering scope)
→ { "tables": [ { "name": "Table 4", "locationName": "Patio", "qrCodeUuid": "c41b2379-…", "orderUrl": "https://…?table=c41b2379-…", "id": "…", … } ] }

GET /api/integration/tables/{qrCodeUuid}/qr-code  →  image/png

Use qrCodeUuid — it is the table id in every call below. The row's own id is a different value and answers 404 everywhere. Once support has made the change above, orderUrl is exactly what that table's new code opens — the quickest way to check it has been done. If staff ever issue a table a new code, its qrCodeUuid changes too.

Taking an order, step by step

In each step, {uuid} is the table id from the QR code and the host is the venue's address from above. None of these calls needs the website key or a signed-in diner.

1. Read the table

GET /api/public/qr/{uuid}/context

This tells you everything the venue allows at this table. The fields you will use:

FieldWhat it tells you
tableName, locationNameWhat to show the diner, e.g. Table 4 · Patio.
restaurantName, logo, currency symbol and contact details.
enablePayAtTill, enableManualEft, enableCustomerWallet, enableOnlinePaymentsWhich ways to pay to offer. Offer only these. defaultPaymentMethod is the one to pre-select.
onlinePaymentGateways[]The card gateways, each with a gatewayType to send back and a displayName to show.
enableOrderTypeSelection, orderTypeOptions[], defaultOrderTypeWhether the diner may choose dine-in or takeaway, and from what.
tipsEnabled, tipPercentages[], allowCustomTip, tipNoteEnabled, qrTipMessageWhether to offer a tip, the preset percentages, and whether a custom amount or a note is allowed.
requiresLocation, qrGeofenceStrictThe venue only takes orders from diners on site: send the phone's coordinates with the order. When strict, an order without them is refused.
isPartiallyOpenThe kitchen is closed but some categories (e.g. drinks) can still be ordered; others are refused.
emailReceiptsEnabled, whatsAppEnabled, loyaltyEnabledWhether to offer an emailed receipt, a WhatsApp update, and loyalty sign-up.
openPriceItems{ "minAmount", "maxAmount" } when the venue lets diners enter their own amount for items such as donations, otherwise null — see donations.
companySlugThe venue's short name. Not needed on the venue's address.
sessionTokenKeep it on your server for this diner and send it back with the menu and the order. It gives a diner who scanned just before closing time a short grace period to finish. Missing or expired is never an error.

If this call fails:

  • 403 — the venue is not taking QR orders right now. The title says why, in words for diners: “QR ordering is closed today.”, “QR ordering is available from 11:00 to 21:00.” or “QR ordering is disabled for this company.” Show it as it is.
  • 404 — the table id is unknown or the table was removed.
  • 402 with {"error":"module_not_included","module":"qr_ordering","detail":"…"} — the venue's plan does not include QR ordering. This one has no title; show a general “ordering is not available” message. Any table or order call can answer it.

2. Read the menu

GET /api/menu/public?table={uuid}
X-Qr-Session: {sessionToken}

Always pass table: it selects the branch, so the prices shown are the prices charged. The reply is categories[] — already in the venue's order, so keep it; a category with a parentId sits inside another — each with items[]: id, name, price, description, imageUrl, dietaryTags, isAlcoholic, isAvailable (with unavailableReason when false), allowOpenPrice and modifierGroups[]. An item with allowOpenPrice: true is priced by the diner — see donations; show “Choose your amount” rather than its price, which is usually 0.

A modifier group (“Choose your sauce”) has isRequired, minSelections, maxSelections and modifiers[], each with an id, name, priceAdjustment and isOutOfStock. A group with parentModifierIds only applies once one of those modifiers is chosen (“How would you like your steak?” appears after “Steak”).

3. Place the order — with the diner's name

Diners order as guests; no account is needed. Ask for their name at checkout and send it as guestName — the staff see it on the order, so a runner can find “Thandi” rather than just “Table 4”. PosiTack does not require it, so if your checkout does not ask, orders arrive without a name.

POST /api/public/qr/{uuid}/orders
Content-Type: application/json
Idempotency-Key: 6f1c0b4e9a2d4f7c8e3b5a1d0c9f8e7a

{
  "guestName": "Thandi",
  "guestPhone": "0821234567",
  "guestEmail": "thandi@example.com",
  "orderType": "DineIn",
  "paymentMethod": "PayAtTill",
  "sessionToken": "…from step 1…",
  "lines": [
    { "menuItemId": "0199…", "quantity": 2 },
    { "menuItemId": "0199…", "quantity": 1, "notes": "No onions", "modifierIds": ["0199…", "0199…"] }
  ]
}
FieldRules
guestNameUp to 200 characters. Make it required in your form.
guestPhone, guestEmailOptional; up to 32 and 256 characters.
lines1 to 50 lines. Each has a menuItemId, an optional quantity (default 1; more than 0, up to 999), optional notes (500 characters) and optional modifierIds (up to 20). Never send a price — PosiTack prices every order itself.
orderTypeDineIn, Takeaway or Retail, and only one the table context offers. Leave it out for the venue's default.
paymentMethod, gatewayTypePayAtTill, Online, Eft or Wallet — see payments. Online always needs a gatewayType.
notesOptional note for the whole order, up to 500 characters.
tipPercentage, tipAmount, tipNoteOnly when the context has tipsEnabled; ignored otherwise and on wallet orders. Send one of tipPercentages, or a tipAmount when allowCustomTip is on (it cannot exceed the order total). tipNote is kept only when tipNoteEnabled.
promotionCodeOptional. An invalid code is a 400 on this field; ask again, or place the order without it.
lines[].unitPriceOnly on a line for an allowOpenPrice item, and then required: the amount the diner entered, inside openPriceItems. On any other line it is refused — see donations.
customerLatitude, customerLongitudeOnly when the context has requiresLocation.
sendEmailReceipt, sendWhatsAppNotificationOptional. Offer them only when the context's emailReceiptsEnabled / whatsAppEnabled is true, and with the matching email or phone filled in.

Idempotency-Key. Send a fresh random value for each order and reuse it if you retry the same order within two minutes (a timeout, a double tap): PosiTack then returns the first reply instead of creating a second order. After two minutes the same key places a new order. A refused order is not remembered, so it is always safe to fix and resend.

Success is 201 with orderId, orderNumber and orderNumberPrefix (the number a diner quotes is the two joined, e.g. A and 17 make A17), status, subtotal, vatAmount, tipAmount, total, amountDue, lines[] and, for card payments, paymentUrl. Keep orderId on your side: it is the only key to tracking and cancelling this order, so treat it like a receipt only this diner holds.

Refusals come one at a time, as the first problem found:

  • 400 with {"errors":{"lines":["Please complete the required options…"]}} — the key is the field at fault (order for pricing, availability and tip problems). The messages are written for diners; show them.
  • 403 with geofenceError: true — the diner is not at the venue. It also carries geofenceReason (location_not_provided or outside_geofence are the ones a diner can fix), distanceMeters and radiusMeters. Any other 403 is the venue closing (as in step 1) or, for Online, card payments having just been switched off — show its title.
  • For Online, if the venue's gateway cannot start the payment, the order is cancelled and the reply is the gateway's error (often 502) instead of 201. Nothing was kept, so the diner can simply try again.
  • 409 “Could not allocate an order number. Please retry.” — resend with the same Idempotency-Key.

Donations and other “choose your amount” items

A venue can let diners enter their own amount for open-price items — a donation, a pay-what-you-like item. The venue switches it on in Settings → Operations → Ordering & payment → QR ordering (“Let diners choose their own amount”) and sets the smallest and largest amount allowed. Until it does, such items are simply not on the QR menu, and an order for one is refused.

When it is on:

  • The item appears on the menu with allowOpenPrice: true, and the table context carries "openPriceItems": { "minAmount": 1, "maxAmount": 5000 }.
  • Ask the diner for the amount before adding the item, and send it as that line's unitPrice, e.g. { "menuItemId": "0199…", "quantity": 1, "unitPrice": 50 }. PosiTack charges exactly that, plus any paid options.
  • Refusals are 400 on lines: “Enter the amount for each item that asks for one.”, “Amounts must be between 1.00 and 5000.00.”, or “Only items that ask for an amount accept one.” if a price is sent for anything else. Amounts carry at most two decimal places.
  • Keep two amounts of the same item as separate lines — a R20 and a R50 donation are two lines, not one line with a quantity of two.

4. Show the order's progress

GET /api/public/orders/{orderId}/tracking

Returns orderNumber, status, isPaid, currencySymbol, total, amountDue, items[] and estimatedReadyAtUtc — but not orderNumberPrefix, so keep that from the order reply. The status moves Pending → Confirmed → Preparing → Ready → Completed, or ends at Cancelled. Asking every 15 seconds while it is not finished is plenty; each call also checks the gateway for a card payment still in progress.

5. Let the diner cancel (optional)

POST /api/public/orders/{orderId}/cancel

Only works while the order is Pending and unpaid; after that it answers 409 and the diner must ask a waiter. Cancelling twice is harmless.

Payments

The venue chooses which of these diners may use, in Settings → Operations → Ordering & payment → Taking payment. If the table context shows none of them switched on, no order can be placed — ask the venue to switch one on (“Pay at till” is the simplest). “Pay online” also needs a payment gateway set up under Settings → Online Gateways; until then the context reports enableOnlinePayments: false.

paymentMethodWhat happens
PayAtTillThe order goes to the kitchen; the diner pays staff. Nothing else to do.
OnlineCard payment on the venue's own gateway. Also send gatewayType (from onlinePaymentGateways), returnUrl and cancelUrl, then send the diner's browser to the paymentUrl in the reply.
EftThe order waits for a bank transfer. The reference is orderNumber on its own — without the prefix (e.g. 17, not A17). Show the bank details from GET /api/public/companies/eft-details: bankAccountHolder, bankName, bankAccountNumber, bankBranchCode, bankAccountType.
WalletNeeds a signed-in diner. The order is created unpaid; then call POST /api/customers/me/wallet/pay-order with {"orderId": "…"}. If that fails, cancel the order.

Coming back from a card payment

After paying, the gateway sends the diner to your returnUrl, with PosiTack adding ?paidOrder={orderId}; if they give up, to your cancelUrl with ?cancelledOrder={orderId}. Any query string you put on the address is kept. Send both as full https:// addresses — a relative one is taken to mean the PosiTack address.

Both addresses must be on the website address registered on the key

PosiTack only sends a diner who has just paid to an address the venue registered — anything else is quietly replaced with a PosiTack page, and nothing reports an error. Only the site name is compared: www. and no www. count as the same site, but a staging domain, a different subdomain or a typo in the saved address do not. If diners land on a PosiTack page after paying, check the address in Settings → Websites first. Revoking the key withdraws it.
  • Returning to your page is not proof of payment. Read the order's tracking and wait for isPaid: true before telling the diner they have paid.
  • A declined or cancelled card payment cancels the order. When the gateway reports either on an order placed with Online, PosiTack cancels that order, so the kitchen never makes food nobody paid for. To let the diner try again, place the order again (with a new Idempotency-Key) — the cancelled one cannot be paid.
  • A diner who chose to pay at the till can switch to card later: POST /api/public/orders/{orderId}/checkout with {"gatewayType": "…", "returnUrl": "…", "cancelUrl": "…"} answers 201 with a new paymentUrl, or 409 when the order can no longer be paid online (already paid, finished, or waiting for an EFT). A decline here leaves the order standing.

Diner accounts: sign-up, sign-in, loyalty and wallet

Optional. Diners can always order as guests; an account adds order history, loyalty points and a wallet balance. Every call in this section must go to the venue's address (see above).

Signing up and signing in

POST /api/customers/register
{
  "email": "thandi@example.com",
  "password": "at least 8 characters",
  "firstName": "Thandi",
  "lastName": "Nkosi",
  "phone": "0821234567",
  "marketingOptIn": false,
  "whatsAppOptIn": false
}

POST /api/customers/login
{ "email": "thandi@example.com", "password": "…" }

Both → 200 { "token": "…", "expiresAt": "…", "customer": { "id", "email", "displayName", "phone", "walletBalance", "emailVerified", … } }
  • Only email (up to 254 characters) and password (8 to 128) are required; names are optional, up to 100 characters. dateOfBirth (YYYY-MM-DD) is optional too. A successful sign-up signs the diner straight in.
  • When the venue runs a loyalty programme (loyaltyEnabled), phone is required — it is how the till finds their card. A number already on another online account is refused; a till-only card with that number is joined to the new account, points and all.
  • 409 means the email already has an account; 400 carries field messages. Sign-in answers the same 401 for a wrong email or a wrong password, on purpose.
  • If your site asks diners to accept your terms or the venue's, keep your own record of who accepted what and when — PosiTack only records marketing and WhatsApp consent.
  • When the venue has email switched on, a new diner is emailed a 6-digit code (valid 30 minutes) to confirm their address: POST /api/customers/me/email-verification/confirm with {"code": "123456"} answers 204, or 400 for a wrong or expired code. POST /api/customers/me/email-verification/request sends a new one.

Using the token

Every signed-in call carries it in this exact form — not Bearer:

Authorization: CustomerToken {token}
  • Keep it off the page. Store it on your server, or in an HttpOnly, Secure, SameSite=Lax cookie (Lax, so it survives the return from a card payment), and have your relay add the header.
  • It lasts 30 days. There is no refresh call — when it expires, the diner signs in again. There is no sign-out call either: signing out means deleting your copy. The token only stops working earlier if the diner changes or resets their password, or the venue deactivates their account.
  • Link the order to the diner by sending the header on the order call in step 3. The order then appears in their history and earns loyalty points. An expired token is ignored there and the order is placed as a guest, so ordering never fails because of it.
  • Only a 401 from GET /api/customers/me proves the diner is signed out. Other calls answer 401 for other reasons too (a wrong current password, for one), so check /me before throwing a diner out.

What a signed-in diner can do

CallPurpose
GET /api/customers/meWho is signed in.
GET / PATCH /api/customers/me/profileRead or change name, phone and marketing choices. A birthday change also needs "dateOfBirthProvided": true, otherwise dateOfBirth is ignored.
POST /api/customers/me/password{"currentPassword", "newPassword"}. Signs the diner out everywhere and returns a new token — replace the stored one.
GET /api/customers/me/ordersOrder history; /me/orders/{id} for one order; /me/orders/{id}/receipt for its PDF (409 until paid); POST /me/orders/{id}/receipt/email to email it.
GET /api/customers/me/loyaltyPoints balance and member number (enrolled: false if not a member yet); POST /api/customers/me/loyalty/enroll joins.
GET /api/customers/me/walletWallet balance; /me/wallet/transactions for the last 50 movements.

Topping up a wallet by card is not yet available to sites built this way — after paying, the diner would land on a PosiTack page rather than yours. Diners can top up at the venue.

Forgotten password

POST /api/customers/password-reset/request   { "email": "thandi@example.com" }
→ 202 always (it never reveals whether the email has an account)

POST /api/customers/password-reset/confirm   { "email": "…", "code": "123456", "newPassword": "…" }
→ 204 done · 401 wrong or expired code (codes last 15 minutes)

The code is emailed only when the venue has email switched on.

Limits

PosiTack caps how often each call can be made. Over the limit, it answers 429 with a Retry-After header.

CallLimit
Place an order10 a minute per table
Switch an existing order to card (checkout)10 a minute for the whole venue
Read the table60 a minute per table
Track and cancel an order60 a minute per order, shared
Read the menu120 a minute per table
Sign in, change password, confirm a code30 a minute for the venue
Sign up, request a reset code, request a verification code20 an hour for the venue, shared between all three

The account limits are shared by every sign-in on the venue's address — every diner, whichever website or app they use, and staff signing in there too. Cache the menu on your side rather than reading it for every visitor, and if you expect more sign-ups than that — a launch night, a promotion — tell support@positack.co.za in advance.

Before you go live

  1. Scan a real table code with a phone — it opens your page, showing the right table.
  2. Place a pay-at-till order with a name, and check the name appears on the venue's till or kitchen screen.
  3. Pay a small order by card, and check you land back on your page — not a PosiTack page — and the order shows as paid.
  4. Start a card payment and cancel it at the gateway; your page should read the order's status and, when it shows Cancelled, let the diner place it again.
  5. If accounts are on: sign up, sign in, place an order signed in, and find it in the order history.
  6. Open your browser's developer tools during all of this: there should be no calls to positack.co.za from the page itself.

The WordPress plugin is a complete, working example of most of this page — its includes/class-positack-rest.php is the relay described in rule one — and is worth reading even if your site is not WordPress.