Translations

http
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/translations

Supply 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:

ResourceFieldsAddressed by
Productsname, description, badgeLabel:ref (externalId or id)
Categoriesname:ref
Ingredientsname (shown in compositions AND modifier options):ref
Bannerstitle, description:ref
Modifier groupsnameproduct :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:

  1. Globally supported → otherwise 400 INVALID_LOCALE
  2. Enabled for the venue (enabledLanguages in settings) → otherwise 400 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#

bash
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" }
    ]
  }'

Response (PUT and GET)#

json
{
  "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:

json
{
  "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 paramMeaning
locale (required)Target locale
model (required)product | category | ingredient | banner (modifier groups are addressed per-product by name)
filteruntranslated (default — includes stale) | stale | translated | all
fieldsComma-separated subset, e.g. fields=name,description
refsComma-separated refs to scope the batch
page / limitPagination (limit ≤ 50, default 25)
json
{
  "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#

  • 400 INVALID_LOCALE — locale not supported at all
  • 400 LOCALE_NOT_ENABLED — enable it via PATCH /v1/settings first
  • 400 EMPTY_TRANSLATION — entry without any translatable field
  • 400 INVALID_MODEL — batch read with an unsupported model
  • 404 PRODUCT_NOT_FOUND / CATEGORY_NOT_FOUND / INGREDIENT_NOT_FOUND / BANNER_NOT_FOUND — unknown :ref
  • 404 MODIFIER_GROUP_NOT_FOUND — the product has no group with that exact name