Get menu

http
GET /v1/menu

Returns 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:

bash
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 400 naming the parameter — a typo like ?lang=en fails 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#

bash
curl https://api.duck-hub.com/v1/menu \
  -H "Authorization: Bearer dk_live_your_api_key"

Response#

200 OK. All lists are sorted by sortOrder; priceMinor and priceAdjustment are integers in minor currency units:

json
{
  "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.

json
{
  "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 paramMeaning
categoryCategory ref — only its products
searchName search (case-insensitive, multi-word AND)
withoutDescription=trueOnly products MISSING a description
includeDescription=trueAdd description text to summary rows
viewsummary (default, sparse) | full (single-product shape)
localeLocalize rows (see above)
page / limitPagination (limit ≤ 50, default 15)

Summary rows carry flags instead of payloads:

json
{
  "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#

json
{
  "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#