Sync menu
POST /v1/syncUpserts 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[]#
| Field | Type | Required | Constraints |
|---|---|---|---|
externalId | string | Yes | ≤ 255 chars, unique per venue |
name | string | Yes | ≤ 200 chars |
sortOrder | integer | No | Default 0 on create |
ingredients[]#
| Field | Type | Required | Constraints |
|---|---|---|---|
externalId | string | Yes | ≤ 255 chars, unique per venue |
name | string | Yes | ≤ 200 chars |
sortOrder | integer | No | Default 0 on create |
products[]#
| Field | Type | Required | Constraints |
|---|---|---|---|
externalId | string | Yes | ≤ 255 chars, unique per venue |
name | string | Yes | ≤ 200 chars |
description | string | No | ≤ 1000 chars |
priceMinor | integer | Yes | ≥ 0, minor currency units (kopecks/cents) |
categoryExternalId | string | No | ≤ 255 chars; unknown id → saved without category + warning |
ingredientExternalIds | string[] | No | Product composition — see omitted-vs-empty below |
modifierGroups | object[] | No | See ModifierGroup — omitted-vs-empty applies |
sortOrder | integer | No | Default 0 on create |
menuVisible | boolean | No | Default 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[]#
| Field | Type | Required | Constraints |
|---|---|---|---|
name | string | Yes | ≤ 200 chars |
type | string | Yes | single_choice | multiple_choice | add_ingredients | remove_ingredients |
isRequired | boolean | No | Only honoured for single_choice (defaults to true there); forced false for every other type |
options | object[] | Yes | See below |
sortOrder | integer | No | Defaults to array position |
modifierGroups[].options[]#
| Field | Type | Required | Constraints |
|---|---|---|---|
ingredientExternalId | string | Yes | ≤ 255 chars; unknown id → option skipped + warning |
action | string | No | add | remove; defaults to remove for remove_ingredients groups, else add |
priceAdjustment | integer | No | Minor units, may be negative; default 0 |
sortOrder | integer | No | Defaults to array position |
Example#
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 }
]
}
]
}
]
}'const response = await fetch('https://api.duck-hub.com/v1/sync', {
method: 'POST',
headers: {
Authorization: 'Bearer dk_live_your_api_key',
'Content-Type': 'application/json',
},
body: JSON.stringify({
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 },
],
},
],
},
],
}),
})
const result = await response.json()Response#
201 Created. Each section you sent gets its own result block:
{
"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"
}| Field | Meaning |
|---|---|
created / updated | Items inserted / changed |
skipped | Items 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 |
syncedAt | Server 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)400plan limit exceeded — see Plan limits; checked before writing, counting only new items401— see Authentication
Syncing a previously cleaned-up (soft-deleted) product by its
externalId restores it.