Sync menu

http
POST /v1/sync

Upserts categories, ingredients and products (with their modifier groups) in one request. Every item is matched by your externalId: unknown ids are created, known ids are updated, unchanged items are skipped. Repeating the same request is safe (idempotent).

Sections are processed in order categories → ingredients → products inside a single database transaction, so products can reference categories and ingredients created earlier in the same request.

Sync updates the draft menu. Guests see changes only after POST /v1/publish.

References that don't resolve become warnings

A product referencing an unknown categoryExternalId or ingredientExternalId is still saved — the bad reference is skipped and reported in warnings, not treated as an error. Duplicate externalIds within one request also produce a warning (the last occurrence wins).

Request body#

All three arrays are optional — send any subset. Per-request caps: 200 categories, 200 ingredients, 500 products (a larger array is a 400 validation error; bodies over 5 MB are rejected). Per product: ≤ 20 modifier groups (≤ 50 options each) and ≤ 100 ingredient references.

Chunk large catalogs — hard rule

Keep each sync at ≤ 200 products. The hard cap is 500, but a maxed-out payload (products with many modifier groups) writes thousands of rows in one transaction and can exceed the 30-second window — the sync then fails with 413 SYNC_TOO_LARGE and nothing is written. On SYNC_TOO_LARGE: split the payload into smaller batches and retry; syncs are idempotent, so re-sending items that already landed is safe.

An empty body is not an error: it returns success: true with a warning and changes nothing.

categories[]#

FieldTypeRequiredConstraints
externalIdstringYes≤ 255 chars, unique per venue
namestringYes≤ 200 chars
sortOrderintegerNoDefault 0 on create
scheduleobject[]NoCategory availability hours — { day, isOpen, openTime?, closeTime? }, HH:MM
cardStylestring | nullNoCard-design override (see Reference); null = inherit the venue-level style
isActivebooleanNoDeactivated categories are hidden from the menu

ingredients[]#

FieldTypeRequiredConstraints
externalIdstringYes≤ 255 chars, unique per venue
namestringYes≤ 200 chars
sortOrderintegerNoDefault 0 on create

products[]#

FieldTypeRequiredConstraints
externalIdstringYes≤ 255 chars, unique per venue
namestringYes≤ 200 chars
descriptionstringNo≤ 1000 chars
priceMinorintegerYes0–100000000, minor currency units (kopecks/cents)
salePriceMinorinteger | nullNo0–100000000, minor units; explicit null clears the sale price on update
isSaleActivebooleanNoDefault false on create — the sale price shows only while true
amountintegerNo0–100000 — portion size, paired with unit
unitstringNoPCS | G | ML (see Reference)
badgeLabelstring | nullNo≤ 16 chars — badge on the menu card; null clears
badgeColorstring | nullNoRED | PURPLE | YELLOW | GREEN | BLUE | GREY; null clears
categoryExternalIdstring | nullNo≤ 255 chars. Omit = keep the current category; null = detach; unknown id → warning (existing category kept on update; created without category)
ingredientExternalIdsstring[]NoProduct composition — see omitted-vs-empty below
modifierGroupsobject[]NoSee ModifierGroup — omitted-vs-empty applies
sortOrderintegerNoDefault 0 on create
menuVisiblebooleanNoDefault true on create

Omit = keep; explicit values clear

Every optional field treats omitted as "keep the stored value". To clear something, say so explicitly: ingredientExternalIds / modifierGroups — send an empty array [] (non-empty replaces entirely); categoryExternalId, salePriceMinor, badgeLabel, badgeColor — send null (when clearing the sale price, also send isSaleActive: false).

Inline translations — a full multilingual menu in ONE sync#

Every sync entity (categories, ingredients, products, and each modifier group) accepts an optional translations map — locale-keyed field maps:

json
{
  "categories": [
    {
      "externalId": "cat-mains", "name": "Mains",
      "translations": { "uk": { "name": "Основні" }, "de": { "name": "Hauptgerichte" } }
    }
  ],
  "products": [
    {
      "externalId": "prod-margherita", "name": "Margherita", "priceMinor": 24500,
      "translations": { "uk": { "name": "Маргарита", "description": "Томати, моцарела" } },
      "modifierGroups": [
        {
          "name": "Extras", "type": "add_ingredients",
          "options": [{ "ingredientExternalId": "ing-olives" }],
          "translations": { "uk": { "name": "Добавки" } }
        }
      ]
    }
  ]
}
  • Allowed fields per entity match the translation routes: products name/description/badgeLabel; categories, ingredients and modifier groups name.
  • Merge semantics — omitting translations (or a locale, or a field) on a re-sync preserves what's stored; "" clears a field. Translations and content land in the same transaction.
  • Locales must be globally supported AND venue-enabled — errors name the entity (INVALID_LOCALE, LOCALE_NOT_ENABLED, EMPTY_TRANSLATION).
  • The response reports translations: { applied: N } (locale-entries written). Dry runs validate translations too, writing nothing.

