Catalogue
The catalogue is what the point of sale shows and what every sale line refers back to. An item belongs to a category, a brand and a tax; a menu decides which items a channel sees; modifiers add the questions asked at the till.
Endpoints
Section titled “Endpoints”| Method | Path | Scope |
|---|---|---|
GET | /v1/items | items.read |
GET | /v1/items/:id | items.read |
GET | /v1/items/:id/full | items.read |
POST | /v1/items | items.write |
PATCH | /v1/items/:id | items.write |
GET | /v1/categories | categories.read |
POST | /v1/categories | categories.write |
PATCH | /v1/categories/:id | categories.write |
GET | /v1/brands | brands.read |
POST | /v1/brands | brands.write |
PATCH | /v1/brands/:id | brands.write |
GET | /v1/units | items.read |
POST | /v1/units | items.write |
GET | /v1/kitchens | items.read |
GET | /v1/variant-attributes | items.read |
GET | /v1/modifiers | modifiers.read |
POST | /v1/modifiers | modifiers.write |
PATCH | /v1/modifiers/:id | modifiers.write |
GET | /v1/menus | menus.read |
GET | /v1/menus/:id/full | menus.read |
POST | /v1/menus/create-with-items | menus.write |
PATCH | /v1/menus/:id/update-with-items | menus.write |
GET | /v1/combo-slots | meal_slots.read |
POST | /v1/combo-slots | meal_slots.write |
PUT | /v1/items/:id/meal | meal_slots.write |
Order of operations
Section titled “Order of operations”An item cannot exist before the rows it points at. For a new catalogue, create in this order:
- Taxes —
POST /v1/taxes. See taxes. - Categories —
POST /v1/categories. - Brands —
POST /v1/brands. - Units —
GET /v1/units(create one withPOST /v1/unitsif the merchant has none you can use). - Items —
POST /v1/items, referencing the ids above. - Modifiers —
POST /v1/modifiers, attaching them to items. - Menus —
POST /v1/menus/create-with-items, selecting items per channel.
Kitchens are optional and never need creating: GET /v1/kitchens lists the ones the
merchant already runs, and an item’s kitchenId routes its ticket to one of them.
Syncing an existing catalogue? Read the merchant’s current categories, brands and taxes first and map onto them. Creating duplicates of rows the merchant already curates is the most common review failure.
Categories
Section titled “Categories”curl -s "$API_BASE_URL/categories" \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H 'Content-Type: application/json' \ -d '{ "groupName": "Hot drinks", "priority": 10, "kitchenId": 2 }'| Field | Type | Required | Notes |
|---|---|---|---|
groupName | string | yes | Up to 50 characters, unique in practice |
priority | number | no | Display order; lower sorts first. Default 0 |
kitchenId | number | no | Routes the category’s tickets to a kitchen printer or KDS |
status | number | no | 1 active (default), 0 hidden |
favourite | number | no | 1 pins the category on the till’s quick grid |
IsEBT | number | no | US only — marks the category as EBT-eligible |
Brands
Section titled “Brands”curl -s "$API_BASE_URL/brands" \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H 'Content-Type: application/json' \ -d '{ "brand": "House", "priority": 1 }'brand (≤ 50 characters) is the only required field; priority and status behave as they
do on categories. Every item needs a brand — merchants that do not think in brands keep a
single House row.
Units, kitchens and variant attributes
Section titled “Units, kitchens and variant attributes”Three small reference lists that item writes lean on. All three are item reference data, so
they use the item scopes: items.read to list, items.write to create a unit.
Every item needs a uomId. Read the merchant’s list first and map onto it.
curl -s "$API_BASE_URL/units?limit=100" \ -H "Authorization: Bearer $ACCESS_TOKEN"{ "data": [ { "id": 1, "name": "Piece", "uom": "PCS", "decPlace": 0, "status": 1 } ], "meta": { "page": 1, "limit": 100, "total": 1 }}Create one only when nothing in that list fits:
curl -s "$API_BASE_URL/units" \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H 'Content-Type: application/json' \ -d '{ "name": "Kilogram", "uom": "KG", "decPlace": 3 }'| Field | Type | Required | Notes |
|---|---|---|---|
name | string | yes | Display name, up to 30 characters |
uom | string | yes | Short code printed on documents, up to 30 characters |
decPlace | number | yes | Decimal places allowed on a quantity — 0 for whole units, 3 for weights |
multiple | number | no | Conversion multiple for the unit. Default 1 |
status | number | no | 1 active (default), 0 inactive |
Kitchens
Section titled “Kitchens”curl -s "$API_BASE_URL/kitchens" \ -H "Authorization: Bearer $ACCESS_TOKEN"{ "data": [ { "id": 2, "name": "Grill", "status": 1 } ], "meta": { "page": 1, "limit": 10, "total": 1 }}A kitchen is a printer or KDS destination. Set kitchenId on a category to route everything
in it, or on an item to override the category.
Variant attributes
Section titled “Variant attributes”curl -s "$API_BASE_URL/variant-attributes" \ -H "Authorization: Bearer $ACCESS_TOKEN"{ "data": [ { "id": 2, "name": "Size" }, { "id": 5, "name": "Colour" } ]}These are the attribute names — Size, Colour, Flavour — the merchant already uses on variant
items. Read the list before you build a variants.attributes block so that your “size”
lands on the merchant’s existing Size rather than creating a near-duplicate. Names are
matched case-insensitively on write, and a name that is genuinely new is created for the
merchant.
An item is one line at the till. Every item has a type, the type is fixed when the item is created, and the type decides what else the body may carry.
Item types
Section titled “Item types”type | Item | What it carries |
|---|---|---|
0 | Standard (default) | One sellable line with one price per store |
1 | Variant | A matrix of SKUs — Size × Colour — each with its own code, barcode and price |
2 | Composite | A fixed bundle sold and priced as one line, assembled from other items |
The type is fixed at creation. A PATCH that names a different type is refused with
409 partner.item_type_immutable, because changing it would orphan the SKUs or components
already attached. Send the same value, or leave type out.
A standard item
Section titled “A standard item”curl -s "$API_BASE_URL/items" \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H 'Content-Type: application/json' \ -d '{ "item": "Flat White", "groupId": 12, "brandId": 1, "uomId": 1, "taxId": 3, "rate": 3.75, "mrp": 4.00, "barcode": "8901234567890", "ItemCode": "COF-FW-REG", "Description": "Double ristretto, steamed milk", "stockable": 0, "CanOnlineSale": 1, "IsVeg": true, "kitchenId": 2 }'const response = await fetch(new URL('items', apiBaseUrl), { method: 'POST', headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ item: 'Flat White', groupId: 12, brandId: 1, uomId: 1, taxId: 3, rate: 3.75, barcode: '8901234567890', ItemCode: 'COF-FW-REG', CanOnlineSale: 1, }),});
if (!response.ok) { const body = await response.json(); throw new Error(`${body.code ?? response.status}: ${body.message}`);}
const { data: item } = await response.json();That body carries no stores, so rate is applied at every active store the merchant has.
That fan-out is what makes the item sellable at the till: an item with no store price is not
on sale anywhere. Warehouses are skipped, and a store-scoped grant narrows the fan-out to
the stores your app was granted.
Send stores when the price differs by store, or when you want to open the item with stock:
curl -s "$API_BASE_URL/items" \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H 'Content-Type: application/json' \ -d '{ "item": "Flat White", "groupId": 12, "brandId": 1, "uomId": 1, "taxId": 3, "rate": 3.75, "stores": [ { "storeId": 1, "price": 3.75, "openingStock": 20, "cost": 1.1 }, { "storeId": 2, "price": 3.95 } ] }'On create, a store you leave out of stores gets no row at all — list every store the item
should sell in, or leave stores out entirely (an empty list means the same thing) and let
rate fan out.
Fields
Section titled “Fields”| Field | Type | Required | Notes |
|---|---|---|---|
item | string | yes | Display name, up to 100 characters |
groupId | number | yes | Category id |
brandId | number | yes | Brand id |
uomId | number | yes | Unit of measure id — GET /v1/units |
taxId | number | yes | Sales tax row applied at the till |
type | number | no | 0 standard (default), 1 variant, 2 composite. Fixed at creation |
item1 | string | no | Secondary name — used for a second language on receipts |
rate | number | no | Selling price. On a standard or composite item it is applied at every active store unless stores says otherwise; on a variant item pricing lives on the SKUs |
mrp | number | no | Maximum retail price, where regulation requires it printed |
barcode | string | no | Single barcode. One value, not a list |
ItemCode | string | no | Your own SKU. The field to correlate on when syncing |
hsn | string | no | Tax classification code (HSN/SAC), up to 35 characters |
Description | string | no | Long description for online channels |
status | number | no | 1 active (default), 0 inactive |
stockable | number | no | 1 tracks stock (default), 0 does not |
canSale | number | no | 1 sellable at the till (default), 0 hides it while keeping it for recipes |
CanOnlineSale | number | no | 1 exposes the item to online ordering (default) |
kitchenId | number | no | Overrides the category’s kitchen routing |
hasBatch | number | no | 1 enables batch tracking |
ExpiryTrack | number | no | 1 requires expiry dates on receipt |
weighingScale | number | no | 1 for scale-priced goods. Default 0, and always 0 on a variant item |
serialNo | number | no | 1 requires a serial number per unit. Default 0 |
favourite | number | no | 1 pins the item on the till’s quick grid. Default 0 |
eachItem | boolean | no | Default false |
IsVeg | boolean | no | Vegetarian marker used on menus. Default false |
IsService | boolean | no | Service line rather than a physical good. Default false |
IsEBT | number | no | US only — marks the item as EBT-eligible. Default 0 |
IsAlcahol | boolean | no | Alcohol marker (spelling is part of the wire contract). Default false |
HealthyInfo | string | no | Nutrition or allergen note shown on online channels |
PurchTaxId | number | no | Purchase-side tax, when it differs from the sales tax |
cessId, Tax4Id | number | no | Additional levies in jurisdictions that use them |
PurchCessId, PurchTax4Id | number | no | Purchase-side equivalents |
modifierIds | number[] | no | Modifier groups attached to the item, up to 50 ids |
prepTime | number | null | no | Preparation time in minutes. null clears it |
calories | number | null | no | Energy in kcal. null clears it |
dietaryTags | string[] | no | Up to 50 tags of up to 40 characters. [] clears them |
stores | object[] | no | Per-store price and stock. Standard and composite items only — see below |
variants | object | type: 1 | Attributes and SKUs — see A variant item |
combo | object[] | type: 2 | Components of the bundle — see A composite item |
Never send userId, Modifiers, modifierId, imageId or Images: the actor is derived
from your token, modifier groups are attached with modifierIds, and item images are back
office only.
stores[]
Section titled “stores[]”Up to 200 rows, on standard and composite items. A variant item carries no item-level
stores — its SKUs do. On create, omitting the list and sending an empty one mean the same
thing: sell at every active store at rate. A storeId may appear only once per list.
| Field | Type | Required | Notes |
|---|---|---|---|
storeId | number | yes | Must be an active store of the merchant, and one your app was granted |
price | number | no | Sale price at this store. Defaults to the item’s rate |
minPrice | number | no | Lowest price the till will accept. Default 0 |
markup | number | no | Markup % over cost (the back office’s Markup % column). Default 0 on create; an update that omits it keeps the stored value |
cost | number | no | Unit cost used to value openingStock. Default 0 |
openingStock | number | no | Create only, stockable items only. Ignored on PATCH |
reorderPoint | number | no | Low-stock threshold. Default 0 |
status | number | no | 1 sold at this store (default), 0 not |
{ "data": { "id": 8841, "type": 0, "item": "Flat White", "groupId": 12, "brandId": 1, "uomId": 1, "taxId": 3, "rate": 3.75, "status": 1, "modifierIds": [], "stores": [ { "id": 77, "storeId": 1, "storeName": "Brigade Road", "price": 3.75, "minPrice": 0, "markup": 0, "cost": 1.1, "reorderPoint": 0, "status": 1 } ], "variants": null, "combo": [] }}POST and PATCH both return the item definition — the same shape as
GET /v1/items/:id/full, abridged here.
A variant item
Section titled “A variant item”One product, many SKUs. Declare the attributes, then every SKU the merchant sells.
curl -s "$API_BASE_URL/items" \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H 'Content-Type: application/json' \ -d '{ "item": "House T-shirt", "type": 1, "groupId": 12, "brandId": 1, "uomId": 1, "taxId": 3, "ItemCode": "TS-001", "variants": { "attributes": [ { "name": "Size", "values": ["S", "M", "L"] }, { "name": "Colour", "values": ["Black", "White"] } ], "skus": [ { "options": { "Size": "S", "Colour": "Black" }, "itemCode": "TS-001-S-BK", "barcode": "8901234500011", "price": 9.5 }, { "options": { "Size": "M", "Colour": "Black" }, "itemCode": "TS-001-M-BK", "barcode": "8901234500012", "price": 9.5 }, { "options": { "Size": "M", "Colour": "White" }, "itemCode": "TS-001-M-WH", "barcode": "8901234500013", "price": 10.5, "stores": [ { "storeId": 1, "price": 10.5, "openingStock": 12, "cost": 4.2 }, { "storeId": 2, "price": 11.0 } ] } ] } }'const response = await fetch(new URL('items', apiBaseUrl), { method: 'POST', headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ item: 'House T-shirt', type: 1, groupId: 12, brandId: 1, uomId: 1, taxId: 3, ItemCode: 'TS-001', variants: { attributes: [ { name: 'Size', values: ['S', 'M', 'L'] }, { name: 'Colour', values: ['Black', 'White'] }, ], skus: [ { options: { Size: 'S', Colour: 'Black' }, itemCode: 'TS-001-S-BK', barcode: '8901234500011', price: 9.5 }, { options: { Size: 'M', Colour: 'Black' }, itemCode: 'TS-001-M-BK', barcode: '8901234500012', price: 9.5 }, { options: { Size: 'M', Colour: 'White' }, itemCode: 'TS-001-M-WH', barcode: '8901234500013', price: 10.5, stores: [ { storeId: 1, price: 10.5, openingStock: 12, cost: 4.2 }, { storeId: 2, price: 11.0 }, ], }, ], }, }),});
const { data: item } = await response.json();You do not have to send the whole matrix. Three of the six combinations are listed above and three simply do not exist for this merchant.
variants.attributes[]
Section titled “variants.attributes[]”One to five attributes.
| Field | Type | Required | Notes |
|---|---|---|---|
name | string | yes | 1–20 characters. Matched case-insensitively against the merchant’s existing attributes; a new name creates one |
values | string[] | yes | 1–50 values, each 1–200 characters |
variants.skus[]
Section titled “variants.skus[]”One to 500 SKUs.
| Field | Type | Required | Notes |
|---|---|---|---|
id | number | no | An existing SKU id. PATCH only — leave it out when creating |
options | object | yes | Attribute name to value, one entry per declared attribute |
itemCode | string | no | SKU code, up to 45 characters |
barcode | string | no | Up to 45 characters |
price | number | no | Default sale price for this SKU |
minPrice | number | no | Lowest price the till will accept |
mrp | number | no | Maximum retail price |
markup | number | no | Markup % over cost for this SKU. Default 0 on create; an update that omits it keeps the stored value. SKU store rows carry no markup |
status | number | no | 1 active (default), 0 inactive |
stores | object[] | no | Per-store rows for this SKU, up to 200. Omitted means price fans out to every active store |
variants.skus[].stores[]
Section titled “variants.skus[].stores[]”| Field | Type | Required | Notes |
|---|---|---|---|
storeId | number | yes | Must be an active store of the merchant, and one your app was granted |
price | number | no | Defaults to the SKU’s price |
minPrice | number | no | |
cost | number | no | Unit cost used to value openingStock |
openingStock | number | no | Create only. Ignored on PATCH |
reorderPoint | number | no | Low-stock threshold |
reorderQty | number | no | Quantity suggested when reordering |
status | number | no | 1 sold at this store (default), 0 not |
The rules the validator enforces:
- Every SKU names every declared attribute, with a value that attribute declares. A SKU that skips one is rejected with
partner.variant_sku_incomplete; an unknown name or value withpartner.variant_option_unknown. - Attribute names and values are matched case-insensitively, so
"size": "m"finds the merchant’sSize/M. A name nobody has used before is created for the merchant — list what exists withGET /v1/variant-attributesfirst. - Attribute names must be unique within
variants.attributes— they are compared case-insensitively after trimming, soSizeandsizeare the same attribute (partner.variant_attribute_duplicate). - No two SKUs may share the same option combination, and no two SKUs in one update may resolve to the same stored SKU (
partner.variant_sku_duplicate). - A variant item carries no item-level
stores: store pricing lives on the SKUs. Prices come from the SKUs too, so leaverateto the read side — it comes back as the average of the SKU store prices, and an attempt toPATCHit is refused withcatalogue_products.variant_item_price_update_blocked.
A composite item
Section titled “A composite item”A composite item is a bundle sold as one line: one till button, one price, several items behind it.
curl -s "$API_BASE_URL/items" \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H 'Content-Type: application/json' \ -d '{ "item": "Lunch deal", "type": 2, "groupId": 12, "brandId": 1, "uomId": 1, "taxId": 3, "rate": 12, "combo": [ { "itemId": 8850, "qty": 1 }, { "itemId": 8851, "qty": 1, "choiceGroup": "drink" }, { "itemId": 8852, "qty": 1, "choiceGroup": "drink", "price": 0.5 }, { "itemId": 8853, "optional": true } ] }'rate is the price of the whole bundle and fans out to every active store, exactly as it
does on a standard item. stores works here too when the deal costs more in one location.
combo[]
Section titled “combo[]”One to 100 components; at least one is required when creating.
| Field | Type | Required | Notes |
|---|---|---|---|
id | number | no | An existing component row id. PATCH only |
itemId | number | yes | The component item. It must exist and must not itself be a composite |
variantId | number | no | The SKU, when the component is a variant item. 0 or omitted otherwise |
qty | number | no | Units of the component in the bundle. Default 1 |
price | number | no | What this component costs inside the combo. Default 0, meaning included in the bundle price |
optional | boolean | no | true offers the component instead of always including it. Default false |
choiceGroup | string | no | Up to 45 characters. Components sharing a group are a pick-one choice |
In the example: item 8850 is always in the deal. Items 8851 and 8852 share the group
drink, so the cashier picks one of the two — and picking 8852 adds 0.50 to the line,
because that is its price inside the combo. Item 8853 is optional: offered, never
automatic.
Reading the definition back
Section titled “Reading the definition back”GET /v1/items/:id returns the list row — the projection you get from GET /v1/items, with
rate as the average active-store price. GET /v1/items/:id/full returns the definition:
the same vocabulary you write.
curl -s "$API_BASE_URL/items/8841/full" \ -H "Authorization: Bearer $ACCESS_TOKEN"{ "data": { "id": 8841, "type": 1, "item": "T-shirt", "item1": null, "Description": null, "ItemCode": "TS-001", "barcode": "", "hsn": null, "groupId": 12, "brandId": 1, "uomId": 1, "taxId": 3, "PurchTaxId": 3, "cessId": 0, "PurchCessId": 0, "Tax4Id": 0, "PurchTax4Id": 0, "kitchenId": 0, "mrp": 0, "rate": 9.5, "status": 1, "stockable": 1, "canSale": 1, "CanOnlineSale": 1, "eachItem": false, "favourite": 0, "weighingScale": 0, "serialNo": 0, "hasBatch": 0, "ExpiryTrack": 0, "IsVeg": false, "IsService": false, "IsEBT": 0, "IsAlcahol": false, "HealthyInfo": null, "imageId": null, "prepTime": null, "calories": null, "dietaryTags": [], "modifierIds": [4], "stores": [], "variants": { "attributes": [ { "id": 2, "name": "Size", "values": [ { "id": 31, "name": "S" }, { "id": 32, "name": "M" } ] } ], "skus": [ { "id": 501, "options": { "Size": "S" }, "itemCode": "TS-001-S", "barcode": "", "price": 9.5, "minPrice": 0, "mrp": 0, "markup": 0, "status": 1, "stores": [ { "id": 77, "storeId": 1, "price": 9.5, "minPrice": 0, "cost": 0, "reorderPoint": 0, "reorderQty": 0, "status": 1 } ] } ] }, "combo": [] }}The flat keys are the ones you wrote, spelled the same way. These are the keys the list row does not have:
| Key | Notes |
|---|---|
type | 0 standard, 1 variant, 2 composite |
stores | { id, storeId, storeName, price, minPrice, markup, cost, reorderPoint, status } per store. Empty on a variant item. Live stock is not part of the definition — read it from inventory |
variants | attributes with their value ids, and skus (each carrying markup) with their store rows. null on a non-variant item |
combo | { id, itemId, itemName, variantId, qty, price, optional, choiceGroup, status } per component. [] on a non-composite item, and rows materialised from meal slots are excluded — those belong to PUT /v1/items/:id/meal |
modifierIds | Ids of the modifier groups attached to the item |
prepTime, calories, dietaryTags | Preparation minutes, kcal and the tag list |
variants.skus[].id is the variantId that menus, online orders, purchase orders and stock
transfers carry. Read it here once, store it against your own SKU, and send it on every line
for that SKU.
rate is the average active-store price, and 0 when no store row carries a price.
Finding items
Section titled “Finding items”curl -s "$API_BASE_URL/items?search=flat&groupId=12&status=1&limit=100" \ -H "Authorization: Bearer $ACCESS_TOKEN"| Parameter | Notes |
|---|---|
search | Matches name, code and barcode |
groupId | One or more category ids, comma-separated |
brandId, taxId | Single id |
status | 1 or 0 |
stockStatus | inStock, low or out |
page, limit, sort, order | Standard paging |
Updating
Section titled “Updating”PATCH accepts any subset of the create fields. Send only what changes:
curl -s -X PATCH "$API_BASE_URL/items/8841" \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H 'Content-Type: application/json' \ -d '{ "rate": 3.95 }'| What you send | What happens |
|---|---|
| A scalar field | Written. A field you leave out is untouched |
rate | On a standard or composite item: every active store your app is granted is re-priced — each existing row at such a store, and every such store with no row yet, gets price = rate. Rows at archived stores, at warehouses, or at stores outside a store-scoped grant are left as they are. A stores[] sent in the same body is a set of per-store overrides: a store you list with its own price keeps that price, so { "rate": 10, "stores": [] } re-prices everything. Divergent store prices are overwritten — that is what one rate means. On a variant item the request is refused with catalogue_products.variant_item_price_update_blocked; price the SKUs instead |
stores | Each listed row is upserted, matched by storeId; values you omit on a listed store keep their stored values (including markup). On its own, an empty stores list touches nothing; when rate is in the same body, stores[] is the set of overrides described in the rate row above. Stores you do not list are untouched. openingStock is ignored — move stock with an adjustment |
variants | The complete definition. attributes is the whole attribute list, so a value you leave out is deactivated. skus is the whole SKU list: each is matched by id, or failing that by its option combination; a matched SKU keeps every scalar you omit, and one whose stores you omit keeps its store rows. SKUs you leave out are retired (soft-deleted; sales history is kept). New SKUs without stores get the usual fan-out |
combo | The complete component list. Components are matched by id, or by itemId and variantId. Components you leave out are disabled; new ones are added |
modifierIds | The complete modifier list for the item |
prepTime, calories | null clears the value; omitting the key leaves it alone |
dietaryTags | [] clears the tags; omitting the key leaves them alone |
type | Ignored when it equals the stored type; 409 partner.item_type_immutable when it differs |
A nested collection you send replaces the stored one — except stores, where only the rows
you list are written; stores you do not list are untouched. A nested collection you omit is
not touched at all, so a price change never needs the SKU list attached.
# Per-store price, one store only — store 1 is untouchedcurl -s -X PATCH "$API_BASE_URL/items/8841" \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H 'Content-Type: application/json' \ -d '{ "stores": [{ "storeId": 2, "price": 4.25 }] }'
# The SKU list is complete: the SKU left out of this body is retiredcurl -s -X PATCH "$API_BASE_URL/items/8900" \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H 'Content-Type: application/json' \ -d '{ "variants": { "attributes": [{ "name": "Size", "values": ["S", "M"] }], "skus": [ { "id": 501, "options": { "Size": "S" }, "price": 10.5 }, { "id": 502, "options": { "Size": "M" }, "price": 10.5 } ] } }'
# Swap a component out of the bundlecurl -s -X PATCH "$API_BASE_URL/items/8910" \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H 'Content-Type: application/json' \ -d '{ "combo": [{ "itemId": 8850, "qty": 1 }, { "itemId": 8854, "qty": 1, "choiceGroup": "drink" }] }'
# Re-attach modifier groups, and clear the dietary tagscurl -s -X PATCH "$API_BASE_URL/items/8841" \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H 'Content-Type: application/json' \ -d '{ "modifierIds": [4, 6], "dietaryTags": [] }'Deactivate rather than delete — {"status": 0} keeps the item’s sales history intact while
removing it from the till.
Errors
Section titled “Errors”Item writes branch on code, never on the message text — messages arrive in the caller’s
language.
| Code | HTTP | Means |
|---|---|---|
partner.item_type_immutable | 409 | The PATCH names a type other than the stored one |
partner.item_variants_required | 400 | type: 1 created without variants, or with an empty attributes or skus list |
partner.item_variants_not_allowed | 400 | variants sent for an item that is not type: 1, or item-level stores sent for one that is |
partner.item_combo_required | 400 | type: 2 created without components |
partner.item_combo_not_allowed | 400 | combo sent for an item that is not type: 2 |
partner.variant_option_unknown | 400 | A SKU names an attribute or value that the body did not declare. params carries attribute and value |
partner.variant_sku_incomplete | 400 | A SKU does not name every declared attribute |
partner.variant_sku_duplicate | 400 | Two SKUs share a combination, or two SKUs in one update resolve to the same stored SKU |
partner.variant_attribute_duplicate | 400 | The same attribute name is declared twice in variants.attributes |
partner.combo_component_not_found | 404 | A component item or SKU does not exist in this merchant |
partner.combo_component_invalid | 400 | A component is itself a composite item |
partner.store_unknown | 400 | A storeId is not an active store of the merchant |
partner.store_duplicate | 400 | The same storeId appears twice in one stores list |
partner.store_not_granted | 403 | A storeId is outside the stores the merchant granted your app |
The catalogue’s own codes come through unchanged, including
catalogue_products.product_name_exists, catalogue_products.sku_exists,
catalogue_products.barcode_exists, catalogue_products.duplicate_sku_in_request and
catalogue_products.category_required. catalogue_products.variant_item_price_update_blocked
is the one returned for a rate update on a variant item.
Modifiers
Section titled “Modifiers”A modifier group is a question — “Milk?”, “Add extras” — with options, attached to the items that should ask it.
curl -s "$API_BASE_URL/modifiers" \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H 'Content-Type: application/json' \ -d '{ "modifier": "Milk", "Type": 0, "Min": 1, "Max": 1, "DisplayOrder": 1, "Options": [ { "item": "Whole", "rate": 0, "PreDefault": true }, { "item": "Oat", "rate": 0.40 }, { "item": "Soy", "rate": 0.40 } ], "ItemIds": [8841] }'| Field | Type | Required | Notes |
|---|---|---|---|
modifier | string | yes | Group label, up to 45 characters |
Type | number | yes | 0 single choice, 1 multiple choice |
Min / Max | number | no | Selection bounds. Min: 1 makes the question mandatory |
HasMax | number | no | 1 enforces Max |
IncludeQty | number | no | 1 lets the cashier pick a quantity per option |
DisplayOrder | number | no | Order among an item’s modifier groups |
Options[].item | string | yes | Option label |
Options[].rate | number | no | Price delta at the till |
Options[].Rate1 / Rate2 | number | no | Takeaway and delivery price deltas |
Options[].PreDefault | boolean | no | Pre-selected option |
Options[].Max / Qty | number | no | Per-option quantity cap and default quantity |
Options[].ItemId | number | no | Links the option to a stocked item so it depletes inventory |
ItemIds | number[] | no | Items this group is attached to |
Attaching from the item’s side works too: modifierIds on POST /v1/items or
PATCH /v1/items/:id sets the item’s complete list of groups.
A menu is a named selection of items with optional timings and channel flags. Use it to give delivery a different list from the dine-in till.
curl -s "$API_BASE_URL/menus/create-with-items" \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H 'Content-Type: application/json' \ -d '{ "Menu": "All day", "Description": "Everything except breakfast-only lines", "Status": true, "Online": true, "Delivery": true, "Takeaway": true, "StoreIds": [1, 2], "Timings": [{ "days": [1,2,3,4,5], "fromTime": "07:00", "toTime": "22:00" }], "Items": [ { "itemId": 8841, "variantId": 0 }, { "itemId": 8842, "variantId": 0 } ] }'| Field | Type | Required | Notes |
|---|---|---|---|
Menu | string | yes | Name, up to 45 characters |
Description | string | yes | |
Status | boolean | no | Default true |
Online, Delivery, Takeaway | boolean | no | Channel flags, default false |
StoreIds | number[] | no | Empty or omitted means every store |
Timings[].days | number[] | no | 0 Sunday through 6 Saturday |
Timings[].fromTime / toTime | string | no | HH:mm, store-local |
Items[].itemId | number | yes | |
Items[].variantId | number | yes | 0 when the item has no variants; otherwise a SKU id from GET /v1/items/:id/full (variants.skus[].id) |
GET /v1/menus/:id/full returns the menu with its items and timings expanded —
this is the read the Online Order API uses for
menu sync.
Combo meals
Section titled “Combo meals”Meal slots are the slot-based builder; for a fixed bundle use a composite item
(type: 2, above). Combo meals are built from slots: “pick a main”,
“pick a side”, “pick a drink”.
# 1. Define a slot with its optionscurl -s "$API_BASE_URL/combo-slots" \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H 'Content-Type: application/json' \ -d '{ "name": "Choose a side", "minSelect": 1, "maxSelect": 1, "allowRepeat": 0, "options": [ { "childItemId": 8850, "qty": 1, "isDefault": 1, "displayOrder": 1 }, { "childItemId": 8851, "qty": 1, "extraPrice": 0.50, "displayOrder": 2 } ] }'
# 2. Attach slots to the combo itemcurl -s -X PUT "$API_BASE_URL/items/8900/meal" \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H 'Content-Type: application/json' \ -d '{ "slots": [{ "slotId": 4, "displayOrder": 1 }] }'| Field | Type | Required | Notes |
|---|---|---|---|
name | string | yes | Slot label shown at the till |
minSelect / maxSelect | number | no | Selection bounds for the slot |
allowRepeat | number | no | 1 allows the same option more than once |
options[].childItemId | number | yes | The item offered in this slot |
options[].childVariantId | number | no | 0 when the item has no variants |
options[].qty | number | no | Units of the option included, default 1 |
options[].extraPrice | number | no | Surcharge when this option is chosen |
options[].isDefault | number | no | 1 pre-selects the option |
options[].displayOrder | number | no | Order within the slot |
On PUT /v1/items/:id/meal, slots attaches slot definitions (slotId, plus
optional minSelect, maxSelect, displayOrder), fixed lists items always included in
the combo (itemId, qty), and upsells offers paid additions triggered by a chosen item
(triggerItemId, slotId, label).