Connecting an external system (API)


What it is and what for

An external system — a cash register, an accounting or hotel program — can pull the menu, read the venue's orders and bills, and hand us an order it took itself. There is a separate API for that: it acts on behalf of the venue and needs no staff account.

The address is the Qreaty app's address plus /api/external/v1. In production that is https://qreaty.com/api/external/v1; the ready address is shown in the "API access" section (how to open it) — copy it from there. Every request below is relative to it: GET /menu/items means GET https://qreaty.com/api/external/v1/menu/items.

Responses are JSON in the JSON:API shape (data, attributes), request bodies are JSON. The token goes in the header Authorization: Bearer <token>; Accept: application/json is always advisable.

Language. Names and descriptions come in the language you ask for: the ?locale=vi parameter (a language code: ru, en, vi…) or the Accept-Language: vi header. Which languages exist, the API tells you itself: every GET /menu/items and GET /menu/modifiers response carries meta.menus — for each menu, the list locales (the languages it is translated into) and source_locale, the language of the original, the one the owner typed the menu in. If there is no translation into the requested language (or none is requested), the original comes back.

{primary} A token belongs to the venue, not to a person: if the employee who issued it leaves, the token keeps working until you revoke it. Each token sees only its own venue.

What the API does:

What Request Token ability needed
All dishes GET /menu/items menu:read
Modifier groups and options GET /menu/modifiers menu:read
Tables (id, number, name, zone) GET /tables orders:read
Orders GET /orders, GET /orders/{id} orders:read
Bills GET /bills, GET /bills/{id} orders:read
Accept an order POST /orders orders:write

Access token

An owner, or anyone who may change the venue's settings, issues the token.

  1. Open Settings → POS system and find the "API access" section.
  2. Press "Issue token", name it after the system ("Cash register, front desk") and tick what it may do: read the menu, read orders and bills, create orders. Give exactly what is needed: a system that only reconciles prices should not be able to create orders.
  3. Optionally set an expiry date.
  4. Copy the token right away. It is shown once; afterwards the list shows only the name, abilities and the time of last use.

{warning} A token is like a password: anyone holding it acts on behalf of the venue within the abilities given. Do not paste it into chats or keep it in a repository. If it may have leaked, revoke it in the same section and issue a new one — a revoked token stops working at once.

One token per connected system. That way you can see who called and when, and revoking one system does not break the others.

SKU: the code of a dish and an option

An external system names positions by SKU — the code it has in its own catalogue. Every dish and every modifier option has a SKU; it is never empty:

  • A new position is given the code QR-D<number> (dish) or QR-M<number> (option) — unique and unchanging.
  • To match the partner's catalogue, enter their code: open the dish (or the option inside a modifier) and change the "SKU" field. Letters, digits and the characters . _ - / are allowed.
  • Within one menu a SKU does not repeat — the app will not save a duplicate. The same dish in two menus of a venue may share a code.
  • A dish copy gets its own new code, while a whole-menu copy keeps the codes as they are.

An order remembers the SKU together with the name: if you change a dish's code tomorrow, yesterday's bills keep the code the dish was sold under.

{primary} If two dishes of one menu share a code (it happens with old data that was linked by hand), the API says so: GET /menu/items and /menu/modifiers carry meta.duplicate_skus. Until the duplicate is fixed that SKU cannot be ordered — the request gets an error.

Menu: dishes and modifiers

GET /menu/items returns the dishes of the active menu — the one guests and the till order from — 50 per page (?per_page= up to 200, ?page=). Another menu can be asked for explicitly: filter[menu]=<id>; menu ids and their languages are in meta.menus.

A dish's key is its SKU: it is also the id, and it is what you send back in an order. A dish has: sku, name, description, price_type (fixed, from, range, variable — on request) with price_value / price_min / price_max / price_unit, currency, kind (food, drink, alcohol, other), section, available — can it be ordered right now (shown, not on the stop list, its section on), sold_out — whether it is on the stop list, and modifier_group_ids — the modifier groups attached to it.

