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/modifier-groups/:ref/translations
GET /v1/modifier-groups/:ref/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
Storiestitle, description:refwrite-only via the bulk PUT /v1/translations; stories are not batch-readable, so read them back through GET /v1/stories?locale=xx
Modifier groupsname:ref (externalId or id) — see Modifier groups

Modifier groups are reusable and addressed by :ref

Modifier groups now have a stable externalId and are shared across products, so translate them by :ref like every other entity — one translation serves every product the group is attached to. See Modifier groups.


Deprecated: the older composite route PUT/GET /v1/products/:ref/modifier-groups/:name/translations (product + exact group name) still works for backward-compatibility, but prefer the :ref route.

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 the batch back (below). To VERIFY the result the way guests see it, read the menu localized: GET /v1/menu?locale=de resolves every field (translation ?? canonical) and echoes the locale.

Bulk write — PUT /v1/translations#

Up to 50 items across ALL models in one request (one rate-limit hit). Each item is addressed like its single-entity route:

json
{
  "items": [
    { "model": "product", "ref": "prod-margherita",
      "translations": [{ "locale": "de", "name": "Margherita" }] },
    { "model": "banner", "ref": "ban-summer",
      "translations": [{ "locale": "de", "title": "Sommer-Aktion" }] },
    { "model": "modifierGroup", "productRef": "prod-margherita", "name": "Extras",
      "translations": [{ "locale": "de", "name": "Extras" }] }
  ]
}

Per-item results — one bad item never sinks the batch:

json
{
  "results": [
    { "model": "product", "ref": "prod-margherita", "locales": ["de"], "ok": true },
    { "model": "banner", "ref": "ghost", "ok": false,
      "error": { "code": "BANNER_NOT_FOUND", "message": "banner 'ghost' not found" } }
  ],
  "ok": 1, "failed": 1, "total": 2
}

When to use what: building a menu → put translations INLINE in sync (one call for everything); adding a language or fixing a handful of strings later → this bulk write; a single entity → the per-ref PUT routes above.

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