11 operations. Every schema and example on this page is generated from the platform contract.
Merchant read of the store’s menu items. Removed items (soft-deleted) are excluded; merchant-hidden items are included (flagged via is_available/checkout_enabled). The menu is the PHYSICAL items only: a download (PRODUCT-SPEC 0.11) is never in it. `fulfilment_kind=digital` lists the shop’s downloads instead, and is a 404 until downloads are switched on.
Store whose menu to list (also authorises the merchant).
Which items to list. OMIT for the menu: the physical items, exactly as before. `digital` lists the shop’s DOWNLOADS instead (PRODUCT-SPEC 0.11), each carrying `fulfilment_kind`, `digital_ready_asset_count` and `is_complete`; it is a 404 until downloads are switched on. A download is never in the menu list.
physicaldigitalHer photos and videos, in her order: at most 10 together, at most 3 of them videos. Since 21 Oct 2026 a video may come first; the cover is the entry `primary-image` and `image_url`, always a photo.
imagevideoFalse when she holds her item's photos and videos still on her drop page; true (the default) when they move on by themselves.
FA078. True ONLY when a human ticked "no allergens in this item". An empty contains_allergens with no_allergens=false means NOBODY HAS SAID, it is not a statement that the item is allergen-free, and must never be rendered as one.
The diets the merchant declared, in the canonical spelling: any of "Vegetarian", "Vegan", "Gluten-free", "Dairy-free". A legacy "Dairy Free" reads as "Dairy-free".
`diets` plus what they imply, Vegan also satisfies Vegetarian and Dairy-free. Derived on read, never stored, so what the merchant actually said stays distinguishable. This is what a diet filter reads. Since 12 Oct 2026 it leaves out Gluten-free for an item whose `may_contain_allergens` names Gluten, and Dairy-free (the seller's own tag, or through Vegan) for one that names Dairy: a filter never shows an item as free from something the seller says it may contain. Vegan and Vegetarian are never left out, and `diets` and `dietary_tags` still say what the seller tagged.
`contains_allergens` grouped to the UK fourteen, in the law's order ("tree nuts (almond)" and "Tree Nuts (Pistachio)" are both "Tree Nuts"). Allergens MARK a dish on a menu, never hide it.
What the item MAY contain (traces, e.g. made beside nuts): the UK fourteen the seller ticked under "May contain", in the law's order and spelling ("Tree Nuts", "Peanuts"). Empty means she has said nothing may be present, never "none". Not an answer to what it contains (`contains_allergens` / `no_allergens` are). Absent on a payload from before 8 Oct 2026.
The seller's "Anything else" about allergens (one outside the fourteen, say), in her own words, at most 200 characters. Null when she wrote nothing. Show it as written.
This dish's OWN serving times (service period ids). Empty when it follows its section's serving times, see `service_periods_inherited`.
True when the dish has no serving times of its own and follows its section (or, when the section has none either, is served all day).
False for an item that is not food (clothes, flowers): it needs no ingredients and no allergen answer, and `ingredients` holds what it is made of. True or absent: food.
Whether this row is a dish or a SUPPLEMENT, an add-on line off the printed menu ("Flavoured Syrup 50p") that nobody orders on its own. A supplement must never set a section’s "from" price or be counted in its dish tally: Santi’s "Breakfast Hot Drinks from £0.50" was a syrup shot against a cheapest real drink of £3.00. A row from before the column existed reads as a dish, deliberately, defaulting the other way silently drops unclassified rows out of both.
dishsupplementThe printed menu’s chilli marker, 1-3. Null when the dish has no heat.
What this photograph owes the person who took it, or null when it owes nothing. Non-null for every attribution-required licence (CC BY, CC BY-SA), null for the merchant’s own picture and for CC0 / public domain. Rendering it is a CONDITION OF USE of the licence, not a courtesy, see src/lib/menu/photoCredit.ts.
The photographer, or null when the source names nobody.
The licence short name, e.g. "CC BY-SA 3.0".
The licence deed, or null when it cannot be linked.
The file page the picture came from, so the credit is checkable.
e.g. "Wikimedia Commons".
The work’s title, e.g. "File:Red Wine Glass.jpg".
When the row last changed, exactly as the database wrote it (microseconds kept). Send it back UNTOUCHED as `expected_updated_at` on an update to have it refused (409 ITEM_CHANGED) if the item changed since it was read. Round it through a date type and every save is refused.
Who gave the allergen answer: "merchant" or "merchant_confirmed" when the seller said it herself; anything else (a setup script, an import, null for not recorded) is not her word, and an editor shows the question as unanswered.
`digital` on a listed download. Absent on a menu item, which is physical.
physicaldigitalA listed download’s ready files. A download with none cannot be sold. Absent on a menu item.
A listed download’s own answer: it has a name, a cover photo and one ready file. Absent on a menu item.
Her sizes and colours (`ItemChoices`), on an item that has them. Absent on an item that has none.
curl -G "https://www.membber.com/api/v1/menu-items" \
-H "Authorization: Bearer $MEMBBER_TOKEN" \
--data-urlencode "store_id=6659c139-0000-4000-8000-d0c500000066"import { createMembberClient } from "@membber/sdk-ts";
const membber = createMembberClient({
getAccessToken: () => process.env.MEMBBER_TOKEN,
});
const { data, error } = await membber.raw.GET("/api/v1/menu-items", {
params: { query: { store_id: "6659c139-0000-4000-8000-d0c500000066" } },
});
if (error) {
// Typed error envelope: { error: { code, message, requestId } }
throw new Error(`${error.error.code}: ${error.error.message}`);
}
console.log(data);import MembberSwift
let client = MembberClient(
serverURL: MembberClient.productionServerURL,
tokenProvider: { session.accessToken }
)
let response = try await client.api.listMenuItems(
query: .init(storeId: "6659c139-0000-4000-8000-d0c500000066")
).ok.body.json
print(response){
"items": [
{
"id": "00000d1b-0000-4000-8000-d0c500000000",
"name": "Example name",
"description": "Added at the front desk",
"category": "<category>",
"emoji": "<emoji>",
"image_url": "https://example.com/image.jpg",
"product_media": [
{
"id": "00000d1b-0000-4000-8000-d0c500000000",
"type": "image",
"url": "https://example.com/image.jpg",
"thumbnail_url": "https://example.com/image.jpg",
"sort_order": 1
}
],
"media_auto_advance": true,
"contains_allergens": [
"<contains_allergen>"
],
"no_allergens": true,
"dietary_tags": [
"<dietary_tag>"
],
"diets": [
"<diet>"
],
"satisfies_diets": [
"<satisfies_diet>"
],
"allergen_groups": [
"<allergen_group>"
],
"may_contain_allergens": [
"<may_contain_allergen>"
],
"allergen_note": "Added at the front desk",
"service_period_ids": [
"af7d3b4f-0000-4000-8000-d0c5000000af"
],
"service_periods_inherited": true,
"ingredients": "<ingredients>",
"is_food": true,
"nutrition_summary": "<nutrition_summary>",
"is_popular": true,
"is_available": true,
"checkout_enabled": true,
"stock_tracking_enabled": true,
"stock_quantity": 1,
"stock_remaining": 1,
"sold_out": true,
"collect_eligible": true,
"available_now": true,
"collect_prep_minutes": 1,
"price_pence": 1500,
"currency": "GBP",
"sort_order": 1,
"item_kind": "dish",
"heat_level": 1,
"photo_credit": {
"author": "<author>",
"licence": "<licence>",
"licence_url": "https://example.com/image.jpg",
"source_url": "https://example.com/image.jpg",
"library": "<library>",
"file": "<file>"
},
"updated_at": "<updated_at>",
"allergens_source": "<allergens_source>",
"fulfilment_kind": "physical",
"digital_ready_asset_count": 1,
"is_complete": true,
"choices": {
"size_name": "<size_name>",
"colour_name": "<colour_name>",
"sizes": [
{
"label": "<label>"
}
],
"colours": [
{
"label": "<label>",
"photo_url": "https://example.com/image.jpg"
}
]
}
}
]
}Merchant creates a menu item. The service validates name + price and normalises all fields. Broadcasts a menu_item_created realtime event so customer apps re-pull the menu. No HTTP idempotency key (identical to the legacy route), a create is not deduplicated.
Store the item belongs to (also authorises the merchant).
Item name (required; max 120 chars).
Price in pence (required; 0..10,000,000).
Her menu section, as she names it (max 120 chars; sections match without regard to case). Omitted on a create, null or blank: no section, stored as "Menu" (her shop page and the Drops web read it as no section).
Her photos and videos, in her order: `{ id, type: "image" | "video", url, thumbnail_url, sort_order }`. The item keeps at most 10 together; a photo past that is not kept. Up to 3 videos an item: a write that brings a video the item does not already store is taken only when the item then holds 3 or fewer; otherwise the whole write is refused (409 ITEM_VIDEO_LIMIT, `details.videos` is how many it holds) and nothing is written. A list that sends every photo before every video keeps each video where the item already holds it among her photos, unless `media_order` is "as_sent". `image_url` is the cover, and the first photo.
Sent by a client that orders photos and videos freely (the Drops web since 21 Oct 2026): `product_media` is stored in exactly the order sent. Omitted (every client before it), a list of photos then videos keeps each video where the item already holds it.
as_sentFalse holds her item's photos and videos still on her drop page; true moves them on by themselves. Omit to keep what she chose.
FA078. Tick "no allergens in this item". OMIT to leave any existing declaration untouched; false retracts it. Ignored when contains_allergens is non-empty, listing an allergen always withdraws a standing "none" declaration.
What the item MAY contain (traces): any of the UK fourteen ("Celery", "Gluten", "Crustaceans", "Eggs", "Fish", "Lupin", "Dairy", "Molluscs", "Mustard", "Tree Nuts", "Peanuts", "Sesame", "Soya", "Sulphites"; other spellings of one of them, such as "milk" or "tree_nuts", are stored as that one). A word that names none of them is a 422 ALLERGEN_UNKNOWN naming it. OMIT to leave what is stored untouched (a whole-row save that never sends it keeps it); an empty list clears it. Independent of `contains_allergens` and `no_allergens`: it neither answers nor changes them. A save that sends it and would leave "Gluten-free" beside Gluten here, or "Dairy-free" beside Dairy, is a 422 DIET_CONTRADICTS_MAY_CONTAIN naming both (keep one: send `dietary_tags` without the diet, or this list without the allergen). A save that does not send this field is never refused for it, whatever is stored. Nor is a save that leaves both the item's diets and this list exactly as they are stored (a whole row sent back to change a photo or a price). Vegan beside Dairy here is allowed. Taking the diet off to keep this list (the only change to `dietary_tags` being that diet removed) never makes the item's other diets the seller's own word if they were not already.
The seller's "Anything else" about allergens, in her own words (kept on one line, trimmed). At most 200 characters: longer is a 422 ALLERGEN_NOTE_TOO_LONG, never cut. OMIT to leave it untouched; null or blank clears it.
The dish's diets, from a closed set: "Vegetarian", "Vegan", "Gluten-free", "Dairy-free" (any spelling that differs only in case, spaces or hyphens is accepted and stored canonically). An unknown tag is a 422 DIET_UNKNOWN naming it. A diet the dish's own `contains_allergens` makes false, vegan with Dairy/Eggs/Fish/Crustaceans/Molluscs, vegetarian with Fish/Crustaceans/Molluscs, dairy-free with Dairy, is a 422 DIET_CONTRADICTS_ALLERGEN naming the allergen. Gluten-free with gluten listed is allowed (Reg. 828/2014 permits gluten-free wheat starch).
Whether the item is food. False: it needs a name, a price and a photo, and no ingredients or allergen answer. Omitted on a create: taken from the shop's kind (food unless the shop sells streetwear, flowers or something else). Omitted on an update: unchanged.
What the item is. OMIT for an ordinary item: it is `physical`. `digital` makes a DOWNLOAD (PRODUCT-SPEC 0.11): it needs no ingredients or allergens, costs 0 only when the write says `free_for_email: true`, is never sold through the pay page, a drop or Click & Collect, and holds its files through /api/v1/drops/digital/assets. Chosen when the item is created and never changed: an update that names a different kind is a 409 FULFILMENT_KIND_FIXED. `digital` is a 404 until downloads are switched on.
physicaldigitalHer sizes and colours (sizes and colours, 15 Oct 2026). OMIT to keep what is stored (every write that never sends it, an installed app included, keeps them); null removes them. Refused on a download, and when a colour's photo_url is not one of this item's own photos (its cover or a product_media photo, as this write leaves them). A write that removes a photo also clears any colour that showed it.
Her own word for sizes ("Flavour"). Absent: the word for size.
Her own word for colours ("Grind"). Absent: the word for colour.
Her sizes, in her order: `{ label }`. No price: a pair’s price is its drop line’s.
Her colours, in her order: `{ label, photo_url? }`.
The address of one of this item’s own photos, shown when the colour is chosen.
A DOWNLOAD only: true says this download is FREE in exchange for an email, and must be sent with `price_pence: 0` on every write that STORES a price for a free download: a create, a whole row, or a partial write that names the price (nothing is stored beyond the price). A partial write that leaves the price out needs neither. A write that would store 0 for a download without it is a 422 DOWNLOAD_PRICE_NEEDED, so no download is ever free by accident; true beside a price above 0 is a 422 FREE_DOWNLOAD_HAS_A_PRICE. Not read for an ordinary item.
A menu_items row (write result, raw DB shape).
False for an item that is not food. True or absent: food.
curl -X POST "https://www.membber.com/api/v1/menu-items" \
-H "Authorization: Bearer $MEMBBER_TOKEN" \
-H "Idempotency-Key: 1f0e2d3c-4b5a-4678-9abc-def012345678" \
-H "Content-Type: application/json" \
-d '{
"store_id": "6659c139-0000-4000-8000-d0c500000066",
"name": "Example name",
"price_pence": 1500
}'import { createMembberClient } from "@membber/sdk-ts";
const membber = createMembberClient({
getAccessToken: () => process.env.MEMBBER_TOKEN,
});
const { data, error } = await membber.raw.POST("/api/v1/menu-items", {
body: {
store_id: "6659c139-0000-4000-8000-d0c500000066",
name: "Example name",
price_pence: 1500
},
headers: { "Idempotency-Key": crypto.randomUUID() },
});
if (error) {
// Typed error envelope: { error: { code, message, requestId } }
throw new Error(`${error.error.code}: ${error.error.message}`);
}
console.log(data);import MembberSwift
let client = MembberClient(
serverURL: MembberClient.productionServerURL,
tokenProvider: { session.accessToken }
)
let response = try await client.api.createMenuItem(
body: .json(.init(
storeId: "6659c139-0000-4000-8000-d0c500000066",
name: "Example name",
pricePence: 1500
))
).ok.body.json
print(response){
"item": {
"id": "00000d1b-0000-4000-8000-d0c500000000",
"store_id": "6659c139-0000-4000-8000-d0c500000066",
"name": "Example name",
"price_pence": 1500,
"currency": "GBP",
"is_available": true,
"checkout_enabled": true,
"is_food": true
}
}Merchant removes a menu item. Soft delete (order-history integrity): flips visibility AND stamps deleted_at so the list can tell a REMOVED item apart from a merchant-HIDDEN one. Broadcasts a menu_item_archived realtime event. Idempotent. store_id is supplied as a query parameter. An item in a scheduled drop, or in one open for orders, is not removed (409 ITEM_IN_DROP, naming the drop in `details.drop_name`); one only in a draft, or in a drop that has closed, may go.
Store the item belongs to (also authorises the merchant).
curl -X DELETE "https://www.membber.com/api/v1/menu-items/b9bf0b8e-0000-4000-8000-d0c5000000b9?store_id=6659c139-0000-4000-8000-d0c500000066" \
-H "Authorization: Bearer $MEMBBER_TOKEN" \
-H "Idempotency-Key: 1f0e2d3c-4b5a-4678-9abc-def012345678"import { createMembberClient } from "@membber/sdk-ts";
const membber = createMembberClient({
getAccessToken: () => process.env.MEMBBER_TOKEN,
});
const { data, error } = await membber.raw.DELETE("/api/v1/menu-items/{itemId}", {
params: { path: { itemId: "b9bf0b8e-0000-4000-8000-d0c5000000b9" }, query: { store_id: "6659c139-0000-4000-8000-d0c500000066" } },
headers: { "Idempotency-Key": crypto.randomUUID() },
});
if (error) {
// Typed error envelope: { error: { code, message, requestId } }
throw new Error(`${error.error.code}: ${error.error.message}`);
}
console.log(data);import MembberSwift
let client = MembberClient(
serverURL: MembberClient.productionServerURL,
tokenProvider: { session.accessToken }
)
let response = try await client.api.archiveMenuItem(
path: .init(itemId: "b9bf0b8e-0000-4000-8000-d0c5000000b9"),
query: .init(storeId: "6659c139-0000-4000-8000-d0c500000066")
).ok.body.json
print(response){
"archived": true
}Merchant updates a menu item. Stock edits preserve in-flight reservations (set_menu_item_stock); a plain edit cannot silently resurrect a removed item (409 ITEM_ARCHIVED unless restore is intended). Broadcasts a menu_item_updated realtime event. Idempotent (converges). `partial: true` changes only the fields sent; `expected_updated_at` refuses a stale write (409 ITEM_CHANGED, `details.item` is the item now). A change to the usual price while a drop selling the item is open for orders is refused (409 ITEM_SELLING_NOW, naming the drop in `details.drop_name`); a drop not yet open takes the new price. With `partial: true`, a new `category` (her section; null puts the item in no section) moves the item to the END of that section (its sort_order goes after every other item unless `sort_order` is sent), so her sections keep their order.
Store the item belongs to (also authorises the merchant).
true: change ONLY the fields this request sends (an explicit null clears one), and read the rest from the item as stored. `name` and `price_pence` may then be omitted. The Business app sends it from build 72 for its held photo removal (image_url and product_media) and from build 77 for the save of its item editor (only the fields she changed), both with `expected_updated_at`. Omitted or false (its other saves, and the editor in every earlier build): the whole row is written, and an omitted field is cleared or defaulted, as before. Allergens travel together: send `contains_allergens` with `no_allergens` (ticking "no allergens" on its own sends an empty list). A signed-in person sending the same allergen list over a source that is not hers confirms it as her own.
The `updated_at` this client read, passed back untouched. If the item changed since, nothing is written and the answer is 409 ITEM_CHANGED with `details.item`, the item as it is now (list shape). Omitted: no check, as before.
Item name (max 120 chars). Required unless `partial` is true.
Usual price in pence (0..10,000,000). Required unless `partial` is true.
Her menu section, as she names it (max 120 chars; sections match without regard to case). Omitted on a create, null or blank: no section, stored as "Menu" (her shop page and the Drops web read it as no section).
Her photos and videos, in her order: `{ id, type: "image" | "video", url, thumbnail_url, sort_order }`. The item keeps at most 10 together; a photo past that is not kept. Up to 3 videos an item: a write that brings a video the item does not already store is taken only when the item then holds 3 or fewer; otherwise the whole write is refused (409 ITEM_VIDEO_LIMIT, `details.videos` is how many it holds) and nothing is written. A list that sends every photo before every video keeps each video where the item already holds it among her photos, unless `media_order` is "as_sent". `image_url` is the cover, and the first photo.
Sent by a client that orders photos and videos freely (the Drops web since 21 Oct 2026): `product_media` is stored in exactly the order sent. Omitted (every client before it), a list of photos then videos keeps each video where the item already holds it.
as_sentFalse holds her item's photos and videos still on her drop page; true moves them on by themselves. Omit to keep what she chose.
FA078. Tick "no allergens in this item". OMIT to leave any existing declaration untouched; false retracts it. Ignored when contains_allergens is non-empty, listing an allergen always withdraws a standing "none" declaration.
What the item MAY contain (traces): any of the UK fourteen ("Celery", "Gluten", "Crustaceans", "Eggs", "Fish", "Lupin", "Dairy", "Molluscs", "Mustard", "Tree Nuts", "Peanuts", "Sesame", "Soya", "Sulphites"; other spellings of one of them, such as "milk" or "tree_nuts", are stored as that one). A word that names none of them is a 422 ALLERGEN_UNKNOWN naming it. OMIT to leave what is stored untouched (a whole-row save that never sends it keeps it); an empty list clears it. Independent of `contains_allergens` and `no_allergens`: it neither answers nor changes them. A save that sends it and would leave "Gluten-free" beside Gluten here, or "Dairy-free" beside Dairy, is a 422 DIET_CONTRADICTS_MAY_CONTAIN naming both (keep one: send `dietary_tags` without the diet, or this list without the allergen). A save that does not send this field is never refused for it, whatever is stored. Nor is a save that leaves both the item's diets and this list exactly as they are stored (a whole row sent back to change a photo or a price). Vegan beside Dairy here is allowed. Taking the diet off to keep this list (the only change to `dietary_tags` being that diet removed) never makes the item's other diets the seller's own word if they were not already.
The seller's "Anything else" about allergens, in her own words (kept on one line, trimmed). At most 200 characters: longer is a 422 ALLERGEN_NOTE_TOO_LONG, never cut. OMIT to leave it untouched; null or blank clears it.
The dish's diets, from a closed set: "Vegetarian", "Vegan", "Gluten-free", "Dairy-free" (any spelling that differs only in case, spaces or hyphens is accepted and stored canonically). An unknown tag is a 422 DIET_UNKNOWN naming it. A diet the dish's own `contains_allergens` makes false, vegan with Dairy/Eggs/Fish/Crustaceans/Molluscs, vegetarian with Fish/Crustaceans/Molluscs, dairy-free with Dairy, is a 422 DIET_CONTRADICTS_ALLERGEN naming the allergen. Gluten-free with gluten listed is allowed (Reg. 828/2014 permits gluten-free wheat starch).
Whether the item is food. False: it needs a name, a price and a photo, and no ingredients or allergen answer. Omitted on a create: taken from the shop's kind (food unless the shop sells streetwear, flowers or something else). Omitted on an update: unchanged.
What the item is. OMIT for an ordinary item: it is `physical`. `digital` makes a DOWNLOAD (PRODUCT-SPEC 0.11): it needs no ingredients or allergens, costs 0 only when the write says `free_for_email: true`, is never sold through the pay page, a drop or Click & Collect, and holds its files through /api/v1/drops/digital/assets. Chosen when the item is created and never changed: an update that names a different kind is a 409 FULFILMENT_KIND_FIXED. `digital` is a 404 until downloads are switched on.
physicaldigitalHer sizes and colours (sizes and colours, 15 Oct 2026). OMIT to keep what is stored (every write that never sends it, an installed app included, keeps them); null removes them. Refused on a download, and when a colour's photo_url is not one of this item's own photos (its cover or a product_media photo, as this write leaves them). A write that removes a photo also clears any colour that showed it.
Her own word for sizes ("Flavour"). Absent: the word for size.
Her own word for colours ("Grind"). Absent: the word for colour.
Her sizes, in her order: `{ label }`. No price: a pair’s price is its drop line’s.
Her colours, in her order: `{ label, photo_url? }`.
The address of one of this item’s own photos, shown when the colour is chosen.
A DOWNLOAD only: true says this download is FREE in exchange for an email, and must be sent with `price_pence: 0` on every write that STORES a price for a free download: a create, a whole row, or a partial write that names the price (nothing is stored beyond the price). A partial write that leaves the price out needs neither. A write that would store 0 for a download without it is a 422 DOWNLOAD_PRICE_NEEDED, so no download is ever free by accident; true beside a price above 0 is a 422 FREE_DOWNLOAD_HAS_A_PRICE. Not read for an ordinary item.
A menu_items row (write result, raw DB shape).
False for an item that is not food. True or absent: food.
curl -X PATCH "https://www.membber.com/api/v1/menu-items/b9bf0b8e-0000-4000-8000-d0c5000000b9" \
-H "Authorization: Bearer $MEMBBER_TOKEN" \
-H "Idempotency-Key: 1f0e2d3c-4b5a-4678-9abc-def012345678" \
-H "Content-Type: application/json" \
-d '{
"store_id": "6659c139-0000-4000-8000-d0c500000066"
}'import { createMembberClient } from "@membber/sdk-ts";
const membber = createMembberClient({
getAccessToken: () => process.env.MEMBBER_TOKEN,
});
const { data, error } = await membber.raw.PATCH("/api/v1/menu-items/{itemId}", {
params: { path: { itemId: "b9bf0b8e-0000-4000-8000-d0c5000000b9" } },
body: {
store_id: "6659c139-0000-4000-8000-d0c500000066"
},
headers: { "Idempotency-Key": crypto.randomUUID() },
});
if (error) {
// Typed error envelope: { error: { code, message, requestId } }
throw new Error(`${error.error.code}: ${error.error.message}`);
}
console.log(data);import MembberSwift
let client = MembberClient(
serverURL: MembberClient.productionServerURL,
tokenProvider: { session.accessToken }
)
let response = try await client.api.updateMenuItem(
path: .init(itemId: "b9bf0b8e-0000-4000-8000-d0c5000000b9"),
body: .json(.init(
storeId: "6659c139-0000-4000-8000-d0c500000066"
))
).ok.body.json
print(response){
"item": {
"id": "00000d1b-0000-4000-8000-d0c500000000",
"store_id": "6659c139-0000-4000-8000-d0c500000066",
"name": "Example name",
"price_pence": 1500,
"currency": "GBP",
"is_available": true,
"checkout_enabled": true,
"is_food": true
}
}Replaces one dish's own serving times in a single transaction (set_menu_item_service_periods RPC). A dish with its own serving times ignores its section's; null resets it to follow the section. Every id must be one of the store's service periods (422 SERVICE_PERIOD_NOT_FOUND otherwise); a removed dish is 404. Idempotent (the same payload converges). Writes one audit row and broadcasts a menu_item_updated realtime event so customer apps re-pull the menu.
Store the dish belongs to (also authorises the merchant).
Exactly the services this dish is served at (ids from GET /api/v1/bookings/service-periods), replacing any it has. null puts the dish back on its section's serving times. An EMPTY array is refused with 422 SERVICE_SET_EMPTY: choose at least one, or send null to use its section's times.
The dish's own serving times after the write, in the merchant's card order. Empty = follows its section.
True when the dish now follows its section's serving times.
curl -X PUT "https://www.membber.com/api/v1/menu-items/b9bf0b8e-0000-4000-8000-d0c5000000b9/service-periods" \
-H "Authorization: Bearer $MEMBBER_TOKEN" \
-H "Idempotency-Key: 1f0e2d3c-4b5a-4678-9abc-def012345678" \
-H "Content-Type: application/json" \
-d '{
"store_id": "6659c139-0000-4000-8000-d0c500000066",
"service_period_ids": [
"af7d3b4f-0000-4000-8000-d0c5000000af"
]
}'import { createMembberClient } from "@membber/sdk-ts";
const membber = createMembberClient({
getAccessToken: () => process.env.MEMBBER_TOKEN,
});
const { data, error } = await membber.raw.PUT("/api/v1/menu-items/{itemId}/service-periods", {
params: { path: { itemId: "b9bf0b8e-0000-4000-8000-d0c5000000b9" } },
body: {
store_id: "6659c139-0000-4000-8000-d0c500000066",
service_period_ids: [
"af7d3b4f-0000-4000-8000-d0c5000000af"
]
},
headers: { "Idempotency-Key": crypto.randomUUID() },
});
if (error) {
// Typed error envelope: { error: { code, message, requestId } }
throw new Error(`${error.error.code}: ${error.error.message}`);
}
console.log(data);import MembberSwift
let client = MembberClient(
serverURL: MembberClient.productionServerURL,
tokenProvider: { session.accessToken }
)
let response = try await client.api.setMenuItemServicePeriods(
path: .init(itemId: "b9bf0b8e-0000-4000-8000-d0c5000000b9"),
body: .json(.init(
storeId: "6659c139-0000-4000-8000-d0c500000066",
servicePeriodIds: ["af7d3b4f-0000-4000-8000-d0c5000000af"]
))
).ok.body.json
print(response){
"item_id": "7e22b9c7-0000-4000-8000-d0c50000007e",
"service_period_ids": [
"af7d3b4f-0000-4000-8000-d0c5000000af"
],
"follows_section": true
}The Business app's select-mode Diet… and Serving times… actions. ONE transaction (bulk_set_menu_item_attributes RPC), all of the dishes change or none do, where the client used to PATCH them one by one and could leave half a menu changed. Idempotent: every field SETS a value rather than toggling one, so a retried request converges to the same menu. Writes one audit row and broadcasts a menu_item_updated realtime event.
Store the dishes belong to (also authorises the merchant).
The dishes to change.
Set EXACTLY these diets on every dish (same closed set and same refusals as a single dish). Omit to leave diets untouched. A diet any selected dish's allergens contradict refuses the whole batch (422 DIET_CONTRADICTS_ALLERGEN, naming the dish).
Give every dish exactly these serving times of its own. Omit to leave serving times untouched.
true puts every dish back on its section's serving times. Cannot be combined with service_period_ids.
How many dishes the write changed.
The full menu re-read after the write, so the client lands the server's answer.
Her photos and videos, in her order: at most 10 together, at most 3 of them videos. Since 21 Oct 2026 a video may come first; the cover is the entry `primary-image` and `image_url`, always a photo.
imagevideoFalse when she holds her item's photos and videos still on her drop page; true (the default) when they move on by themselves.
FA078. True ONLY when a human ticked "no allergens in this item". An empty contains_allergens with no_allergens=false means NOBODY HAS SAID, it is not a statement that the item is allergen-free, and must never be rendered as one.
The diets the merchant declared, in the canonical spelling: any of "Vegetarian", "Vegan", "Gluten-free", "Dairy-free". A legacy "Dairy Free" reads as "Dairy-free".
`diets` plus what they imply, Vegan also satisfies Vegetarian and Dairy-free. Derived on read, never stored, so what the merchant actually said stays distinguishable. This is what a diet filter reads. Since 12 Oct 2026 it leaves out Gluten-free for an item whose `may_contain_allergens` names Gluten, and Dairy-free (the seller's own tag, or through Vegan) for one that names Dairy: a filter never shows an item as free from something the seller says it may contain. Vegan and Vegetarian are never left out, and `diets` and `dietary_tags` still say what the seller tagged.
`contains_allergens` grouped to the UK fourteen, in the law's order ("tree nuts (almond)" and "Tree Nuts (Pistachio)" are both "Tree Nuts"). Allergens MARK a dish on a menu, never hide it.
What the item MAY contain (traces, e.g. made beside nuts): the UK fourteen the seller ticked under "May contain", in the law's order and spelling ("Tree Nuts", "Peanuts"). Empty means she has said nothing may be present, never "none". Not an answer to what it contains (`contains_allergens` / `no_allergens` are). Absent on a payload from before 8 Oct 2026.
The seller's "Anything else" about allergens (one outside the fourteen, say), in her own words, at most 200 characters. Null when she wrote nothing. Show it as written.
This dish's OWN serving times (service period ids). Empty when it follows its section's serving times, see `service_periods_inherited`.
True when the dish has no serving times of its own and follows its section (or, when the section has none either, is served all day).
False for an item that is not food (clothes, flowers): it needs no ingredients and no allergen answer, and `ingredients` holds what it is made of. True or absent: food.
Whether this row is a dish or a SUPPLEMENT, an add-on line off the printed menu ("Flavoured Syrup 50p") that nobody orders on its own. A supplement must never set a section’s "from" price or be counted in its dish tally: Santi’s "Breakfast Hot Drinks from £0.50" was a syrup shot against a cheapest real drink of £3.00. A row from before the column existed reads as a dish, deliberately, defaulting the other way silently drops unclassified rows out of both.
dishsupplementThe printed menu’s chilli marker, 1-3. Null when the dish has no heat.
What this photograph owes the person who took it, or null when it owes nothing. Non-null for every attribution-required licence (CC BY, CC BY-SA), null for the merchant’s own picture and for CC0 / public domain. Rendering it is a CONDITION OF USE of the licence, not a courtesy, see src/lib/menu/photoCredit.ts.
The photographer, or null when the source names nobody.
The licence short name, e.g. "CC BY-SA 3.0".
The licence deed, or null when it cannot be linked.
The file page the picture came from, so the credit is checkable.
e.g. "Wikimedia Commons".
The work’s title, e.g. "File:Red Wine Glass.jpg".
When the row last changed, exactly as the database wrote it (microseconds kept). Send it back UNTOUCHED as `expected_updated_at` on an update to have it refused (409 ITEM_CHANGED) if the item changed since it was read. Round it through a date type and every save is refused.
Who gave the allergen answer: "merchant" or "merchant_confirmed" when the seller said it herself; anything else (a setup script, an import, null for not recorded) is not her word, and an editor shows the question as unanswered.
`digital` on a listed download. Absent on a menu item, which is physical.
physicaldigitalA listed download’s ready files. A download with none cannot be sold. Absent on a menu item.
A listed download’s own answer: it has a name, a cover photo and one ready file. Absent on a menu item.
Her sizes and colours (`ItemChoices`), on an item that has them. Absent on an item that has none.
curl -X POST "https://www.membber.com/api/v1/menu-items/bulk" \
-H "Authorization: Bearer $MEMBBER_TOKEN" \
-H "Idempotency-Key: 1f0e2d3c-4b5a-4678-9abc-def012345678" \
-H "Content-Type: application/json" \
-d '{
"store_id": "6659c139-0000-4000-8000-d0c500000066",
"item_ids": [
"7e22b9c7-0000-4000-8000-d0c50000007e"
]
}'import { createMembberClient } from "@membber/sdk-ts";
const membber = createMembberClient({
getAccessToken: () => process.env.MEMBBER_TOKEN,
});
const { data, error } = await membber.raw.POST("/api/v1/menu-items/bulk", {
body: {
store_id: "6659c139-0000-4000-8000-d0c500000066",
item_ids: [
"7e22b9c7-0000-4000-8000-d0c50000007e"
]
},
headers: { "Idempotency-Key": crypto.randomUUID() },
});
if (error) {
// Typed error envelope: { error: { code, message, requestId } }
throw new Error(`${error.error.code}: ${error.error.message}`);
}
console.log(data);import MembberSwift
let client = MembberClient(
serverURL: MembberClient.productionServerURL,
tokenProvider: { session.accessToken }
)
let response = try await client.api.bulkUpdateMenuItems(
body: .json(.init(
storeId: "6659c139-0000-4000-8000-d0c500000066",
itemIds: ["7e22b9c7-0000-4000-8000-d0c50000007e"]
))
).ok.body.json
print(response){
"updated": -9007199254740991,
"items": [
{
"id": "00000d1b-0000-4000-8000-d0c500000000",
"name": "Example name",
"description": "Added at the front desk",
"category": "<category>",
"emoji": "<emoji>",
"image_url": "https://example.com/image.jpg",
"product_media": [
{
"id": "00000d1b-0000-4000-8000-d0c500000000",
"type": "image",
"url": "https://example.com/image.jpg",
"thumbnail_url": "https://example.com/image.jpg",
"sort_order": 1
}
],
"media_auto_advance": true,
"contains_allergens": [
"<contains_allergen>"
],
"no_allergens": true,
"dietary_tags": [
"<dietary_tag>"
],
"diets": [
"<diet>"
],
"satisfies_diets": [
"<satisfies_diet>"
],
"allergen_groups": [
"<allergen_group>"
],
"may_contain_allergens": [
"<may_contain_allergen>"
],
"allergen_note": "Added at the front desk",
"service_period_ids": [
"af7d3b4f-0000-4000-8000-d0c5000000af"
],
"service_periods_inherited": true,
"ingredients": "<ingredients>",
"is_food": true,
"nutrition_summary": "<nutrition_summary>",
"is_popular": true,
"is_available": true,
"checkout_enabled": true,
"stock_tracking_enabled": true,
"stock_quantity": 1,
"stock_remaining": 1,
"sold_out": true,
"collect_eligible": true,
"available_now": true,
"collect_prep_minutes": 1,
"price_pence": 1500,
"currency": "GBP",
"sort_order": 1,
"item_kind": "dish",
"heat_level": 1,
"photo_credit": {
"author": "<author>",
"licence": "<licence>",
"licence_url": "https://example.com/image.jpg",
"source_url": "https://example.com/image.jpg",
"library": "<library>",
"file": "<file>"
},
"updated_at": "<updated_at>",
"allergens_source": "<allergens_source>",
"fulfilment_kind": "physical",
"digital_ready_asset_count": 1,
"is_complete": true,
"choices": {
"size_name": "<size_name>",
"colour_name": "<colour_name>",
"sizes": [
{
"label": "<label>"
}
],
"colours": [
{
"label": "<label>",
"photo_url": "https://example.com/image.jpg"
}
]
}
}
]
}Merchant sets the order their menu sections appear in. The customer menu derives section order from each section’s first appearance in the sort_order-sorted item list, so this renumbers every item’s sort_order in ONE atomic statement (menu_reorder_sections RPC), item order inside a section is preserved byte for byte; only whole sections move. Idempotent (same payload converges to the same numbering). Broadcasts a menu_item_updated realtime event so customer apps re-pull the menu.
Store whose menu sections to reorder (also authorises the merchant).
Every menu section (item category name) in the merchant’s chosen order, first to last. Matching is case- and whitespace-insensitive. Sections not named here (including uncategorised items) keep their current relative order after the named ones, on the menu, never lost.
Her photos and videos, in her order: at most 10 together, at most 3 of them videos. Since 21 Oct 2026 a video may come first; the cover is the entry `primary-image` and `image_url`, always a photo.
imagevideoFalse when she holds her item's photos and videos still on her drop page; true (the default) when they move on by themselves.
FA078. True ONLY when a human ticked "no allergens in this item". An empty contains_allergens with no_allergens=false means NOBODY HAS SAID, it is not a statement that the item is allergen-free, and must never be rendered as one.
The diets the merchant declared, in the canonical spelling: any of "Vegetarian", "Vegan", "Gluten-free", "Dairy-free". A legacy "Dairy Free" reads as "Dairy-free".
`diets` plus what they imply, Vegan also satisfies Vegetarian and Dairy-free. Derived on read, never stored, so what the merchant actually said stays distinguishable. This is what a diet filter reads. Since 12 Oct 2026 it leaves out Gluten-free for an item whose `may_contain_allergens` names Gluten, and Dairy-free (the seller's own tag, or through Vegan) for one that names Dairy: a filter never shows an item as free from something the seller says it may contain. Vegan and Vegetarian are never left out, and `diets` and `dietary_tags` still say what the seller tagged.
`contains_allergens` grouped to the UK fourteen, in the law's order ("tree nuts (almond)" and "Tree Nuts (Pistachio)" are both "Tree Nuts"). Allergens MARK a dish on a menu, never hide it.
What the item MAY contain (traces, e.g. made beside nuts): the UK fourteen the seller ticked under "May contain", in the law's order and spelling ("Tree Nuts", "Peanuts"). Empty means she has said nothing may be present, never "none". Not an answer to what it contains (`contains_allergens` / `no_allergens` are). Absent on a payload from before 8 Oct 2026.
The seller's "Anything else" about allergens (one outside the fourteen, say), in her own words, at most 200 characters. Null when she wrote nothing. Show it as written.
This dish's OWN serving times (service period ids). Empty when it follows its section's serving times, see `service_periods_inherited`.
True when the dish has no serving times of its own and follows its section (or, when the section has none either, is served all day).
False for an item that is not food (clothes, flowers): it needs no ingredients and no allergen answer, and `ingredients` holds what it is made of. True or absent: food.
Whether this row is a dish or a SUPPLEMENT, an add-on line off the printed menu ("Flavoured Syrup 50p") that nobody orders on its own. A supplement must never set a section’s "from" price or be counted in its dish tally: Santi’s "Breakfast Hot Drinks from £0.50" was a syrup shot against a cheapest real drink of £3.00. A row from before the column existed reads as a dish, deliberately, defaulting the other way silently drops unclassified rows out of both.
dishsupplementThe printed menu’s chilli marker, 1-3. Null when the dish has no heat.
What this photograph owes the person who took it, or null when it owes nothing. Non-null for every attribution-required licence (CC BY, CC BY-SA), null for the merchant’s own picture and for CC0 / public domain. Rendering it is a CONDITION OF USE of the licence, not a courtesy, see src/lib/menu/photoCredit.ts.
The photographer, or null when the source names nobody.
The licence short name, e.g. "CC BY-SA 3.0".
The licence deed, or null when it cannot be linked.
The file page the picture came from, so the credit is checkable.
e.g. "Wikimedia Commons".
The work’s title, e.g. "File:Red Wine Glass.jpg".
When the row last changed, exactly as the database wrote it (microseconds kept). Send it back UNTOUCHED as `expected_updated_at` on an update to have it refused (409 ITEM_CHANGED) if the item changed since it was read. Round it through a date type and every save is refused.
Who gave the allergen answer: "merchant" or "merchant_confirmed" when the seller said it herself; anything else (a setup script, an import, null for not recorded) is not her word, and an editor shows the question as unanswered.
`digital` on a listed download. Absent on a menu item, which is physical.
physicaldigitalA listed download’s ready files. A download with none cannot be sold. Absent on a menu item.
A listed download’s own answer: it has a name, a cover photo and one ready file. Absent on a menu item.
Her sizes and colours (`ItemChoices`), on an item that has them. Absent on an item that has none.
curl -X PUT "https://www.membber.com/api/v1/menu-items/section-order" \
-H "Authorization: Bearer $MEMBBER_TOKEN" \
-H "Idempotency-Key: 1f0e2d3c-4b5a-4678-9abc-def012345678" \
-H "Content-Type: application/json" \
-d '{
"store_id": "6659c139-0000-4000-8000-d0c500000066",
"sections": [
"<section>"
]
}'import { createMembberClient } from "@membber/sdk-ts";
const membber = createMembberClient({
getAccessToken: () => process.env.MEMBBER_TOKEN,
});
const { data, error } = await membber.raw.PUT("/api/v1/menu-items/section-order", {
body: {
store_id: "6659c139-0000-4000-8000-d0c500000066",
sections: [
"<section>"
]
},
headers: { "Idempotency-Key": crypto.randomUUID() },
});
if (error) {
// Typed error envelope: { error: { code, message, requestId } }
throw new Error(`${error.error.code}: ${error.error.message}`);
}
console.log(data);import MembberSwift
let client = MembberClient(
serverURL: MembberClient.productionServerURL,
tokenProvider: { session.accessToken }
)
let response = try await client.api.setMenuSectionOrder(
body: .json(.init(
storeId: "6659c139-0000-4000-8000-d0c500000066",
sections: ["<section>"]
))
).ok.body.json
print(response){
"items": [
{
"id": "00000d1b-0000-4000-8000-d0c500000000",
"name": "Example name",
"description": "Added at the front desk",
"category": "<category>",
"emoji": "<emoji>",
"image_url": "https://example.com/image.jpg",
"product_media": [
{
"id": "00000d1b-0000-4000-8000-d0c500000000",
"type": "image",
"url": "https://example.com/image.jpg",
"thumbnail_url": "https://example.com/image.jpg",
"sort_order": 1
}
],
"media_auto_advance": true,
"contains_allergens": [
"<contains_allergen>"
],
"no_allergens": true,
"dietary_tags": [
"<dietary_tag>"
],
"diets": [
"<diet>"
],
"satisfies_diets": [
"<satisfies_diet>"
],
"allergen_groups": [
"<allergen_group>"
],
"may_contain_allergens": [
"<may_contain_allergen>"
],
"allergen_note": "Added at the front desk",
"service_period_ids": [
"af7d3b4f-0000-4000-8000-d0c5000000af"
],
"service_periods_inherited": true,
"ingredients": "<ingredients>",
"is_food": true,
"nutrition_summary": "<nutrition_summary>",
"is_popular": true,
"is_available": true,
"checkout_enabled": true,
"stock_tracking_enabled": true,
"stock_quantity": 1,
"stock_remaining": 1,
"sold_out": true,
"collect_eligible": true,
"available_now": true,
"collect_prep_minutes": 1,
"price_pence": 1500,
"currency": "GBP",
"sort_order": 1,
"item_kind": "dish",
"heat_level": 1,
"photo_credit": {
"author": "<author>",
"licence": "<licence>",
"licence_url": "https://example.com/image.jpg",
"source_url": "https://example.com/image.jpg",
"library": "<library>",
"file": "<file>"
},
"updated_at": "<updated_at>",
"allergens_source": "<allergens_source>",
"fulfilment_kind": "physical",
"digital_ready_asset_count": 1,
"is_complete": true,
"choices": {
"size_name": "<size_name>",
"colour_name": "<colour_name>",
"sizes": [
{
"label": "<label>"
}
],
"colours": [
{
"label": "<label>",
"photo_url": "https://example.com/image.jpg"
}
]
}
}
]
}Merchant removes one of her sections, a ready-made one included. No item is deleted: every item in it stays on her menu in no section (category "Menu"), in its order, at the end of the menu. The section’s serving-time links go with it. One transaction (menu_remove_section RPC) with its audit row. Idempotent: a resend answers `changed: 0`. store_id and section are query parameters. Broadcasts a menu_item_updated realtime event.
Store whose menu it is (also authorises the merchant).
The section to remove (matched without regard to case or outer spaces).
Her photos and videos, in her order: at most 10 together, at most 3 of them videos. Since 21 Oct 2026 a video may come first; the cover is the entry `primary-image` and `image_url`, always a photo.
imagevideoFalse when she holds her item's photos and videos still on her drop page; true (the default) when they move on by themselves.
FA078. True ONLY when a human ticked "no allergens in this item". An empty contains_allergens with no_allergens=false means NOBODY HAS SAID, it is not a statement that the item is allergen-free, and must never be rendered as one.
The diets the merchant declared, in the canonical spelling: any of "Vegetarian", "Vegan", "Gluten-free", "Dairy-free". A legacy "Dairy Free" reads as "Dairy-free".
`diets` plus what they imply, Vegan also satisfies Vegetarian and Dairy-free. Derived on read, never stored, so what the merchant actually said stays distinguishable. This is what a diet filter reads. Since 12 Oct 2026 it leaves out Gluten-free for an item whose `may_contain_allergens` names Gluten, and Dairy-free (the seller's own tag, or through Vegan) for one that names Dairy: a filter never shows an item as free from something the seller says it may contain. Vegan and Vegetarian are never left out, and `diets` and `dietary_tags` still say what the seller tagged.
`contains_allergens` grouped to the UK fourteen, in the law's order ("tree nuts (almond)" and "Tree Nuts (Pistachio)" are both "Tree Nuts"). Allergens MARK a dish on a menu, never hide it.
What the item MAY contain (traces, e.g. made beside nuts): the UK fourteen the seller ticked under "May contain", in the law's order and spelling ("Tree Nuts", "Peanuts"). Empty means she has said nothing may be present, never "none". Not an answer to what it contains (`contains_allergens` / `no_allergens` are). Absent on a payload from before 8 Oct 2026.
The seller's "Anything else" about allergens (one outside the fourteen, say), in her own words, at most 200 characters. Null when she wrote nothing. Show it as written.
This dish's OWN serving times (service period ids). Empty when it follows its section's serving times, see `service_periods_inherited`.
True when the dish has no serving times of its own and follows its section (or, when the section has none either, is served all day).
False for an item that is not food (clothes, flowers): it needs no ingredients and no allergen answer, and `ingredients` holds what it is made of. True or absent: food.
Whether this row is a dish or a SUPPLEMENT, an add-on line off the printed menu ("Flavoured Syrup 50p") that nobody orders on its own. A supplement must never set a section’s "from" price or be counted in its dish tally: Santi’s "Breakfast Hot Drinks from £0.50" was a syrup shot against a cheapest real drink of £3.00. A row from before the column existed reads as a dish, deliberately, defaulting the other way silently drops unclassified rows out of both.
dishsupplementThe printed menu’s chilli marker, 1-3. Null when the dish has no heat.
What this photograph owes the person who took it, or null when it owes nothing. Non-null for every attribution-required licence (CC BY, CC BY-SA), null for the merchant’s own picture and for CC0 / public domain. Rendering it is a CONDITION OF USE of the licence, not a courtesy, see src/lib/menu/photoCredit.ts.
The photographer, or null when the source names nobody.
The licence short name, e.g. "CC BY-SA 3.0".
The licence deed, or null when it cannot be linked.
The file page the picture came from, so the credit is checkable.
e.g. "Wikimedia Commons".
The work’s title, e.g. "File:Red Wine Glass.jpg".
When the row last changed, exactly as the database wrote it (microseconds kept). Send it back UNTOUCHED as `expected_updated_at` on an update to have it refused (409 ITEM_CHANGED) if the item changed since it was read. Round it through a date type and every save is refused.
Who gave the allergen answer: "merchant" or "merchant_confirmed" when the seller said it herself; anything else (a setup script, an import, null for not recorded) is not her word, and an editor shows the question as unanswered.
`digital` on a listed download. Absent on a menu item, which is physical.
physicaldigitalA listed download’s ready files. A download with none cannot be sold. Absent on a menu item.
A listed download’s own answer: it has a name, a cover photo and one ready file. Absent on a menu item.
Her sizes and colours (`ItemChoices`), on an item that has them. Absent on an item that has none.
How many items the write moved or renamed (0 when it had already landed).
curl -X DELETE "https://www.membber.com/api/v1/menu-items/sections?store_id=6659c139-0000-4000-8000-d0c500000066§ion=%3Csection%3E" \
-H "Authorization: Bearer $MEMBBER_TOKEN" \
-H "Idempotency-Key: 1f0e2d3c-4b5a-4678-9abc-def012345678"import { createMembberClient } from "@membber/sdk-ts";
const membber = createMembberClient({
getAccessToken: () => process.env.MEMBBER_TOKEN,
});
const { data, error } = await membber.raw.DELETE("/api/v1/menu-items/sections", {
params: { query: { store_id: "6659c139-0000-4000-8000-d0c500000066", section: "<section>" } },
headers: { "Idempotency-Key": crypto.randomUUID() },
});
if (error) {
// Typed error envelope: { error: { code, message, requestId } }
throw new Error(`${error.error.code}: ${error.error.message}`);
}
console.log(data);import MembberSwift
let client = MembberClient(
serverURL: MembberClient.productionServerURL,
tokenProvider: { session.accessToken }
)
let response = try await client.api.removeMenuSection(
query: .init(storeId: "6659c139-0000-4000-8000-d0c500000066", section: "<section>")
).ok.body.json
print(response){
"items": [
{
"id": "00000d1b-0000-4000-8000-d0c500000000",
"name": "Example name",
"description": "Added at the front desk",
"category": "<category>",
"emoji": "<emoji>",
"image_url": "https://example.com/image.jpg",
"product_media": [
{
"id": "00000d1b-0000-4000-8000-d0c500000000",
"type": "image",
"url": "https://example.com/image.jpg",
"thumbnail_url": "https://example.com/image.jpg",
"sort_order": 1
}
],
"media_auto_advance": true,
"contains_allergens": [
"<contains_allergen>"
],
"no_allergens": true,
"dietary_tags": [
"<dietary_tag>"
],
"diets": [
"<diet>"
],
"satisfies_diets": [
"<satisfies_diet>"
],
"allergen_groups": [
"<allergen_group>"
],
"may_contain_allergens": [
"<may_contain_allergen>"
],
"allergen_note": "Added at the front desk",
"service_period_ids": [
"af7d3b4f-0000-4000-8000-d0c5000000af"
],
"service_periods_inherited": true,
"ingredients": "<ingredients>",
"is_food": true,
"nutrition_summary": "<nutrition_summary>",
"is_popular": true,
"is_available": true,
"checkout_enabled": true,
"stock_tracking_enabled": true,
"stock_quantity": 1,
"stock_remaining": 1,
"sold_out": true,
"collect_eligible": true,
"available_now": true,
"collect_prep_minutes": 1,
"price_pence": 1500,
"currency": "GBP",
"sort_order": 1,
"item_kind": "dish",
"heat_level": 1,
"photo_credit": {
"author": "<author>",
"licence": "<licence>",
"licence_url": "https://example.com/image.jpg",
"source_url": "https://example.com/image.jpg",
"library": "<library>",
"file": "<file>"
},
"updated_at": "<updated_at>",
"allergens_source": "<allergens_source>",
"fulfilment_kind": "physical",
"digital_ready_asset_count": 1,
"is_complete": true,
"choices": {
"size_name": "<size_name>",
"colour_name": "<colour_name>",
"sizes": [
{
"label": "<label>"
}
],
"colours": [
{
"label": "<label>",
"photo_url": "https://example.com/image.jpg"
}
]
}
}
],
"changed": -9007199254740991
}Merchant renames one of her sections, a ready-made one included: every item in it takes the new name in ONE transaction (menu_rename_section RPC) with its audit row, and its serving-time links follow it. Nothing moves. A name another of her sections has is refused (409 SECTION_EXISTS: sections are never merged silently); a section she does not have is 404 SECTION_NOT_FOUND. Idempotent: a resend of a rename that landed answers `changed: 0`. Broadcasts a menu_item_updated realtime event.
Store whose menu it is (also authorises the merchant).
The section as it is now (matched without regard to case or outer spaces).
Its new name.
Her photos and videos, in her order: at most 10 together, at most 3 of them videos. Since 21 Oct 2026 a video may come first; the cover is the entry `primary-image` and `image_url`, always a photo.
imagevideoFalse when she holds her item's photos and videos still on her drop page; true (the default) when they move on by themselves.
FA078. True ONLY when a human ticked "no allergens in this item". An empty contains_allergens with no_allergens=false means NOBODY HAS SAID, it is not a statement that the item is allergen-free, and must never be rendered as one.
The diets the merchant declared, in the canonical spelling: any of "Vegetarian", "Vegan", "Gluten-free", "Dairy-free". A legacy "Dairy Free" reads as "Dairy-free".
`diets` plus what they imply, Vegan also satisfies Vegetarian and Dairy-free. Derived on read, never stored, so what the merchant actually said stays distinguishable. This is what a diet filter reads. Since 12 Oct 2026 it leaves out Gluten-free for an item whose `may_contain_allergens` names Gluten, and Dairy-free (the seller's own tag, or through Vegan) for one that names Dairy: a filter never shows an item as free from something the seller says it may contain. Vegan and Vegetarian are never left out, and `diets` and `dietary_tags` still say what the seller tagged.
`contains_allergens` grouped to the UK fourteen, in the law's order ("tree nuts (almond)" and "Tree Nuts (Pistachio)" are both "Tree Nuts"). Allergens MARK a dish on a menu, never hide it.
What the item MAY contain (traces, e.g. made beside nuts): the UK fourteen the seller ticked under "May contain", in the law's order and spelling ("Tree Nuts", "Peanuts"). Empty means she has said nothing may be present, never "none". Not an answer to what it contains (`contains_allergens` / `no_allergens` are). Absent on a payload from before 8 Oct 2026.
The seller's "Anything else" about allergens (one outside the fourteen, say), in her own words, at most 200 characters. Null when she wrote nothing. Show it as written.
This dish's OWN serving times (service period ids). Empty when it follows its section's serving times, see `service_periods_inherited`.
True when the dish has no serving times of its own and follows its section (or, when the section has none either, is served all day).
False for an item that is not food (clothes, flowers): it needs no ingredients and no allergen answer, and `ingredients` holds what it is made of. True or absent: food.
Whether this row is a dish or a SUPPLEMENT, an add-on line off the printed menu ("Flavoured Syrup 50p") that nobody orders on its own. A supplement must never set a section’s "from" price or be counted in its dish tally: Santi’s "Breakfast Hot Drinks from £0.50" was a syrup shot against a cheapest real drink of £3.00. A row from before the column existed reads as a dish, deliberately, defaulting the other way silently drops unclassified rows out of both.
dishsupplementThe printed menu’s chilli marker, 1-3. Null when the dish has no heat.
What this photograph owes the person who took it, or null when it owes nothing. Non-null for every attribution-required licence (CC BY, CC BY-SA), null for the merchant’s own picture and for CC0 / public domain. Rendering it is a CONDITION OF USE of the licence, not a courtesy, see src/lib/menu/photoCredit.ts.
The photographer, or null when the source names nobody.
The licence short name, e.g. "CC BY-SA 3.0".
The licence deed, or null when it cannot be linked.
The file page the picture came from, so the credit is checkable.
e.g. "Wikimedia Commons".
The work’s title, e.g. "File:Red Wine Glass.jpg".
When the row last changed, exactly as the database wrote it (microseconds kept). Send it back UNTOUCHED as `expected_updated_at` on an update to have it refused (409 ITEM_CHANGED) if the item changed since it was read. Round it through a date type and every save is refused.
Who gave the allergen answer: "merchant" or "merchant_confirmed" when the seller said it herself; anything else (a setup script, an import, null for not recorded) is not her word, and an editor shows the question as unanswered.
`digital` on a listed download. Absent on a menu item, which is physical.
physicaldigitalA listed download’s ready files. A download with none cannot be sold. Absent on a menu item.
A listed download’s own answer: it has a name, a cover photo and one ready file. Absent on a menu item.
Her sizes and colours (`ItemChoices`), on an item that has them. Absent on an item that has none.
How many items the write moved or renamed (0 when it had already landed).
curl -X PATCH "https://www.membber.com/api/v1/menu-items/sections" \
-H "Authorization: Bearer $MEMBBER_TOKEN" \
-H "Idempotency-Key: 1f0e2d3c-4b5a-4678-9abc-def012345678" \
-H "Content-Type: application/json" \
-d '{
"store_id": "6659c139-0000-4000-8000-d0c500000066",
"from": "<from>",
"to": "<to>"
}'import { createMembberClient } from "@membber/sdk-ts";
const membber = createMembberClient({
getAccessToken: () => process.env.MEMBBER_TOKEN,
});
const { data, error } = await membber.raw.PATCH("/api/v1/menu-items/sections", {
body: {
store_id: "6659c139-0000-4000-8000-d0c500000066",
from: "<from>",
to: "<to>"
},
headers: { "Idempotency-Key": crypto.randomUUID() },
});
if (error) {
// Typed error envelope: { error: { code, message, requestId } }
throw new Error(`${error.error.code}: ${error.error.message}`);
}
console.log(data);import MembberSwift
let client = MembberClient(
serverURL: MembberClient.productionServerURL,
tokenProvider: { session.accessToken }
)
let response = try await client.api.renameMenuSection(
body: .json(.init(
storeId: "6659c139-0000-4000-8000-d0c500000066",
from: "<from>",
to: "<to>"
))
).ok.body.json
print(response){
"items": [
{
"id": "00000d1b-0000-4000-8000-d0c500000000",
"name": "Example name",
"description": "Added at the front desk",
"category": "<category>",
"emoji": "<emoji>",
"image_url": "https://example.com/image.jpg",
"product_media": [
{
"id": "00000d1b-0000-4000-8000-d0c500000000",
"type": "image",
"url": "https://example.com/image.jpg",
"thumbnail_url": "https://example.com/image.jpg",
"sort_order": 1
}
],
"media_auto_advance": true,
"contains_allergens": [
"<contains_allergen>"
],
"no_allergens": true,
"dietary_tags": [
"<dietary_tag>"
],
"diets": [
"<diet>"
],
"satisfies_diets": [
"<satisfies_diet>"
],
"allergen_groups": [
"<allergen_group>"
],
"may_contain_allergens": [
"<may_contain_allergen>"
],
"allergen_note": "Added at the front desk",
"service_period_ids": [
"af7d3b4f-0000-4000-8000-d0c5000000af"
],
"service_periods_inherited": true,
"ingredients": "<ingredients>",
"is_food": true,
"nutrition_summary": "<nutrition_summary>",
"is_popular": true,
"is_available": true,
"checkout_enabled": true,
"stock_tracking_enabled": true,
"stock_quantity": 1,
"stock_remaining": 1,
"sold_out": true,
"collect_eligible": true,
"available_now": true,
"collect_prep_minutes": 1,
"price_pence": 1500,
"currency": "GBP",
"sort_order": 1,
"item_kind": "dish",
"heat_level": 1,
"photo_credit": {
"author": "<author>",
"licence": "<licence>",
"licence_url": "https://example.com/image.jpg",
"source_url": "https://example.com/image.jpg",
"library": "<library>",
"file": "<file>"
},
"updated_at": "<updated_at>",
"allergens_source": "<allergens_source>",
"fulfilment_kind": "physical",
"digital_ready_asset_count": 1,
"is_complete": true,
"choices": {
"size_name": "<size_name>",
"colour_name": "<colour_name>",
"sizes": [
{
"label": "<label>"
}
],
"colours": [
{
"label": "<label>",
"photo_url": "https://example.com/image.jpg"
}
]
}
}
],
"changed": -9007199254740991
}Merchant puts the items she chose into one section. A section is the items’ category, so a new one exists once it holds an item. Each item goes to the END of the section in the order the items sat, so her sections keep their order and a new section closes the menu. All or none, one transaction (menu_move_items_to_section RPC) with its audit row. Items already in it stay where they are. Idempotent (a resend changes nothing). Broadcasts a menu_item_updated realtime event.
Store whose menu it is (also authorises the merchant).
The section the items go into: a new one, or one she has (matched without regard to case; it keeps her spelling).
The items that go into it. Every one must be a live item of hers, or nothing is written (404 MENU_ITEM_NOT_FOUND).
Her photos and videos, in her order: at most 10 together, at most 3 of them videos. Since 21 Oct 2026 a video may come first; the cover is the entry `primary-image` and `image_url`, always a photo.
imagevideoFalse when she holds her item's photos and videos still on her drop page; true (the default) when they move on by themselves.
FA078. True ONLY when a human ticked "no allergens in this item". An empty contains_allergens with no_allergens=false means NOBODY HAS SAID, it is not a statement that the item is allergen-free, and must never be rendered as one.
The diets the merchant declared, in the canonical spelling: any of "Vegetarian", "Vegan", "Gluten-free", "Dairy-free". A legacy "Dairy Free" reads as "Dairy-free".
`diets` plus what they imply, Vegan also satisfies Vegetarian and Dairy-free. Derived on read, never stored, so what the merchant actually said stays distinguishable. This is what a diet filter reads. Since 12 Oct 2026 it leaves out Gluten-free for an item whose `may_contain_allergens` names Gluten, and Dairy-free (the seller's own tag, or through Vegan) for one that names Dairy: a filter never shows an item as free from something the seller says it may contain. Vegan and Vegetarian are never left out, and `diets` and `dietary_tags` still say what the seller tagged.
`contains_allergens` grouped to the UK fourteen, in the law's order ("tree nuts (almond)" and "Tree Nuts (Pistachio)" are both "Tree Nuts"). Allergens MARK a dish on a menu, never hide it.
What the item MAY contain (traces, e.g. made beside nuts): the UK fourteen the seller ticked under "May contain", in the law's order and spelling ("Tree Nuts", "Peanuts"). Empty means she has said nothing may be present, never "none". Not an answer to what it contains (`contains_allergens` / `no_allergens` are). Absent on a payload from before 8 Oct 2026.
The seller's "Anything else" about allergens (one outside the fourteen, say), in her own words, at most 200 characters. Null when she wrote nothing. Show it as written.
This dish's OWN serving times (service period ids). Empty when it follows its section's serving times, see `service_periods_inherited`.
True when the dish has no serving times of its own and follows its section (or, when the section has none either, is served all day).
False for an item that is not food (clothes, flowers): it needs no ingredients and no allergen answer, and `ingredients` holds what it is made of. True or absent: food.
Whether this row is a dish or a SUPPLEMENT, an add-on line off the printed menu ("Flavoured Syrup 50p") that nobody orders on its own. A supplement must never set a section’s "from" price or be counted in its dish tally: Santi’s "Breakfast Hot Drinks from £0.50" was a syrup shot against a cheapest real drink of £3.00. A row from before the column existed reads as a dish, deliberately, defaulting the other way silently drops unclassified rows out of both.
dishsupplementThe printed menu’s chilli marker, 1-3. Null when the dish has no heat.
What this photograph owes the person who took it, or null when it owes nothing. Non-null for every attribution-required licence (CC BY, CC BY-SA), null for the merchant’s own picture and for CC0 / public domain. Rendering it is a CONDITION OF USE of the licence, not a courtesy, see src/lib/menu/photoCredit.ts.
The photographer, or null when the source names nobody.
The licence short name, e.g. "CC BY-SA 3.0".
The licence deed, or null when it cannot be linked.
The file page the picture came from, so the credit is checkable.
e.g. "Wikimedia Commons".
The work’s title, e.g. "File:Red Wine Glass.jpg".
When the row last changed, exactly as the database wrote it (microseconds kept). Send it back UNTOUCHED as `expected_updated_at` on an update to have it refused (409 ITEM_CHANGED) if the item changed since it was read. Round it through a date type and every save is refused.
Who gave the allergen answer: "merchant" or "merchant_confirmed" when the seller said it herself; anything else (a setup script, an import, null for not recorded) is not her word, and an editor shows the question as unanswered.
`digital` on a listed download. Absent on a menu item, which is physical.
physicaldigitalA listed download’s ready files. A download with none cannot be sold. Absent on a menu item.
A listed download’s own answer: it has a name, a cover photo and one ready file. Absent on a menu item.
Her sizes and colours (`ItemChoices`), on an item that has them. Absent on an item that has none.
How many items the write moved or renamed (0 when it had already landed).
curl -X POST "https://www.membber.com/api/v1/menu-items/sections" \
-H "Authorization: Bearer $MEMBBER_TOKEN" \
-H "Idempotency-Key: 1f0e2d3c-4b5a-4678-9abc-def012345678" \
-H "Content-Type: application/json" \
-d '{
"store_id": "6659c139-0000-4000-8000-d0c500000066",
"name": "Example name",
"item_ids": [
"7e22b9c7-0000-4000-8000-d0c50000007e"
]
}'import { createMembberClient } from "@membber/sdk-ts";
const membber = createMembberClient({
getAccessToken: () => process.env.MEMBBER_TOKEN,
});
const { data, error } = await membber.raw.POST("/api/v1/menu-items/sections", {
body: {
store_id: "6659c139-0000-4000-8000-d0c500000066",
name: "Example name",
item_ids: [
"7e22b9c7-0000-4000-8000-d0c50000007e"
]
},
headers: { "Idempotency-Key": crypto.randomUUID() },
});
if (error) {
// Typed error envelope: { error: { code, message, requestId } }
throw new Error(`${error.error.code}: ${error.error.message}`);
}
console.log(data);import MembberSwift
let client = MembberClient(
serverURL: MembberClient.productionServerURL,
tokenProvider: { session.accessToken }
)
let response = try await client.api.addMenuSection(
body: .json(.init(
storeId: "6659c139-0000-4000-8000-d0c500000066",
name: "Example name",
itemIds: ["7e22b9c7-0000-4000-8000-d0c50000007e"]
))
).ok.body.json
print(response){
"items": [
{
"id": "00000d1b-0000-4000-8000-d0c500000000",
"name": "Example name",
"description": "Added at the front desk",
"category": "<category>",
"emoji": "<emoji>",
"image_url": "https://example.com/image.jpg",
"product_media": [
{
"id": "00000d1b-0000-4000-8000-d0c500000000",
"type": "image",
"url": "https://example.com/image.jpg",
"thumbnail_url": "https://example.com/image.jpg",
"sort_order": 1
}
],
"media_auto_advance": true,
"contains_allergens": [
"<contains_allergen>"
],
"no_allergens": true,
"dietary_tags": [
"<dietary_tag>"
],
"diets": [
"<diet>"
],
"satisfies_diets": [
"<satisfies_diet>"
],
"allergen_groups": [
"<allergen_group>"
],
"may_contain_allergens": [
"<may_contain_allergen>"
],
"allergen_note": "Added at the front desk",
"service_period_ids": [
"af7d3b4f-0000-4000-8000-d0c5000000af"
],
"service_periods_inherited": true,
"ingredients": "<ingredients>",
"is_food": true,
"nutrition_summary": "<nutrition_summary>",
"is_popular": true,
"is_available": true,
"checkout_enabled": true,
"stock_tracking_enabled": true,
"stock_quantity": 1,
"stock_remaining": 1,
"sold_out": true,
"collect_eligible": true,
"available_now": true,
"collect_prep_minutes": 1,
"price_pence": 1500,
"currency": "GBP",
"sort_order": 1,
"item_kind": "dish",
"heat_level": 1,
"photo_credit": {
"author": "<author>",
"licence": "<licence>",
"licence_url": "https://example.com/image.jpg",
"source_url": "https://example.com/image.jpg",
"library": "<library>",
"file": "<file>"
},
"updated_at": "<updated_at>",
"allergens_source": "<allergens_source>",
"fulfilment_kind": "physical",
"digital_ready_asset_count": 1,
"is_complete": true,
"choices": {
"size_name": "<size_name>",
"colour_name": "<colour_name>",
"sizes": [
{
"label": "<label>"
}
],
"colours": [
{
"label": "<label>",
"photo_url": "https://example.com/image.jpg"
}
]
}
}
],
"changed": -9007199254740991
}The server chooses where the video goes (the public `store-videos` bucket, a name of its own that is never written over) and returns a one-use signed upload target, so the bytes go straight to storage and never ride through a request body here (a function body is capped at 4.5 MB). Then send the returned `url` in the item's `product_media` as `{ type: "video" }` through the item write. MP4 or QuickTime, up to 15 MiB; an item keeps at most 10 photos and videos together and takes one video (a second is 409 ITEM_VIDEO_LIMIT on the item write). `menu_item_id`, when sent, must be one of this shop's items (404 ITEM_NOT_FOUND otherwise). With the video space rule on, three more refusals, each with nothing uploaded and no target handed out: 409 VIDEO_SPACE_FULL when the shop has no room for it ("Your video space is full.", and "Your video space is full. We've asked for more." once the request for more has gone; `details.used_bytes`, `details.allowance_bytes`, `details.asked`); 429 RATE_LIMITED at five uploads started and not finished ("Too many videos started. Try again in a little while."); 503 DOWNSTREAM_UNAVAILABLE when the count cannot be read ("Try again in a moment.").
The item the video is for, when it is already on her menu (its id goes in the file name). Omit for an item not saved yet.
MP4, or QuickTime (`video/quicktime`, a `.mov`). The file is stored as the kind it is: its name ends `.mp4` or `.mov` to match.
video/mp4video/quicktimeThe size the client is about to send, at most 15 MiB. Storage refuses anything bigger whatever is declared.
PUT the bytes here with the `Content-Type` the upload was started with. Never writes over another file.
The video's public address once its bytes are up: what goes in the item's `product_media`.
curl -X POST "https://www.membber.com/api/v1/menu-items/videos" \
-H "Authorization: Bearer $MEMBBER_TOKEN" \
-H "Idempotency-Key: 1f0e2d3c-4b5a-4678-9abc-def012345678" \
-H "Content-Type: application/json" \
-d '{
"content_type": "video/mp4",
"byte_size": 1
}'import { createMembberClient } from "@membber/sdk-ts";
const membber = createMembberClient({
getAccessToken: () => process.env.MEMBBER_TOKEN,
});
const { data, error } = await membber.raw.POST("/api/v1/menu-items/videos", {
body: {
content_type: "video/mp4",
byte_size: 1
},
headers: { "Idempotency-Key": crypto.randomUUID() },
});
if (error) {
// Typed error envelope: { error: { code, message, requestId } }
throw new Error(`${error.error.code}: ${error.error.message}`);
}
console.log(data);import MembberSwift
let client = MembberClient(
serverURL: MembberClient.productionServerURL,
tokenProvider: { session.accessToken }
)
let response = try await client.api.startMenuItemVideoUpload(
body: .json(.init(
contentType: .video/mp4,
byteSize: 1
))
).ok.body.json
print(response){
"upload": {
"path": "<path>",
"token": "<token>",
"signed_url": "https://example.com/image.jpg"
},
"url": "https://example.com/image.jpg",
"max_bytes": -9007199254740991
}