Get menu
GET /v1/menuReturns the complete current (draft) state of the menu — categories, ingredients and all non-deleted products with their compositions and modifier groups. Use it to verify what a sync produced or to reconcile DuckHub against your POS before a cleanup.
Draft state, not the published snapshot
This endpoint reflects the menu as it is right now, including unpublished
changes and products with menuVisible: false. What guests
see is the latest publication snapshot.
Items are identified by externalId only (no internal ids). Items created
manually in the DuckHub app have externalId: null. Null-valued fields
are omitted from the response (a missing key means null) — this keeps
large menus small.
Localized reads — ?locale=#
By default every menu read returns canonical (default-language)
content. Add ?locale=xx to any read endpoint on this page (and
GET /v1/menu/structure, GET /v1/products, GET /v1/products/:ref,
GET /v1/categories) to get every translatable field resolved to that
locale — translation ?? canonical, exactly like the public menu
renders it. This verifies your translations in one
call:
curl "https://api.duck-hub.com/v1/menu?locale=en" \
-H "Authorization: Bearer dk_live_your_api_key"- The response echoes the applied locale (top-level
"locale": "en") so you know exactly what you received. - Fields without a translation return their canonical value — an honest "not translated yet", not an error.
- An unsupported locale is
400 INVALID_LOCALE— never a silent canonical fallback. - Unknown query parameters are rejected with
400naming the parameter — a typo like?lang=enfails loudly instead of silently returning canonical content.
This is the HEAVY read — cheaper ones below
GET /v1/menu ships everything. For most agent flows the
token-optimized reads below answer the same
questions at a fraction of the payload.
Example#
curl https://api.duck-hub.com/v1/menu \
-H "Authorization: Bearer dk_live_your_api_key"const response = await fetch('https://api.duck-hub.com/v1/menu', {
headers: { Authorization: 'Bearer dk_live_your_api_key' },
})
const menu = await response.json()
const visible = menu.products.filter((p) => p.menuVisible)
console.log(`${visible.length} visible of ${menu.products.length} products`)Response#
200 OK. All lists are sorted by sortOrder; priceMinor and
priceAdjustment are integers in minor currency units:
{
"generatedAt": "2026-07-05T12:00:00.000Z",
"categories": [
{
"externalId": "cat-pizza",
"name": "Pizza",
"sortOrder": 1,
"schedule": null,
"cardStyle": null,
"isActive": true
}
],
"ingredients": [
{ "externalId": "ing-mozzarella", "name": "Mozzarella", "sortOrder": 0 },
{ "externalId": "ing-olives", "name": "Olives", "sortOrder": 0 }
],
"products": [
{
"externalId": "prod-margherita",
"name": "Margherita",
"description": "Tomato, mozzarella, basil",
"priceMinor": 21500,
"salePriceMinor": 15000,
"isSaleActive": true,
"amount": 450,
"unit": "G",
"badgeLabel": "HIT",
"badgeColor": "RED",
"categoryExternalId": "cat-pizza",
"sortOrder": 0,
"menuVisible": true,
"ingredients": [
{ "externalId": "ing-mozzarella", "name": "Mozzarella" }
],
"modifierGroups": [
{
"name": "Extras",
"type": "add_ingredients",
"isRequired": false,
"sortOrder": 0,
"options": [
{
"ingredientExternalId": "ing-olives",
"ingredientName": "Olives",
"action": "add",
"priceAdjustment": 2500,
"sortOrder": 0
}
]
}
]
}
]
}Field-by-field details for every object are in the field reference.
Token-optimized reads#
Sparse alternatives for agents (all GET, every plan). Rows are keyed by
ref — your externalId when set, otherwise the opaque id; either
value works directly in every :ref route.
GET /v1/menu/structure — the ~500-byte menu map#
The cheapest full-menu overview: category and product names + refs, nothing else. Resolve a name→ref in one call instead of pulling the menu.
{
"categories": [
{
"ref": "cat-mains", "name": "Mains", "isActive": true,
"products": [
{ "ref": "prod-margherita", "name": "Margherita" },
{ "ref": "cml9x2...", "name": "Manually created dish" }
]
}
]
}Uncategorised products appear in a final entry with ref: null.
GET /v1/products — filtered, paginated, sparse#
| Query param | Meaning |
|---|---|
category | Category ref — only its products |
search | Name search (case-insensitive, multi-word AND) |
withoutDescription=true | Only products MISSING a description |
includeDescription=true | Add description text to summary rows |
view | summary (default, sparse) | full (single-product shape) |
locale | Localize rows (see above) |
page / limit | Pagination (limit ≤ 50, default 15) |
Summary rows carry flags instead of payloads:
{
"items": [
{
"ref": "prod-margherita", "name": "Margherita", "priceMinor": 24500,
"categoryRef": "cat-mains", "hasDescription": true, "hasImage": false
}
],
"total": 64, "page": 1, "limit": 15, "totalPages": 5
}GET /v1/products/:ref — one product#
The full single-product view (same shape PATCH /v1/products/:ref
returns), null fields omitted. The response carries an ETag — send
it back as If-Match on a later PATCH
for conflict-safe updates. 404 PRODUCT_NOT_FOUND on a miss.
GET /v1/categories — index with live counts#
{
"categories": [
{ "ref": "cat-mains", "name": "Mains", "isActive": true, "productsCount": 12 }
]
}For translation reads see
translations stats & batch; your
usage-vs-limits headroom is in GET /v1/reference
(planLimits.usage).
Errors#
401— see Authentication404PRODUCT_NOT_FOUND/CATEGORY_NOT_FOUND— unknown:ref/category429— see Rate limits