The full-menu agent flow is now: 1 PATCH /v1/settings (enabledLanguages) → 1 sync (content + all translations) → 1 publish. For incremental fixes after the build, use the bulk translation write.

Single-product updates — PATCH /v1/products/:ref#

For touching one product, prefer the dedicated partial update over a one-item sync:

http
PATCH /v1/products/:ref
  • Same field set as sync products (minus externalId, ingredientExternalIds and modifierGroups — composition/modifier changes stay on sync, whose replace-semantics they need).
  • Uniform semantics on every field: omitted = keep, null = clear (nullable fields), value = set — no bulk special cases.
  • categoryRef accepts your externalId or the opaque id; null detaches. Unlike sync, an unknown ref is a hard 404 CATEGORY_NOT_FOUND (precise feedback beats a warning for a single-item call).
  • Supports optimistic concurrency: the response carries an ETag; send it back as If-Match and a concurrent change fails with 412 STALE_RESOURCE (same pattern as settings).
  • Changes affect the draft — publish to go live.
bash
curl -X PATCH https://api.duck-hub.com/v1/products/prod-margherita \
  -H "Authorization: Bearer dk_live_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{ "priceMinor": 26500, "badgeLabel": null }'

modifierGroups[]#

FieldTypeRequiredConstraints
namestringYes≤ 200 chars
typestringYessingle_choice | add_ingredients | remove_ingredients; multiple_choice is deprecated — accepted but stored as add_ingredients (a warning is returned)
isRequiredbooleanNoOnly honoured for single_choice (defaults to true there); forced false for every other type
optionsobject[]YesSee below
sortOrderintegerNoDefaults to array position

modifierGroups[].options[]#

FieldTypeRequiredConstraints
ingredientExternalIdstringYes≤ 255 chars; unknown id → option skipped + warning
actionstringNoadd | remove; defaults to remove for remove_ingredients groups, else add
priceAdjustmentintegerNoMinor units, may be negative; default 0
sortOrderintegerNoDefaults to array position

Example#

bash
curl -X POST https://api.duck-hub.com/v1/sync \
  -H "Authorization: Bearer dk_live_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "categories": [
      { "externalId": "cat-pizza", "name": "Pizza", "sortOrder": 1 }
    ],
    "ingredients": [
      { "externalId": "ing-mozzarella", "name": "Mozzarella" },
      { "externalId": "ing-olives", "name": "Olives" }
    ],
    "products": [
      {
        "externalId": "prod-margherita",
        "name": "Margherita",
        "description": "Tomato, mozzarella, basil",
        "priceMinor": 21500,
        "categoryExternalId": "cat-pizza",
        "ingredientExternalIds": ["ing-mozzarella"],
        "modifierGroups": [
          {
            "name": "Extras",
            "type": "add_ingredients",
            "options": [
              { "ingredientExternalId": "ing-olives", "priceAdjustment": 2500 }
            ]
          }
        ]
      }
    ]
  }'

Response#

201 Created. Each section you sent gets its own result block:

json
{
  "success": true,
  "categories": {
    "created": 1, "updated": 0, "skipped": 0, "errors": [], "warnings": []
  },
  "ingredients": {
    "created": 2, "updated": 0, "skipped": 0, "errors": [], "warnings": []
  },
  "products": {
    "created": 1, "updated": 0, "skipped": 0,
    "errors": [],
    "warnings": []
  },
  "warnings": [],
  "syncedAt": "2026-07-05T12:00:00.000Z"
}
FieldMeaning
created / updatedItems inserted / changed
skippedItems identical to the stored version — nothing written
errors[]Per-item failures, e.g. "Product prod-x: <reason>" — other items still processed
warnings[]Non-fatal issues: unresolved references, duplicates
syncedAtServer timestamp of the sync

Top-level warnings carries request-wide notes (duplicate externalIds, empty request). Per-section warnings carries item-scoped notes like "Product prod-x: category 'cat-y' not found, saved without category".

Dry run#

Append ?dryRun=true to validate a sync without writing anything: the full pipeline runs — validation, plan-limit checks, reference resolution, create/update/skip decisions — and you get the same response shape with "dryRun": true and the counts/warnings the real call would produce. Plan-limit and validation failures return the same errors as a real sync. Use it to iterate cheaply before committing a large sync.

bash
curl -X POST "https://api.duck-hub.com/v1/sync?dryRun=true" \
  -H "Authorization: Bearer dk_live_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{ "products": [ ... ] }'

Errors#

  • 400 Validation failed — schema violations, array caps (200/200/500)
  • 400 plan limit exceeded — see Plan limits; checked before writing, counting only new items
  • 401 — see Authentication

Syncing a previously cleaned-up (soft-deleted) product by its externalId restores it.