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 10 MB are rejected).

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

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
priceMinorintegerYes≥ 0, minor currency units (kopecks/cents)
categoryExternalIdstringNo≤ 255 chars; unknown id → saved without category + warning
ingredientExternalIdsstring[]NoProduct composition — see omitted-vs-empty below
modifierGroupsobject[]NoSee ModifierGroup — omitted-vs-empty applies
sortOrderintegerNoDefault 0 on create
menuVisiblebooleanNoDefault true on create

Omitted vs empty array — different meanings

For ingredientExternalIds and modifierGroups: omit the field to leave the product's existing composition/modifiers untouched; send an empty array [] to clear them; send a non-empty array to replace them entirely. Also note: on updates, omitting categoryExternalId clears the product's category link — always send it if the product should stay in its category.

modifierGroups[]#

FieldTypeRequiredConstraints
namestringYes≤ 200 chars
typestringYessingle_choice | multiple_choice | add_ingredients | remove_ingredients
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".

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.