Filters: filter[available]=1, filter[section], filter[sku], filter[menu], filter[updated_since]=2026-10-14T00:00:00Z — handy for regular reconciliation: you fetch only what changed.

GET /menu/modifiers — the active menu's modifier groups with their options. An option is named by its own SKU (options[].sku) and carries name, price, is_default, max_qty and prices — the size matrix (driver_option_sku is the size option, price the price with it). A group has name, pricing_mode (replace — the option's price replaces the dish price, like a size; add — it is added, like a topping), required, selection_min / selection_max — how many options may be chosen, item_skus — the dishes it is attached to, and parent_option_sku — when the group opens under an option of another group. A group's id is its reference in a dish's modifier_group_ids.

curl -s -g "https://qreaty.com/api/external/v1/menu/items?filter[available]=1&locale=vi" \
  -H "Authorization: Bearer $TOKEN" -H "Accept-Language: en"

Ready-made requests: menu

{primary} All the requests below are collected in a ready Postman collection: download. Import the file, put your token into the collection variable token — and you can send; SKUs and the table number fill in by themselves.

A filtered address is built from parts: path?filter[name]=value&filter[name2]=value2. Below are ready strings to use as they are. In curl add -g so the square brackets are not treated as a glob; in Postman type them as shown. A + in a time is written %2B (+07:00 → %2B07:00), otherwise it turns into a space.

What you need Request
All dishes in Vietnamese, 200 per page GET /menu/items?locale=vi&per_page=200
Only dishes that can be ordered now GET /menu/items?filter[available]=1
A dish by its SKU GET /menu/items?filter[sku]=PHO-1
Dishes of one menu (the menu id is in meta.menus) GET /menu/items?filter[menu]=12
What changed in the menu since a moment GET /menu/items?filter[updated_since]=2026-10-14T00:00:00Z
Modifier groups of one menu GET /menu/modifiers?filter[menu]=12

Orders and bills

GET /orders — orders (rounds of a bill), newest first. Each comes with its lines, chosen options, status, table, number and times. A line names the dish by its SKU (lines[].sku), and nested options by lines[].modifiers[].sku.

Filter What it does
filter[status] pending, confirmed, completed, rejected, cancelled; several — comma-separated
filter[source] guest (guest via QR), staff (waiter), external (accepted through the API)
filter[table] table number
filter[bill] bill id
filter[external_id] the order id in your system
filter[placed_from], filter[placed_to] a time interval of placement (from — inclusive, to — exclusive)
filter[updated_since] everything that changed since the given moment

GET /bills — table bills: amount, tax, payment method (tender), room number, when opened and closed, and a list of order ids. With ?embed=orders the orders arrive whole. Filters: filter[status]=open|closed, filter[tender] (cash, transfer, card, ewallet, room_charge, voucher, none — no payment recorded), filter[table], filter[number], filter[room_number], filter[opened_from|opened_to], filter[closed_from|closed_to], filter[updated_since]. Sort: ?sort=-closed_at.

Times in requests are ISO 8601 (2026-10-14T09:00:00+07:00); responses use it too.

{primary} To lose no changes, keep the moment of your last successful fetch and ask for filter[updated_since] with a little margin back next time — repeated records are recognised by id anyway.

Ready-made requests: orders and bills

The same rules (-g in curl, %2B for +). Times are in Vietnam time (+07:00).

