POS API Requirements — what your POS needs to provide¶
Version 1.0 · 4 September 2026 · Audience: POS vendors integrating with Cata via a Cata-built adapter.
This is the standard requirements document. It is the same for every POS vendor — send it as-is at first contact, before any scoping call.
How this integration works¶
Who builds what¶
flowchart LR
subgraph CATA["CATA — we build and run all of this"]
direction TB
APP["Cata ordering app<br/>(diner places order)"]
ADP["POS adapter<br/><b>written by Cata</b>"]
RCV["Webhook receiver<br/><code>CATA_WEBHOOK_URL</code>"]
APP --> ADP
end
subgraph POSS["YOUR POS — you build and run all of this"]
direction TB
API["Your API<br/><code>POS_BASE_URL</code>"]
TERM["POS terminal / kitchen"]
API --> TERM
end
ADP ==>|"1 connection test · 2 outlets<br/>3 products · 4 create order"| API
TERM ==>|"5 order status"| RCV
The boundary is the only thing that matters here. Everything in the left box is Cata's — including the adapter that talks to you, which we write and maintain. Everything in the right box is yours.
Four arrows point right: those are the endpoints you host and Cata calls
(POS_BASE_URL). One arrow points left: that is the status webhook, the single call
you make to us (CATA_WEBHOOK_URL).
You do not build a client against Cata's API, and you do not need Cata credentials for the catalogue side. The one exception is that one arrow back.
So: four endpoints you expose, one webhook you send.
Want the exact spec? Start at Appendix A.
Appendix A — Reference API gives the precise endpoints and payloads Cata's adapter expects. If you can implement it exactly, do — an exact implementation needs almost no mapping and is the fastest path to a working integration.
If your API already exists and differs, that is fine and expected. The sections below describe what the data must contain, independent of shape; we map the rest.
The Cata side of this pattern is already in production with other POS systems, so the runtime, the dispatch pipeline and the status receiver all exist. What is new for each vendor is the adapter itself.
What you need to provide¶
| # | Capability | Direction | Method | Path | Body | What you provide |
|---|---|---|---|---|---|---|
| 1 | Connection test | Cata → You | GET |
{POS_BASE_URL}/v1/health |
— | Any cheap authenticated read |
| 2 | Get outlets | Cata → You | GET |
{POS_BASE_URL}/v1/outlets |
— | List of your stores / locations |
| 3 | Get products | Cata → You | GET |
{POS_BASE_URL}/v1/outlets/{outletId}/products |
— | Catalogue including modifiers and bundles |
| 4 | Create order | Cata → You | POST |
{POS_BASE_URL}/v1/outlets/{outletId}/orders |
Cata order DTO, or your own format — §4.1 | Endpoint that accepts a paid order |
| 5 | Order status webhook | You → Cata | POST |
{CATA_WEBHOOK_URL} |
Status JSON | Outbound call when an order progresses |
The same five, split by who receives the call:
flowchart LR
ADP["Cata<br/>adapter"]
RCV["Cata<br/>webhook receiver"]
subgraph SERVE["4 APIs YOU PROVIDE — Cata calls these"]
direction TB
E1["1 · GET /v1/health"]
E2["2 · GET /v1/outlets"]
E3["3 · GET /v1/outlets/:outletId/products"]
E4["4 · POST /v1/outlets/:outletId/orders"]
end
subgraph OUTBOUND["1 API YOU CALL — Cata provides this"]
direction TB
E5["5 · POST to CATA_WEBHOOK_URL<br/>order status"]
end
ADP ==> E1
ADP ==> E2
ADP ==> E3
ADP ==> E4
E5 ==> RCV
classDef serve fill:#eef4ff,stroke:#3b6fd4,stroke-width:1px;
classDef outbound fill:#eefaf0,stroke:#2e8b57,stroke-width:1px;
class E1,E2,E3,E4 serve;
class E5 outbound;
Read the Direction column carefully. Rows 1–4 are endpoints you host and Cata
calls — all four live under your {POS_BASE_URL}. Row 5 is the opposite: Cata hosts it,
you call it. You are not building an endpoint for row 5 — you are building an outbound
HTTP call from your POS to a URL we give you.
So the work on your side is four endpoints, plus one outbound call — plus an OAuth token endpoint if you use OAuth (A.1).
Placeholders used in this document¶
Every URL below is written with one of these. The prefix tells you whose server it is.
| Placeholder | Whose | Meaning |
|---|---|---|
{POS_BASE_URL} |
Yours | The root of your POS API — e.g. https://api.your-pos.com, or https://{merchant}.your-pos.com if each merchant gets their own host. You tell us this value; Cata stores it per merchant. |
{CATA_WEBHOOK_URL} |
Ours | Where you POST order status updates. Cata gives you this value, one per merchant. It is the only Cata URL you call. |
{access_token} |
Yours | Issued by your auth endpoint (A.1), or a static API key |
{outletId} |
Yours | An outlet id from §2 |
If you see {POS_BASE_URL}, you are building it. If you see {CATA_WEBHOOK_URL}, we are.
Every capability section below opens with its request contract — method, path, headers, query params and body. Paths are the reference shape from Appendix A; mount them wherever suits your API, but keep the methods, parameters and payloads.
The gate — endpoints alone are not enough¶
Cata does not begin building the adapter for a capability until it has both:
- the endpoint, and
- a real sample payload for it.
Redact customer data as needed, but send genuine responses. Where vendor documentation and a real payload have disagreed in past integrations, the payload was right. Partial delivery is fine — each capability unblocks independently.
Authentication and connectivity¶
Authentication¶
Tell us your auth model and the exact header format. Cata stores your credentials encrypted server-side; they are never exposed to adapter code.
| Model | What we need |
|---|---|
| API key | Header name and exact value format |
| OAuth 2.0 | Token endpoint, grant type, scopes, token lifetime, refresh behaviour. If the token response also returns a per-merchant base URL, name that field — we support the pattern. |
| Other | Full description plus a working example request |
Do you have sandbox credentials? Building against production is possible but slower and riskier for your live merchants.
Connectivity¶
| Question | Why we ask |
|---|---|
| Base URL | Global, or per-merchant (e.g. https://{merchant}.example.com)? Both are supported. |
| Environments | Sandbox and production base URLs |
| Rate limits | Requests/minute, burst, and your 429 behaviour. A full catalogue sync can be many calls. |
| Pagination | Exact parameter names, and how we know we have reached the last page. |
| Error envelope | Non-2xx on failure, or HTTP 200 with an error body? Both are workable — we must know which, with an example of each. |
Data formats¶
| Field | Cata uses | Tell us |
|---|---|---|
| Prices | Decimal (e.g. 35.00) |
Integer minor units or decimal? The adapter converts — we need certainty, not a particular choice. |
| IDs | String | Integers and GUIDs are both fine. Confirm they are stable over time. |
| Timestamps | ISO-8601 / RFC 3339 | UTC or local? Format? Timezone source? |
| Currency | ISO 4217 | Fixed per outlet, or on the payload? |
1. Connection test¶
The cheapest authenticated read that proves a credential set is valid. Called when an operator saves credentials, and before any sync.
Request contract
| Method | GET |
| Path | {POS_BASE_URL}/v1/health (any read-only path you prefer) |
| Headers | Authorization: Bearer {access_token} |
| Query params | none |
| Request body | none |
| Expected status | 200 valid · 401/403 invalid credentials |
Full response schema: A.2.
- Must be read-only.
- Must return a distinguishable 401/403 for bad credentials. If an auth failure comes back as HTTP 200 with an error body, say so — we will handle it, but silently reporting a healthy connection is the failure mode to avoid.
- "Get merchant info" or "list outlets" is ideal. If your outlets endpoint is cheap, it can double as the connection test.
2. Get outlets¶
Every location the credentials can access. An operator maps each one to a Cata outlet.
Request contract
| Method | GET |
| Path | {POS_BASE_URL}/v1/outlets |
| Headers | Authorization: Bearer {access_token} |
| Query params | cursor (optional — from nextCursor), limit (optional) |
| Request body | none |
| Expected status | 200 |
Full response schema: A.3.
| Field | Required | Notes |
|---|---|---|
| Outlet id | Required | Stable identifier. Becomes the mapping key and is reused when Cata sends you orders — it must not change for the life of the store. |
| Name | Required | Shown in the operator's dropdown |
| Address | Optional | Secondary text, to disambiguate similar names |
Return all locations, paginating if needed. An empty list is valid, not an error.
3. Get products¶
The menu — and usually the largest piece of work, because Cata needs the full product structure, not just names and prices.
Request contract
| Method | GET |
| Path | {POS_BASE_URL}/v1/outlets/{outletId}/products |
| Path params | outletId — the id from §2 |
| Headers | Authorization: Bearer {access_token} |
| Query params | cursor (optional), limit (optional). Tell us if modifiers or bundles need an extra include-style param — see Pitfall 2. |
| Request body | none |
| Expected status | 200 |
Full response schema: A.4.
One endpoint or several is fine (products, modifier groups, options separately). Tell us which exist and how they join.
Cata's catalogue is flat. Cata imports a flat list of products — no category or menu tree. Menu structure, sections and ordering are built afterwards in Cata's Menu Manager. Send a category label per product if you have one (we keep it for reference), but do not model the hierarchy — it is discarded.
Per product¶
| Concept | Required | Notes |
|---|---|---|
| Item code / SKU | Required | Stable key. Cata sends it back on every order line. |
| Name | Required | Customer-facing |
| Base price | Required | Before modifiers |
| Description / short description | Optional | Product page copy |
| Image URL | Optional | Publicly reachable; Cata copies it into its own storage |
| Product kind | Optional | Simple item, variant, or bundle/combo |
| Category label | Optional | Retained for reference only |
Modifiers — and the selection rules¶
Cata models modifiers as groups (e.g. "Choice of milk") holding options (e.g. "Oat milk, +0.80"). Your response must express how many options a customer must and may choose.
| Concept | Required | Meaning |
|---|---|---|
| Group id | Required | Stable identifier |
| Group name | Required | Shown to the customer |
| Required vs optional | Required | Must the customer choose at least one? Cata stores a minimum-selection count (0 = optional). |
| Single vs multi-select | Required | May they choose more than one? Cata stores a maximum-selection count (1 = single). |
| Min / max selection | Required | The explicit numbers where you have them (e.g. "pick 2 to 4") |
| Repeat same option | Optional | May the same option be taken more than once? |
| Option id / name / price delta | Required | Option id is sent back to you on every order |
| Option is default | Optional | Pre-selected |
Pitfall 1 — selection rules must come from your API
In a previous POS integration the catalogue endpoint did not expose required/optional or min/max at all. Cata had to apply defaults, which produced wrong ordering rules for the merchant, and the integration was not correct until the vendor added those fields — months later.
Please confirm at the outset that your API returns them. If it does not, treat adding them as part of this project's scope, not a later refinement. It is the single most common cause of delay.
Variants¶
If a product has variants (sizes, styles) with their own SKU and price, tell us how they relate to the parent — typically a shared header or parent id. Cata imports each variant as its own product, so we must be able to name them unambiguously.
Bundles and combos — use this shape¶
Rather than ask how your POS models combos, here is the shape Cata wants. If you can express your bundles this way, the integration is straightforward.
A bundle is a sellable product whose contents are one or more sections. Each section is a slot in the combo — "Main", "Side", "Drink" — and carries:
| Per section | Meaning |
|---|---|
| Section id | Stable identifier for the slot |
| Section name | "Main", "Side", "Drink" — shown to the customer |
minSelection / maxSelection |
How many items the customer takes from this slot |
| Eligible items | The SKUs that may fill the slot, each with an optional price surcharge |
Fixed combos use the same shape. A combo with no customer choice is simply sections
where each has exactly one eligible item and minSelection = maxSelection = 1.
This is the important part. Do not model fixed bundles and choice bundles as two different things. One shape covers both, which means one code path in the adapter, one set of edge cases, and no ambiguity about which kind a given product is. Past integrations that used separate representations cost time in exactly this area.
{
"itemCode": "COMBO-LUNCH-01",
"name": "Lunch Combo",
"basePrice": 49.00,
"kind": "bundle",
"bundleSections": [
{
"itemCode": "SEC-MAIN",
"name": "Main",
"minSelection": 1,
"maxSelection": 1,
"items": [
{ "itemCode": "SKU-BURGER-01", "surcharge": 0.00 },
{ "itemCode": "SKU-WRAP-01", "surcharge": 3.00 }
]
},
{
"itemCode": "SEC-DRINK",
"name": "Drink",
"minSelection": 1,
"maxSelection": 1,
"items": [ { "itemCode": "SKU-COLA-01", "surcharge": 0.00 } ]
}
]
}
The second section above is a fixed component — one eligible item, exactly one taken — and needs no special handling.
If your POS cannot express this, tell us how it does model combos and we will map it. But send us this shape if you can: it is the one Cata's catalogue is built around, and mapping to it is the cheapest path.
What happens to a bundle at order time¶
By default, Cata decomposes a bundle before sending the order: the bundle SKU is dropped and its chosen components are sent as standard order lines, with the bundle's total redistributed across them. Most POS systems accept this without any bundle-specific support, which is why it is the default.
If your POS has a native combo line type and would rather receive the bundle intact, say so and include the request shape — we can send it that way instead. Be aware that a native combo path is stricter: in a past integration the POS rejected the bundle SKU on every order type, and the native path had to be abandoned in favour of decomposition.
Pitfall 2 — bundle contents that come back empty
In a previous integration, bundle contents were null unless a specific
include/expand parameter was passed. It is easy to miss, and it looks like "this
merchant has no combos" rather than an error.
If your API requires a parameter to return modifiers, bundle contents, or per-outlet prices, tell us the exact parameter.
4. Create order¶
When a customer pays in the Cata app, Cata calls your POS. This is the most important endpoint: an order that fails here does not reach the kitchen.
Request contract
| Method | POST |
| Path | {POS_BASE_URL}/v1/outlets/{outletId}/orders |
| Path params | outletId — the id from §2 |
| Headers | Authorization: Bearer {access_token} · Content-Type: application/json · (optional) an idempotency-key header if you support one |
| Request body | JSON. Which schema depends on your route — Cata's order DTO verbatim if you implement A.5, or your own request format if you have an existing order API. See §4.1. |
| Expected status | 200 or 201 accepted · 4xx or accepted:false rejected |
Full request/response schema: A.5.
4.1 The order Cata sends¶
This is Cata's order DTO. Which wire format actually reaches your endpoint depends on which route you take:
| Your choice | What your endpoint receives |
|---|---|
| Implement A.5 exactly (the reference API) | This DTO, verbatim. No mapping — the adapter forwards it. |
| Use your own existing order API | Your own request format. The adapter maps this DTO into it, so your endpoint never sees the shape below. |
Either way you need to read this table: in the first case it is your request schema, and in the second it is the source data we map from.
A field absent here cannot be sourced from the order payload — but it may still be populated from per-outlet configuration. Cata stores per-outlet values for exactly this purpose: payment type ids, dining-option ids, revenue centres, and anything else your create-order call needs that is a property of the outlet rather than the order. So if your request format requires a field you cannot see below, tell us which ones are configuration-derived and we will hold them per outlet — see §4.2.
{
"uuid": "ord-00000000-0000-0000-0000-000000000000",
"orderRefNo": "ORD-2026-001",
"dailyQueueNo": "A12",
"storeUuid": "00000000-0000-0000-0000-000000000000",
"deliveryMethod": "DELIVERY",
"status": "PAID",
"currency": "SGD",
"isPreorder": false,
"customer": {
"fullName": "Sample Customer",
"phone": "+6500000000",
"email": "customer@example.com",
"address": "88 Sample Road"
},
"items": [
{
"plu": "SKU-BURGER-01",
"name": "Classic Burger",
"quantity": 2,
"itemOnlyPrice": 35.00,
"modifierOnlyPrice": 5.00,
"itemPrice": 40.00,
"itemSubTotal": 80.00,
"notes": "No onions",
"modifiers": [
{
"modifierHeaderId": "MG-CHEESE",
"modifierOptionId": "MO-EXTRA",
"modifierHeaderName": "Cheese",
"modifierOptionName": "Extra Cheese",
"price": 5.00,
"quantity": 1
}
]
}
],
"subtotal": 80.00,
"discountTotal": 0.00,
"serviceCharge": 0.00,
"deliveryFee": 5.00,
"packagingFee": 0.00,
"totalPay": 85.00,
"payment": { "amount": 85.00, "tip": 0.00, "method": "CARD" },
"expectedPickupAt": "2026-09-04T10:30:00Z",
"tableNo": "",
"createdAt": "2026-09-04T10:00:00Z"
}
| Field | Type | Always sent | Meaning |
|---|---|---|---|
uuid |
string | yes | Cata's order id, and the idempotency key — see §4.3 |
orderRefNo |
string | yes | Human-readable reference, safe to print on a ticket |
dailyQueueNo |
string | no | Short per-day collection number |
storeUuid |
string | yes | Cata's outlet id. Your own outlet id is in the URL path. |
deliveryMethod |
enum | yes | PICKUP · DELIVERY · EATIN |
status |
string | yes | Always PAID — Cata does not dispatch unpaid orders |
currency |
string | yes | ISO 4217 |
isPreorder |
boolean | yes | true when scheduled for later; see expectedPickupAt |
customer |
object | no | Omitted entirely for anonymous orders — do not expect an empty object. See the customer table below. |
items[] |
array | yes | At least one line — see the item table below |
subtotal |
decimal | yes | Sum of itemSubTotal before order-level adjustments |
discountTotal |
decimal | yes | Total discount applied; 0.00 when none |
serviceCharge |
decimal | yes | 0.00 when none |
deliveryFee |
decimal | yes | 0.00 for non-delivery |
packagingFee |
decimal | yes | 0.00 when none |
totalPay |
decimal | yes | Final amount the customer paid — the authoritative total |
payment |
object | yes | Always present; Cata dispatches only paid orders. See the payment table below. |
expectedPickupAt |
ISO-8601 | no | Present for preorders and scheduled collection |
tableNo |
string | no | Set for EATIN where the merchant uses table numbers |
createdAt |
ISO-8601 | yes | When the order was placed |
customer — absent entirely for anonymous orders.
| Field | Type | Always sent | Meaning |
|---|---|---|---|
fullName |
string | yes when customer present |
Display name |
phone |
string | no | Some POS require this to create a customer record — tell us if so |
email |
string | no | |
address |
string | no | Present for DELIVERY |
items[] — one entry per order line.
| Field | Type | Always sent | Meaning |
|---|---|---|---|
plu |
string | yes | Your itemCode from §3 |
name |
string | yes | Product name as shown to the customer at order time. Informational — match on plu, not on this. |
quantity |
integer | yes | Always ≥ 1 |
itemOnlyPrice |
decimal | yes | Unit price excluding modifiers |
modifierOnlyPrice |
decimal | yes | Sum of modifier prices for one unit; 0.00 when none |
itemPrice |
decimal | yes | Per unit, including modifiers |
itemSubTotal |
decimal | yes | quantity × itemPrice |
notes |
string | no | Customer's note for the kitchen |
modifiers[] |
array | no | Omitted or [] when none — see the modifier table below |
items[].modifiers[] — one entry per selected option.
| Field | Type | Always sent | Meaning |
|---|---|---|---|
modifierHeaderId |
string | yes | Your modifier-group itemCode from §3 |
modifierOptionId |
string | yes | Your option itemCode. This is the match key. |
modifierHeaderName |
string | yes | Group name, informational |
modifierOptionName |
string | yes | Option name, informational |
price |
decimal | yes | Price for one unit of this option; 0.00 when free |
quantity |
integer | yes | Always ≥ 1. Above 1 only where the group allows repeats. |
payment — Cata orders are always already settled.
| Field | Type | Always sent | Meaning |
|---|---|---|---|
amount |
decimal | yes | Amount paid. Equals totalPay. |
method |
string | yes unless the order is free | How the customer paid, e.g. CARD, ONLINE. Map to your prepaid tender type — not to cash. Absent only on a free order (amount and totalPay both 0.00, e.g. a 100% voucher) — record no tender line for it. |
tip |
decimal | no | 0.00 or absent when none. If your POS has no tip field, tell us and we will drop it. |
Bundles: a chosen bundle arrives decomposed into its component lines by default — see what happens to a bundle at order time.
4.2 What we need from you¶
| We need | Why |
|---|---|
| Full request schema | Every field, which are required, accepted enum values |
| A sample request | A real create-order call that succeeds |
| A sample success response | See the critical requirement below |
| A sample error response | So we can tell a rejected order from an outage |
| Required per-outlet configuration | Anything beyond the order itself — location id, payment type, dining-option ids, revenue centre. Cata stores these per outlet. |
| How orders are paid | Cata orders are already paid. Tell us how to represent a settled, prepaid order so your POS does not ask staff to take payment again. |
Critical — return your order ID
Your success response must include the order identifier your POS assigned. Cata stores it and uses it to match your later status updates back to the right order.
Without a returned order ID, status updates cannot be matched and the integration cannot be completed.
4.3 Duplicate protection¶
Requirement: de-duplicate on uuid. A repeated create-order carrying a uuid you have
already accepted must not create a second ticket. Return the original orderId and the
same response you returned the first time, as if the call had just succeeded.
Why this is a requirement and not a nicety. A delivery can repeat — a network timeout
leaves the outcome ambiguous, and the safe response to an ambiguous outcome is to deliver
again. Cata-side de-duplication on order.uuid is in progress; treat your own as
required regardless, since it is what protects the merchant from a duplicate kitchen
ticket.
uuid is stable across every delivery of the same order, which is what makes it usable as
the key. Treat it as a unique constraint with an atomic insert-or-fetch — not a
check-then-insert, which races under exactly the concurrent retries it is meant to guard
against.
If you support an idempotency key header, name it and Cata will send uuid in it as well.
If your POS genuinely cannot enforce this, say so early — it is a material limitation and we will need to discuss how to contain it, not discover it in production.
5. Order status webhook¶
As the order moves through your kitchen, tell Cata. This drives the customer's live order tracking, so latency here is visible to the diner.
Cata provides the destination URL. Cata decides the URL — it is not configurable by you.
Request contract — note the direction reverses here: you make this call.
| Method | POST |
| URL | {CATA_WEBHOOK_URL} — provided by Cata, per tenant |
| Headers | Content-Type: application/json · X-Signature: sha256={hex} · X-Timestamp: {unix-seconds} |
| Request body | { orderId, status, reason, occurredAt } — JSON |
| Expected status | 200 accepted. Retry only on network errors, 408, 429 and 5xx, with bounded backoff. Treat other 4xx as terminal — see A.6. |
Full schema and signing rules: A.6.
What we need:
- Which events fire, and when.
- Your signature scheme — algorithm, exactly what is signed (raw body? body plus timestamp?), and which header carries it.
- A sample body per event.
- The status mapping table below, completed.
- Which events Cata must ignore — e.g. status changes on orders rung up in-store that did not come from Cata.
- Your retry policy on non-2xx.
Pitfall 3 — which field actually means 'the kitchen has progressed'?
In a previous integration the obvious top-level status field read "completed" from
the very first event — because the sale was settled up front, which is always true
for Cata orders. Mapping off it would have marked every order complete the moment it
was created. The real fulfilment progress lived in a different, less obvious field.
Tell us explicitly which field represents kitchen/fulfilment progress, and which fields are about payment or the sale record instead.
Status mapping — please complete¶
| Cata status | Meaning | Your equivalent |
|---|---|---|
ACCEPTED |
POS accepted the order | |
IN PROGRESS |
Kitchen has started | |
READY |
Ready for collection / handover | |
DRIVER PICKED UP |
Courier collected | |
COMPLETED |
Fulfilled | |
CANCELLED |
Cancelled — reason required |
Note the literal spaces in IN PROGRESS and DRIVER PICKED UP. Cata enforces a state
machine — transitions that move backwards are rejected.
Optional capabilities¶
Not required for a working integration. Tell us yes/no and we will scope separately.
| Capability | If yes, we need |
|---|---|
| Item / modifier snooze | Endpoint to mark an item unavailable at one outlet, and how it is restored |
| Per-outlet pricing | Whether prices differ by outlet, and how they are returned |
| Stock levels | Endpoint, and whether counts are per-outlet |
| Tags / allergens | Where they live on the product |
| In-store order sync | Whether till-rung orders should also appear in Cata |
Cata-side rules worth knowing¶
| Rule | What it means for you |
|---|---|
| Your POS owns product structure | Modifier groups, selection rules and bundle composition come from your catalogue and are trusted as-is. Cata will not override them. Operators edit display fields only. |
| Cata's menu structure is Cata's | Categories, sections and ordering are built in Cata's Menu Manager, not imported from your hierarchy. |
| Cancel and refund are Cata-owned | Customer-side cancellation happens in Cata's back office. You can report a kitchen-side cancellation with a reason. |
| Orders arrive pre-paid | Every order Cata sends is already settled. |
What to send back¶
Items 1–3 let us scope; item 4 lets us build.
- API documentation URL, and whether it is public or behind a login.
- Auth details and sandbox credentials.
- Answers to the connectivity and data-format questions.
- Per capability: endpoint + a real sample payload.
- The completed status mapping table.
- Explicit answers to the three pitfalls: do you return modifier selection rules? does anything need an include parameter? which field is fulfilment progress?
- Whether you can implement the reference API in Appendix A as-is, or which parts differ.
Appendix A — Reference API¶
If you can implement this exactly, do. These are the precise endpoints and payloads Cata's adapter expects. An exact implementation needs almost no mapping, which is the fastest and least error-prone path to a working integration.
If you cannot, that is fine — this is a target, not a mandate. Send us your own shapes per the sections above and we will map them. But diverge deliberately, not by accident: the closer you are to this, the shorter the build.
All examples use placeholder values. Prices are decimal. All ids are strings.
A.1 Authentication¶
Preferred: OAuth 2.0 client credentials, or a bearer token.
Authorization: Bearer {access_token}
An API key header (X-API-Key: {key}) is equally acceptable. Tell us which, and the exact
header name.
If you use OAuth, the token endpoint is yours to host too — it is a fifth endpoint on your side, in addition to the four in the table above:
POST /oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials&client_id={id}&client_secret={secret}
{
"access_token": "…",
"token_type": "Bearer",
"expires_in": 3600
}
If your token response also returns a per-merchant base URL (some POS platforms do, so that each merchant's API lives on its own host), name that field — we support the pattern and will use it in preference to a configured base URL.
With a plain API key there is no token endpoint: you provide the key out of band and Cata sends it on every request.
A.2 Connection test¶
GET {POS_BASE_URL}/v1/health
Authorization: Bearer {access_token}
200 OK
{ "ok": true, "merchant": "Example Merchant" }
Return 401 or 403 for invalid credentials. The body is not important; the status code is.
A.3 Outlets¶
GET {POS_BASE_URL}/v1/outlets
Authorization: Bearer {access_token}
200 OK
{
"outlets": [
{
"id": "OUTLET-001",
"name": "Example Store — Central",
"address": "1 Example Street, #01-01",
"timezone": "Asia/Singapore",
"active": true
}
],
"nextCursor": null
}
| Field | Type | Required | Notes |
|---|---|---|---|
id |
string | yes | Stable for the life of the store. Reused as the path parameter below. |
name |
string | yes | |
address |
string | no | |
timezone |
string | no | IANA name, if you have it |
active |
boolean | no | Omit or true if you do not track this |
nextCursor |
string | null | yes | null on the last page; otherwise pass back as ?cursor= |
Pagination — worked example¶
The same rules apply to products. Cata keeps calling while nextCursor is
non-null, passing the previous response's value back verbatim.
Call 1 — no cursor:
GET {POS_BASE_URL}/v1/outlets
Authorization: Bearer {access_token}
{
"outlets": [ "…200 outlets…" ],
"nextCursor": "b2Zmc2V0OjIwMA"
}
nextCursor is non-null, so there is more. Cata calls again with it.
Call 2 — cursor echoed back:
GET {POS_BASE_URL}/v1/outlets?cursor=b2Zmc2V0OjIwMA
Authorization: Bearer {access_token}
{
"outlets": [ "…47 outlets…" ],
"nextCursor": null
}
nextCursor is null. Cata stops. Total collected: 247.
Single-page case — if everything fits in one response, return "nextCursor": null on
the first call. Cata makes exactly one request.
Rules¶
- The cursor is opaque to Cata. We never parse it — we echo it back exactly as given. Encode an offset, a row id, a page number, a timestamp, whatever suits your storage.
nullends the loop. Send JSONnull, not"", not the string"null", and do not omit the field. An absent or empty-string cursor is ambiguous and we will treat it as an error rather than guess.- Never return a non-null
nextCursorfor an empty page. That is the shape that causes an infinite loop. If there is no more data, the cursor isnull. - A stable ordering matters. If rows can shift between calls, items can be skipped or duplicated across pages. Order by something immutable — a created-at plus id, for instance.
- Tell us your page size, and whether
limitis honoured or ignored.
If your API paginates differently¶
Offset/limit, page/page_size, or a next URL are all fine — this is a reference shape,
not a requirement. Tell us which you use and how the last page is signalled, and the
adapter will follow it. What we cannot work with is pagination that is undocumented or has
no reliable end condition.
A.4 Products¶
GET {POS_BASE_URL}/v1/outlets/{outletId}/products?cursor={cursor}&limit=200
Authorization: Bearer {access_token}
200 OK
{
"products": [
{
"itemCode": "SKU-BURGER-01",
"name": "Classic Burger",
"description": "Beef patty, lettuce, tomato",
"basePrice": 35.00,
"kind": "standard",
"category": "Mains",
"imageUrl": "https://cdn.example.com/burger.jpg",
"available": true,
"modifierGroups": [
{
"itemCode": "MG-CHEESE",
"name": "Cheese",
"minSelect": 0,
"maxSelect": 2,
"allowMultipleQty": false,
"options": [
{ "itemCode": "MO-EXTRA", "name": "Extra Cheese", "additionalPrice": 5.00, "isDefault": false },
{ "itemCode": "MO-NONE", "name": "No Cheese", "additionalPrice": 0.00, "isDefault": false }
]
}
],
"bundleSections": []
}
],
"nextCursor": null
}
| Field | Type | Required | Notes |
|---|---|---|---|
itemCode |
string | yes | Your SKU. Sent back on every order line. |
name |
string | yes | |
basePrice |
decimal | yes | Before modifiers |
kind |
enum | yes | standard · variant · bundle |
parentItemCode |
string | if variant |
The parent this variant belongs to |
description |
string | no | |
category |
string | no | Retained for reference; Cata builds its own menu structure |
imageUrl |
string | no | Publicly reachable |
available |
boolean | no | Omit or true if not tracked |
modifierGroups |
array | yes | [] when the product has none — do not send null |
bundleSections |
array | yes when kind is bundle |
[] otherwise |
nextCursor |
string | null | yes | null on the last page — see the worked example |
Modifier group — minSelect 0 = optional, 1+ = required. maxSelect 1 = single-select,
N = multi-select. Both are required; see Pitfall 1.
Bundle section — as specified in Bundles and combos:
itemCode, name, minSelection, maxSelection, and items[] of
{ itemCode, surcharge }.
Return the whole catalogue in one paginated pass. Modifiers, options and bundle sections must be present in this response — not behind a second call per product, and not gated behind an
includeparameter we have to know about. If they are, say so explicitly (Pitfall 2).
A.5 Create order¶
POST {POS_BASE_URL}/v1/outlets/{outletId}/orders
Authorization: Bearer {access_token}
Content-Type: application/json
The body is Cata's order DTO, forwarded verbatim — the full schema and field tables are in §4.1.
This is the defining difference between implementing the reference API and using your own: here your endpoint accepts Cata's shape directly and the adapter does no order mapping at all. If you use your own order API instead, the adapter maps the DTO into your format and this section does not apply to you — see the table in §4.1.
200 OK / 201 Created
{
"orderId": "POS-987654",
"accepted": true,
"message": null,
"estimatedReadyAt": "2026-09-04T10:25:00Z"
}
| Field | Type | Required | Notes |
|---|---|---|---|
orderId |
string | yes | Your order id. Cata stores it and matches your status updates against it. Without this the integration cannot be completed. |
accepted |
boolean | yes | false = you rejected the order |
message |
string | null | no | Reason when accepted is false |
estimatedReadyAt |
ISO-8601 | no | Surfaced to the customer if present |
Rejection — return the same body with "accepted": false and a message, or a 4xx with
an error body. Either works; tell us which you use.
If you support an idempotency key, name the header and Cata will send the order's uuid
in it.
A.6 Order status webhook¶
You send this to a URL Cata provides, whenever an order's fulfilment state changes.
POST {CATA_WEBHOOK_URL}
Content-Type: application/json
X-Signature: sha256={hex}
X-Timestamp: {unix-seconds}
Signature. HMAC-SHA256, hex-encoded, over the string {X-Timestamp}.{raw body} —
timestamp, a literal full stop, then the raw bytes you are sending. The key is a shared
secret Cata provides per merchant.
The timestamp must be inside the signed message. Signing only the body leaves a valid
request replayable forever by anyone who captures it. Cata rejects any request whose
X-Timestamp is more than 5 minutes from our clock, which bounds that window — but
that check is only meaningful because the timestamp is signed and therefore cannot be
rewritten.
Use Unix seconds for X-Timestamp, and keep your clock synchronised (NTP); a POS whose
clock has drifted past the window will have every delivery rejected.
If your platform already has a signing scheme, describe it and we will verify against it instead — provided it binds a timestamp or nonce. A body-only signature with no freshness bound is not something we can accept for order state changes.
{
"orderId": "POS-987654",
"status": "IN PROGRESS",
"reason": null,
"occurredAt": "2026-09-04T10:12:00Z"
}
| Field | Type | Required | Notes |
|---|---|---|---|
orderId |
string | yes | The id you returned from A.5 |
status |
enum | yes | Exactly one of the six Cata statuses — note the literal spaces |
reason |
string | null | when CANCELLED |
Required on cancellation |
occurredAt |
ISO-8601 | no | Defaults to receipt time if absent |
Send only fulfilment transitions. Do not send payment or sale-record events, and do not send events for orders that did not originate from Cata — see Pitfall 3.
Response handling — retry the transient, stop on the permanent.
| Cata returns | Meaning | You should |
|---|---|---|
200 |
Accepted | Done |
| network error, timeout | Cata unreachable | Retry with bounded exponential backoff |
408, 429 |
Timeout / rate-limited | Retry with backoff; honour Retry-After when present |
5xx |
Fault on Cata's side | Retry with backoff |
401, 403 |
Signature or credential rejected | Terminal — retrying cannot fix it. Alert your team. |
400, 422 |
Malformed payload, or a status transition Cata rejects (e.g. moving backwards) | Terminal — do not retry. Log it and move on. |
404 |
Unknown orderId |
Terminal — the order did not come from Cata, or was never dispatched |
Retrying a terminal 4xx indefinitely just grows your queue and our logs without ever
succeeding. Cap total attempts and elapsed time on the retryable cases too — we suggest
roughly 6 attempts over about 15 minutes, then give up and alert. Tell us the policy you
settle on.
A.7 Summary¶
| # | Method | Path | Purpose |
|---|---|---|---|
| 1 | GET |
{POS_BASE_URL}/v1/health |
Connection test |
| 2 | GET |
{POS_BASE_URL}/v1/outlets |
List outlets |
| 3 | GET |
{POS_BASE_URL}/v1/outlets/{outletId}/products |
Catalogue, paginated |
| 4 | POST |
{POS_BASE_URL}/v1/outlets/{outletId}/orders |
Create order — receives Cata's DTO verbatim on this route |
| 5 | POST |
{CATA_WEBHOOK_URL} |
Status webhook — you call us |
Rows 1–4 sit under your POS_BASE_URL. Row 5 is Cata's URL, which we give you.
The /v1/... paths are a suggestion, not a requirement — mount them wherever suits your
API. What matters is the methods, the parameters and the payload shapes.