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.
- Website address — exactly the address your ordering page is served from, e.g.
- 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 presentThat 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-Sessionand the diner'sAuthorizationheader. 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,
/meincluded, answers401— 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
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/pngUse 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}/contextThis tells you everything the venue allows at this table. The fields you will use:
| Field | What it tells you |
|---|---|
tableName, locationName | What to show the diner, e.g. Table 4 · Patio. |
restaurant | Name, logo, currency symbol and contact details. |
enablePayAtTill, enableManualEft, enableCustomerWallet, enableOnlinePayments | Which 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[], defaultOrderType | Whether the diner may choose dine-in or takeaway, and from what. |
tipsEnabled, tipPercentages[], allowCustomTip, tipNoteEnabled, qrTipMessage | Whether to offer a tip, the preset percentages, and whether a custom amount or a note is allowed. |
requiresLocation, qrGeofenceStrict | The venue only takes orders from diners on site: send the phone's coordinates with the order. When strict, an order without them is refused. |
isPartiallyOpen | The kitchen is closed but some categories (e.g. drinks) can still be ordered; others are refused. |
emailReceiptsEnabled, whatsAppEnabled, loyaltyEnabled | Whether 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. |
companySlug | The venue's short name. Not needed on the venue's address. |
sessionToken | Keep 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. Thetitlesays 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.402with{"error":"module_not_included","module":"qr_ordering","detail":"…"}— the venue's plan does not include QR ordering. This one has notitle; 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…"] }
]
}| Field | Rules |
|---|---|
guestName | Up to 200 characters. Make it required in your form. |
guestPhone, guestEmail | Optional; up to 32 and 256 characters. |
lines | 1 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. |
orderType | DineIn, Takeaway or Retail, and only one the table context offers. Leave it out for the venue's default. |
paymentMethod, gatewayType | PayAtTill, Online, Eft or Wallet — see payments. Online always needs a gatewayType. |
notes | Optional note for the whole order, up to 500 characters. |
tipPercentage, tipAmount, tipNote | Only 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. |
promotionCode | Optional. An invalid code is a 400 on this field; ask again, or place the order without it. |
lines[].unitPrice | Only 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, customerLongitude | Only when the context has requiresLocation. |
sendEmailReceipt, sendWhatsAppNotification | Optional. 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:
400with{"errors":{"lines":["Please complete the required options…"]}}— the key is the field at fault (orderfor pricing, availability and tip problems). The messages are written for diners; show them.403withgeofenceError: true— the diner is not at the venue. It also carriesgeofenceReason(location_not_providedoroutside_geofenceare the ones a diner can fix),distanceMetersandradiusMeters. Any other403is the venue closing (as in step 1) or, forOnline, card payments having just been switched off — show itstitle.- For
Online, if the venue's gateway cannot start the payment, the order is cancelled and the reply is the gateway's error (often502) instead of201. 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
400onlines: “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}/trackingReturns 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}/cancelOnly 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.
| paymentMethod | What happens |
|---|---|
PayAtTill | The order goes to the kitchen; the diner pays staff. Nothing else to do. |
Online | Card 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. |
Eft | The 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. |
Wallet | Needs 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
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: truebefore 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}/checkoutwith{"gatewayType": "…", "returnUrl": "…", "cancelUrl": "…"}answers201with a newpaymentUrl, or409when 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) andpassword(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),phoneis 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. 409means the email already has an account;400carries field messages. Sign-in answers the same401for 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/confirmwith{"code": "123456"}answers204, or400for a wrong or expired code.POST /api/customers/me/email-verification/requestsends 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=Laxcookie (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
401fromGET /api/customers/meproves the diner is signed out. Other calls answer 401 for other reasons too (a wrong current password, for one), so check/mebefore throwing a diner out.
What a signed-in diner can do
| Call | Purpose |
|---|---|
GET /api/customers/me | Who is signed in. |
GET / PATCH /api/customers/me/profile | Read 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/orders | Order 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/loyalty | Points balance and member number (enrolled: false if not a member yet); POST /api/customers/me/loyalty/enroll joins. |
GET /api/customers/me/wallet | Wallet 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.
| Call | Limit |
|---|---|
| Place an order | 10 a minute per table |
| Switch an existing order to card (checkout) | 10 a minute for the whole venue |
| Read the table | 60 a minute per table |
| Track and cancel an order | 60 a minute per order, shared |
| Read the menu | 120 a minute per table |
| Sign in, change password, confirm a code | 30 a minute for the venue |
| Sign up, request a reset code, request a verification code | 20 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
- Scan a real table code with a phone — it opens your page, showing the right table.
- Place a pay-at-till order with a name, and check the name appears on the venue's till or kitchen screen.
- Pay a small order by card, and check you land back on your page — not a PosiTack page — and the order shows as paid.
- 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. - If accounts are on: sign up, sign in, place an order signed in, and find it in the order history.
- Open your browser's developer tools during all of this: there should be no calls to
positack.co.zafrom 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.
