Integration
Reading the menu
Categories, portions, extras and option groups, campaigns, stock, and keeping a copy of the menu current.
The menu is what your site will read most often. This guide covers how a menu is built, where the ids an order needs come from, campaigns and stock, and how to keep a copy of the menu current on your server.
Getting the menu#
GET /venues/{venueId}/menu gives the venue's categories and the products in them: each product's portions and prices, extras, option groups, removable ingredients, allergens and stock status.
curl "https://api.cibusy.com/public/v1/venues/3fa85f64-5717-4562-b3fc-2c963f66afa6/menu?lang=tr" \
-H "X-Api-Key: $CIBUSY_API_KEY"Only what the venue shows its guests is on it. A hidden product or category is never listed, so it can never be ordered either. The menu does not depend on how the venue's QR menu is set up. Prices are in lira, VAT included.
How a menu is built#
data
└── categories[] categories, in the venue's order
└── products[] products → productId
├── portions[] portions and their prices → portionId
├── extras[] extras → extraIds
├── optionGroups[] rules over some of the extras
└── removableIngredients[] ingredients that can be left out → removedIngredientIdsAn order line names a product (productId) in one of its portions (portionId), and may add extras (extraIds) and leave ingredients out (removedIngredientIds). Every one of these ids comes from the menu. Categories and products already come in the order the venue shows them; you do not need to sort them.
Language#
lang is the two-letter code of one of the venue's languages. The venue's languages are in languages, in the venue's details.
- A language the venue does not offer, or no
langat all, gets the menu in the venue's default language. languagein the answer says which language the text is in. It isnullwhen the venue has set up no languages, and the text is then as the venue wrote it.langchooses the menu's language only. TheAccept-Languageheader chooses the language of error messages.
Portions and prices#
A product's price is on its portions. Every portion has a price, and isDefault marks the portion the venue preselects. A product served one way usually has a single portion named "Standard"; show the product's name on its own then.
weight and calories are the weight and energy printed on the portion. They are labels to show beside the portion's name; the price is not worked out from them.
Campaigns#
A portion that a running discount covers carries a campaign, with the price to show:
{
"id": "b9f2a7d4-3e05-4c81-86b7-2d8e0f1a4b35",
"name": "Yarım porsiyon",
"price": 190,
"orderable": true,
"campaign": {
"campaignId": "0c2e4a6b-8d1f-4a3c-9e5b-7d9f1b3d5f70",
"name": "Öğle menüsü",
"badgeText": "%15",
"discountedPrice": 161.5,
"endsAt": "2026-10-01T12:00:00.000Z"
}
}Show discountedPrice beside price. badgeText is a short label for a badge, and endsAt the moment the campaign ends, for a countdown. A campaign is worked out on every call, so a happy hour starts and ends on time.
Only flat discounts on the portion itself appear here. Set menus and offers such as "buy two, pay for one" are applied when a basket is priced, and show in discount when you price the basket.
Sold by weight#
A portion with orderable: false is sold by measure. pricingUnit says what the price is for, such as Kilogram, and what the guest takes is weighed at the venue. You can show such a portion on your menu, but you cannot order it through the API.
Extras and option groups#
extras lists what can be added to a product, with prices. An extra's price is added to the price of the portion it is ordered with; it is zero for a free extra.
Extras whose optionGroupId is set are the choices of an option group. The group sets a rule over those choices:
| Field | Rule |
|---|---|
selectionType: "Single" | At most one choice from the group. |
selectionType: "Multiple" | Any number of choices from the group. |
isRequired: true | The product cannot be ordered without a choice from the group. |
In the sample menu, "Pişirme" (cooking) on the grilled köfte is a required single-choice group: exactly one of "Az pişmiş" (rare) and "İyi pişmiş" (well done) must be chosen. "Ekstra peynir" (extra cheese) belongs to no group and can be added freely.
The ids of the chosen extras go in the order's extraIds, and apply to each unit of the line. A choice that is not one of the product's own extras, is repeated, or breaks a group's rule is refused with 400 PUBLIC_API_PRODUCT_OPTION_INVALID.
Removable ingredients#
removableIngredients are the ingredients a guest may ask to be left out, such as onion. They go in the order's removedIngredientIds, and apply to each unit of the line.
Stock and product details#
stockStatussays whether a product is in stock (InStock), running low (LowStock) or sold out (OutOfStock). It isnullwhen the venue does not track the product's stock; show no badge then. A sold-out product stays on the menu, but an order for it is refused with409PUBLIC_API_PRODUCT_UNAVAILABLE.allergensare the allergens the venue declared for the product (the 14 of the EU regulation). An empty list does not mean the product is free of allergens; it means the venue declared none.containsAlcoholandcontainsPorkDerivativessay whether the product contains alcohol, or pork or something made from it. When either istrue, show it on your menu with a label.acceptsMealCardsays whether the venue accepts a meal card for the product.
Keeping a copy of the menu#
Do not ask for the menu again for every visitor of your site. Keep a copy on your server, and ask whether it is still current before you use it:
- When you read the menu, store the answer's
ETagheader with your copy. - Send that value in the
If-None-Matchheader of the next request. - If the menu has not changed, the answer is a
304 Not Modifiedwith no body, and you keep using your copy. If it has, the new menu comes with a newETag.
const menus = new Map(); // "venueId:lang" → { etag, menu }
async function getMenu(venueId, lang) {
const key = `${venueId}:${lang}`;
const cached = menus.get(key);
const headers = { 'X-Api-Key': process.env.CIBUSY_API_KEY };
if (cached) headers['If-None-Match'] = cached.etag;
const response = await fetch(
`https://api.cibusy.com/public/v1/venues/${venueId}/menu?lang=${lang}`,
{ headers }
);
if (response.status === 304) return cached.menu;
const body = await response.json();
if (!body.success) throw new Error(`${body.errorCode}: ${body.developerMessage}`);
menus.set(key, { etag: response.headers.get('ETag'), menu: body.data });
return body.data;
}- Cibusy keeps a menu for up to five minutes, and refreshes it at once when the venue edits it.
- The
ETagstands fordata, not for the envelope around it: the envelope'stimestampchanges on every call, but theETagstays the same while the menu does. - The answer says
Cache-Control: private, no-cache: keep a copy, but ask whether it is still current before you use it.
Check your copy at intervals rather than for every visitor: at most once a minute, for example. That keeps you far below the rate limit, and a change to the menu still reaches your site within a minute.