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 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[]#
| Field | Type | Required | Constraints |
|---|---|---|---|
externalId | string | Yes | ≤ 255 chars, unique per venue |
name | string | Yes | ≤ 200 chars |
sortOrder | integer | No | Default 0 on create |
schedule | object[] | No | Category availability hours — { day, isOpen, openTime?, closeTime? }, HH:MM |
cardStyle | string | null | No | Card-design override (see Reference); null = inherit the venue-level style |
isActive | boolean | No | Deactivated categories are hidden from the menu |
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–100000000, minor currency units (kopecks/cents) |
salePriceMinor | integer | null | No | 0–100000000, minor units; explicit null clears the sale price on update |
isSaleActive | boolean | No | Default false on create — the sale price shows only while true |
amount | integer | No | 0–100000 — portion size, paired with unit |
unit | string | No | PCS | G | ML (see Reference) |
badgeLabel | string | null | No | ≤ 16 chars — badge on the menu card; null clears |
badgeColor | string | null | No | RED | PURPLE | YELLOW | GREEN | BLUE | GREY; null clears |
categoryExternalId | string | null | No | ≤ 255 chars. Omit = keep the current category; null = detach; unknown id → warning (existing category kept on update; created without category) |
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 |
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:
{
"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 groupsname. - 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:
PATCH /v1/products/:ref- Same field set as sync products (minus
externalId,ingredientExternalIdsandmodifierGroups— 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. categoryRefaccepts your externalId or the opaque id;nulldetaches. Unlike sync, an unknown ref is a hard404 CATEGORY_NOT_FOUND(precise feedback beats a warning for a single-item call).- Supports optimistic concurrency: the response carries an
ETag; send it back asIf-Matchand a concurrent change fails with412 STALE_RESOURCE(same pattern as settings). - Changes affect the draft — publish to go live.
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 }'const response = await fetch(
'https://api.duck-hub.com/v1/products/prod-margherita',
{
method: 'PATCH',
headers: {
Authorization: 'Bearer dk_live_your_api_key',
'Content-Type': 'application/json',
},
body: JSON.stringify({ priceMinor: 26500, badgeLabel: null }),
},
)
const product = await response.json() // updated product viewmodifierGroups[]#
| Field | Type | Required | Constraints |
|---|---|---|---|
name | string | Yes | ≤ 200 chars |
type | string | Yes | single_choice | add_ingredients | remove_ingredients; multiple_choice is deprecated — accepted but stored as add_ingredients (a warning is returned) |
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".
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.
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)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.