Skip to content

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.

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 modifiers

Every 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.

ObjectRequired fieldsNotable optional fields
menunameexternal_id, description
categorynameexternal_id, description, image_url, sort_order, tax_exempt, tax
itemexternal_id, name, pricedescription, image_url, available, categories[], addon_groups[], product_type, sort_order, tax_exempt, tax, variant_type, variants[]
variantexternal_id, pricevariant_name, variant_key, stock, image_url, available, consumption_ratio, combinations[]
addon_groupexternal_id, name, add_ons[]description, type, min_select, max_select, required, defaults[]
add_onexternal_id, name, pricechild_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.

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’s add_ons[], of the choices pre-selected on the storefront.
  • add_ons[].child_addon_groups[]: external_ids of the menu’s addon_groups[] revealed when this add-on is chosen.
  • add_ons[].item_external_id: external_id of 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 own external_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.

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.
  • 0 means 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.

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.
  • 0 means 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.

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.

Terminal window
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.json

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[].

Small pushes apply synchronously:

200 OK
{ "status": "success", "message": "Menu pushed to Quiqqy" }

Pushes above ~100 items are processed as a background job:

202 Accepted
{
"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:

200 OK
{
"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.

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.

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.

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"
}