Translations
PUT /v1/products/:ref/translations
GET /v1/products/:ref/translations
PUT /v1/categories/:ref/translations
GET /v1/categories/:ref/translations
PUT /v1/ingredients/:ref/translations
GET /v1/ingredients/:ref/translations
PUT /v1/banners/:ref/translations
GET /v1/banners/:ref/translations
PUT /v1/products/:ref/modifier-groups/:name/translations
GET /v1/products/:ref/modifier-groups/:name/translationsSupply your own translated content per locale and build a fully
multilingual menu — every guest-visible string is coverable. PUT is an
idempotent upsert — repeating a call for the same (ref, locale)
updates the stored translation, never duplicates. Available on every plan
with an active subscription.
Translatable fields per resource:
| Resource | Fields | Addressed by |
|---|---|---|
| Products | name, description, badgeLabel | :ref (externalId or id) |
| Categories | name | :ref |
| Ingredients | name (shown in compositions AND modifier options) | :ref |
| Banners | title, description | :ref |
| Modifier groups | name | product :ref + the group's exact :name (URL-encoded) |
Modifier groups are addressed by name
Modifier groups have no stable id — a sync
that includes modifierGroups replaces a product's groups,
but their translations are carried over by exact group
name, so they survive re-syncs. Renaming a group starts it
fresh (translate again under the new name). If several groups under one
product share a name, the translation applies to all of them.
Enable the language first#
A locale must pass two gates, each with a machine code:
- Globally supported → otherwise
400 INVALID_LOCALE - Enabled for the venue (
enabledLanguagesin settings) → otherwise400 LOCALE_NOT_ENABLED
The agent flow: PATCH /v1/settings { enabledLanguages: [...] } → then
push translations. The usable locale list is in
GET /v1/reference.
Don't translate the default language
Content for the venue's defaultLanguage comes from the base
fields you sync — a "translation" into the
default locale is accepted but never shown.
Request body#
translations — 1–40 entries; each entry targets one locale (multiple
locales per call are fine). Field semantics: omit = keep the stored
value, empty string "" = delete that field's translation,
non-empty = set. An entry with no translatable fields →
400 EMPTY_TRANSLATION.
Example#
curl -X PUT https://api.duck-hub.com/v1/products/prod-margherita/translations \
-H "Authorization: Bearer dk_live_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"translations": [
{ "locale": "uk", "name": "Маргарита", "description": "Томати, моцарела, базилік" },
{ "locale": "de", "name": "Margherita", "description": "Tomaten, Mozzarella, Basilikum" }
]
}'const response = await fetch(
'https://api.duck-hub.com/v1/products/prod-margherita/translations',
{
method: 'PUT',
headers: {
Authorization: 'Bearer dk_live_your_api_key',
'Content-Type': 'application/json',
},
body: JSON.stringify({
translations: [
{ locale: 'uk', name: 'Маргарита', description: 'Томати, моцарела, базилік' },
{ locale: 'de', name: 'Margherita', description: 'Tomaten, Mozzarella, Basilikum' },
],
}),
},
)
const result = await response.json()Response (PUT and GET)#
{
"id": "cml9x2f4a0001abcd1234efgh",
"externalId": "prod-margherita",
"translations": {
"uk": { "name": "Маргарита", "description": "Томати, моцарела, базилік" },
"de": { "name": "Margherita", "description": "Tomaten, Mozzarella, Basilikum" }
}
}All stored locales are returned; only translatable fields appear.
The modifier-group variant returns the product context instead:
{
"productId": "cml9x2f4a0001abcd1234efgh",
"productExternalId": "prod-margherita",
"name": "Extras",
"translations": { "uk": { "name": "Добавки" } }
}Stats & batch reads#
Auditing multilingual coverage does NOT require per-ref GETs:
GET /v1/translations/stats#
One call → per enabled non-default locale × model:
{ total, translated, untranslated, stale, fields: { <field>: { translated, total } } }.
Answers "how complete is each language" without fetching a single row.
GET /v1/translations — batch read#
Returns only the rows that need the locale, only the requested fields, keyed by ref:
| Query param | Meaning |
|---|---|
locale (required) | Target locale |
model (required) | product | category | ingredient | banner (modifier groups are addressed per-product by name) |
filter | untranslated (default — includes stale) | stale | translated | all |
fields | Comma-separated subset, e.g. fields=name,description |
refs | Comma-separated refs to scope the batch |
page / limit | Pagination (limit ≤ 50, default 25) |
{
"items": [
{ "ref": "prod-margherita", "name": "Margherita", "translation": { "name": "" } }
],
"total": 12, "page": 1, "limit": 25, "totalPages": 1
}Typical fill loop: stats → for each incomplete locale,
GET /v1/translations?locale=de&model=product → translate → PUT back
per ref.
Errors#
400INVALID_LOCALE— locale not supported at all400LOCALE_NOT_ENABLED— enable it viaPATCH /v1/settingsfirst400EMPTY_TRANSLATION— entry without any translatable field400INVALID_MODEL— batch read with an unsupported model404PRODUCT_NOT_FOUND/CATEGORY_NOT_FOUND/INGREDIENT_NOT_FOUND/BANNER_NOT_FOUND— unknown:ref404MODIFIER_GROUP_NOT_FOUND— the product has no group with that exact name