What you need Request
Orders waiting for confirmation GET /orders?filter[status]=pending
Confirmed and completed GET /orders?filter[status]=confirmed,completed
Orders of 14 October GET /orders?filter[placed_from]=2026-10-14T00:00:00%2B07:00&filter[placed_to]=2026-10-15T00:00:00%2B07:00
Only those that came through the API GET /orders?filter[source]=external
Confirmed orders of table 5 GET /orders?filter[table]=6512&filter[status]=confirmed
Everything that changed since the last reconciliation, oldest first GET /orders?filter[updated_since]=2026-10-14T09:00:00Z&sort=updated_at
Our order by the number in your system GET /orders?filter[external_id]=POS-100234
Open bills GET /bills?filter[status]=open
Bills closed on 14 October, with orders and lines inside GET /bills?filter[status]=closed&filter[closed_from]=2026-10-14T00:00:00%2B07:00&filter[closed_to]=2026-10-15T00:00:00%2B07:00&embed=orders
Bills charged to a hotel room GET /bills?filter[status]=closed&filter[tender]=room_charge
The bills of one room GET /bills?filter[room_number]=412
Closed bills with no recorded payment GET /bills?filter[status]=closed&filter[tender]=none
The next page add &page=2 to any of the requests

Receiving an order from an external system

POST /orders — the external system hands over an order it took from a guest. It enters the venue as a normal confirmed order: it goes to the kitchen and to printing, deducts stock, lands in the table's bill and in analytics. The price is calculated by us from the menu — you do not need to send prices.

{
  "external_id": "POS-100234",
  "table": 6512,
  "room_number": "412",
  "note": "No cilantro",
  "items": [
    {
      "sku": "PHO-1",
      "quantity": 2,
      "note": "Spicy on the side",
      "modifiers": [
        { "sku": "SIZE-L" },
        { "sku": "EGG", "quantity": 1 }
      ]
    }
  ]
}
Field Meaning
external_id The order number in your system. Required: a repeated request is recognised by it
table The table's id exactly as GET /tables returned it (a number like 6512; the number on the table's sign is its number field, needed only to match your tables with ours)
room_number Room number, to put the order on the hotel room
served true — the order is already served: recorded as completed, does not go to the kitchen
items[].sku, quantity The dish and quantity (1–99)
items[].modifiers[] Chosen options by SKU; nested ones go in their own modifiers

A 201 response carries the created order — with our id, number and prices.

A retry is safe. If the connection dropped and you send the same external_id with the same content, you get 200 and the order already created — no second one appears. The same external_id with different content is the IDEMPOTENCY_KEY_REUSED error: either a bug, or a new order under an old number.

An order is accepted only for dishes of the active menu that can be ordered right now (not hidden, not on the stop list).

Trying it in Postman

The ready Postman collection holds every request of this page with switchable filters and tests. Download: qreaty-external-api.postman_collection.json.

  1. In Postman press Import (top left) and drop the downloaded file into the window — or paste the file link (https://qreaty.com/downloads/qreaty-external-api.postman_collection.json) and press Enter. The collection "Qreaty — External API" appears.
  2. Open the collection and its Variables tab. Put the token from the "API access" section into token (how to issue it). base_url already points to https://qreaty.com/api/external/v1; change it for another environment (no trailing slash). Press Save.
  3. Send Menu → Menu items, Menu modifiers and Tables → List tables: the tests store sku, option_sku and the table number into working collection variables by themselves (they show up in the list after the first run).
  4. Send Orders → Place order. A 201 response means the order was created; sending it again with the same external_id (create the collection variable keep_external_id with any value) returns 200 and the same order.
  5. Filters in "List orders" and "List bills" are switched off by their checkboxes — turn on the ones you need and put in your values.

{primary} Authorization is a Bearer token for the whole collection, so you do not set it in individual requests. If responses are 401, check the token was pasted without spaces and is not revoked; 403 means the token lacks the ability for that request.

Errors and limits

Code When What to do
401 no token, it was revoked, expired, or it is not a venue token issue a new token
403 the token lacks the ability for this request grant the ability or issue another token
404 no order or bill with this id in this venue check the id
422 invalid body: unknown SKU or table, the dish is unavailable now, a required option is not chosen read errors — each error names its path, for example items.0.sku
422 + IDEMPOTENCY_KEY_REUSED external_id was already used with different content change the number or resend the original content
429 too often retry after Retry-After seconds

The limit is 120 requests per minute per token. For regular menu reconciliation that is plenty: use filter[updated_since] and pages of 200.

A machine-readable specification (OpenAPI) for the integrator's developer can be requested from support.