Menu sync
Quiqqy never guesses your catalogue: you push it, and what you push is what customers can order. The POS is the source of truth; Quiqqy mirrors it.
Menu sync requires an active integration with menu_sync_direction of
pos_to_quiqqy or bidirectional — anything else rejects pushes with
menu.push_not_allowed.
The menu model
Section titled “The menu model”A push is a hierarchy keyed entirely on your identifiers:
menus[] named menus (e.g. "Main", "Breakfast")└── categories[] sections within a menu └── items[] sellable products ├── variants[] SKUs of an item (Small / Large) └── addon_groups[] modifier groups attached to the item └── add_ons[] the selectable modifiersEvery object carries an external_id — your POS’s stable id for it. This is
the idempotency key for sync (pushing the same external_id updates rather
than duplicates) and the id that comes back to you on order webhooks as
items[].external_id, items[].variant_external_id and
options[].external_id. Choose ids that never change for the life of the
record; never reuse an id for a different product.
| Object | Required fields | Notable optional fields |
|---|---|---|
| menu | name | external_id, description |
| category | name | external_id, description, image_url, sort_order, tax_exempt, tax |
| item | external_id, name, price | description, image_url, available, categories[], addon_groups[], product_type, sort_order, tax_exempt, tax, variant_type, variants[] |
| variant | external_id, price | variant_name, variant_key, stock, image_url, available, consumption_ratio, combinations[] |
| addon_group | external_id, name, add_ons[] | description, type, min_select, max_select, required, defaults[] |
| add_on | external_id, name, price | child_addon_groups[], item_external_id (nothing else: item fields such as product_type are rejected) |
Prices are integers in the currency’s minor unit (1299 = $12.99).
Items versus add-ons. items[] holds only directly-sellable products. A
modifier that can only be ordered as part of an item (a topping, a sauce)
belongs in add_ons[], wired to items through addon_groups[] — it never
appears standalone in the storefront. A group’s add_ons[] resolves only ids
declared in the same menu’s add_ons[]. An items[] entry with
product_type: "addon" is hidden from the storefront too, but listing its id
in a group does not make it a choice: declare an add-on for it and set that
add-on’s item_external_id to the item’s external_id.
Variants. An item sold in variations carries variant_type —
stand_alone (each variant has its own price and stock) or
consumption_ratio (variants draw from the parent’s stock at a ratio) — plus
variants[]. A push replaces the item’s variant set exactly: a variant you
stop sending is deactivated.
Combos and nested add-ons
Section titled “Combos and nested add-ons”Four optional fields describe combo meals and choices that open further choices:
addon_groups[].type:"combo"for a combo or meal-slot group,"modifier"(the default) for a plain modifier group.addon_groups[].defaults[]:external_ids, from the group’sadd_ons[], of the choices pre-selected on the storefront.add_ons[].child_addon_groups[]:external_ids of the menu’saddon_groups[]revealed when this add-on is chosen.add_ons[].item_external_id:external_idof the item this choice is; the choice uses that product (created as a hidden add-on if it isn’t pushed yet, linked as-is if it exists). Orders still return the add-on’s ownexternal_id.
A burger meal whose main is the menu’s own burger, which opens its extras:
{ "menus": [ { "name": "Main Menu", "items": [ { "external_id": "SKU-MEAL", "name": "Burger Meal", "price": 1299, "addon_groups": ["grp-meal-main"] }, { "external_id": "SKU-BURGER", "name": "Burger", "price": 899 } ], "add_ons": [ { "external_id": "ADD-MEAL-BURGER", "name": "Burger", "price": 0, "item_external_id": "SKU-BURGER", "child_addon_groups": ["grp-burger-extras"] }, { "external_id": "ADD-CHEESE", "name": "Cheese", "price": 100 } ], "addon_groups": [ { "external_id": "grp-meal-main", "name": "Choose your main", "type": "combo", "min_select": 1, "max_select": 1, "required": true, "add_ons": ["ADD-MEAL-BURGER"], "defaults": ["ADD-MEAL-BURGER"] }, { "external_id": "grp-burger-extras", "name": "Burger extras", "add_ons": ["ADD-CHEESE"] } ] } ]}A type other than combo or modifier, an empty item_external_id, or any
add-on or group field not listed in the table above fails the push with
400.
Category order
Section titled “Category order”A category’s optional sort_order — a whole number from 0 to 2147483647 —
sets where it appears in the store’s menu:
- Categories numbered
1,2,3… are shown first, lowest number first. 0means unordered: those categories come after every numbered one.- Ties — two categories with the same number, or all the unordered ones — are sorted by name.
Send sort_order and it overwrites the stored value, like name and
description. Leave it out and the stored value is kept, so an order the
merchant arranged in Quiqqy survives your pushes; new categories start at 0.
If your POS has no notion of category order, simply don’t send the field.
A push is never rejected for duplicate numbers. Only a value that is not a
whole number in range — a negative, a decimal, or a string such as "3" —
fails with 400. The field is accepted wherever you send
categories: both full-push forms and categories[] on the
batch endpoint.
Item order
Section titled “Item order”An item’s optional sort_order — a whole number from 0 to 2147483647 —
sets its position within the menu you push it in:
- Items numbered
1,2,3… are shown first, lowest number first. 0means unordered: those items come after every numbered one, in the order they are shown today.
The position belongs to the item in that menu. Send it on the item inside
each menus[].items[] (or on the flat form’s items[]); an item you push in
two menus can hold a different position in each.
Send sort_order and it overwrites the stored position. Leave it out and the
stored position is kept, so an order the merchant arranged in Quiqqy survives
your pushes; an item new to a menu starts at 0. If your POS has no notion of
item order, simply don’t send the field.
As with categories, only a value that is not a whole number in range — a
negative, a decimal, or a string such as "3" — fails with
400. Duplicate numbers are not rejected, but give each item
its own number if you need a fixed order between them.
Item positions are set only by a full menu push.
add[]/update[] on the batch endpoint and
PATCH /pos/stores/:storeId/items/:extItemId accept the field (they share the
item shape) but ignore it — neither names a single menu.
Full menu push
Section titled “Full menu push”POST /pos/stores/:storeId/menus replaces the store’s synced menu with what
you send. Push the whole catalogue — on first sync, whenever the POS menu
changes, and whenever Quiqqy asks for it.
curl -s https://auth.quiqqy.ai/api/v2/pos/stores/st_8fk2c1p9/menus \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H 'Content-Type: application/json' \ -d @menu.jsonconst response = await fetch( 'https://auth.quiqqy.ai/api/v2/pos/stores/st_8fk2c1p9/menus', { method: 'POST', headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', }, body: JSON.stringify(menu), // see the sample below },);
if (response.status === 202) { const { poll_url } = await response.json(); await pollMenuJob(poll_url);}import requests
response = requests.post( "https://auth.quiqqy.ai/api/v2/pos/stores/st_8fk2c1p9/menus", headers={"Authorization": f"Bearer {access_token}"}, json=menu, # see the sample below)
if response.status_code == 202: poll_menu_job(response.json()["poll_url"])A compact body (the full realistic version is in Sample payloads):
{ "menus": [ { "name": "Main Menu", "external_id": "menu-main", "categories": [{ "external_id": "cat-pizza", "name": "Pizza", "sort_order": 1 }], "add_ons": [{ "external_id": "ADD-BASIL", "name": "Extra basil", "price": 150 }], "addon_groups": [ { "external_id": "grp-toppings", "name": "Toppings", "min_select": 0, "max_select": 3, "add_ons": ["ADD-BASIL"] } ], "items": [ { "external_id": "SKU-MARG", "name": "Margherita Pizza", "price": 1299, "sort_order": 1, "categories": ["cat-pizza"], "addon_groups": ["grp-toppings"], "variant_type": "stand_alone", "variants": [ { "external_id": "SKU-MARG-SM", "variant_name": "Small", "price": 1299 }, { "external_id": "SKU-MARG-LG", "variant_name": "Large", "price": 1699 } ] } ] } ]}Each menu carries its own add_ons[] and addon_groups[]. A flat form also
exists — top-level menu_name + items[]/categories[]/add_ons[]/
addon_groups[] without the menus[] wrapper — for single-menu stores; the
top-level lists are read only in that form, so a body with menus[] ignores
them. Prefer menus[].
200 sync, 202 async
Section titled “200 sync, 202 async”Small pushes apply synchronously:
{ "status": "success", "message": "Menu pushed to Quiqqy" }Pushes above ~100 items are processed as a background job:
{ "status": "accepted", "job_id": "job_5tq8w2ma", "poll_url": "/api/v2/pos/stores/st_8fk2c1p9/menus/jobs/job_5tq8w2ma"}Poll GET /pos/stores/:storeId/menus/jobs/:jobId until it settles:
{ "job_id": "job_5tq8w2ma", "status": "completed", "items_processed": 412, "errors": []}status is queued, processing, completed or failed. Poll with backoff
(a few seconds is plenty); handle both branches from one code path by
treating 200 as an already-completed job. errors[] names offending
external_ids — fix and re-push; a partial failure never half-applies an
item.
Batch updates
Section titled “Batch updates”For deltas between full pushes, POST /pos/stores/:storeId/menus/batch
applies adds, updates and deletes in one call:
{ "add": [{ "external_id": "SKU-TIRAMISU", "name": "Tiramisu", "price": 750 }], "update": [{ "external_id": "SKU-MARG", "price": 1399 }], "delete": ["SKU-CALZONE"]}A single item can also be updated in place with
PATCH /pos/stores/:storeId/items/:extItemId — send only the fields that
change. For stock flips specifically, use the dedicated
availability endpoints.
When Quiqqy asks: menu_push
Section titled “When Quiqqy asks: menu_push”Quiqqy sometimes needs your menu — after an integration is repaired, when a
merchant or Quiqqy support triggers a re-sync from the console. It asks over
your webhook with an action_required event:
{ "event_type": "action_required", "data": { "action": "menu_push", "integration_id": "int_3a2c8d1e" }}Acknowledge with 2xx, then perform a full menu push. data.action may also
be resync — treat it identically (data.request_type is a legacy alias of
data.action). Payload details are in
Webhook events.
Checking sync state
Section titled “Checking sync state”GET /pos/stores/:storeId/menus/status returns the direction, integration
status and last successful sync:
{ "store_id": "st_8fk2c1p9", "menu_sync_direction": "pos_to_quiqqy", "integration_status": "active", "last_synced_at": "2026-07-30T09:12:44.000Z"}