Translate store content
Build a translation app on the Merchant API. The resource types and their ids, how to read source text, write translations safely, count progress per language, and the errors you will meet.
A translation app adds languages to a store and fills in the store's own text in each one: products, collections, pages, blog posts, menus and the words a theme prints. It uses the Merchant API, with the scopes below, from the app's server.
This page is about content a merchant owns. For the words your own theme prints, see Translating your theme. The capability and its limits are listed under Translated commerce content.
Scopes
| Scope | Allows |
|---|---|
merchant-locales-read | GET /locales |
merchant-locales-write | Add, publish, unpublish and remove a language; set the default language |
merchant-translations-read | GET /translatable-resources and GET /translatable-resources/{type}/{id} |
merchant-translations-write | PUT /translations/{type}/{id} and DELETE /translations/{type}/{id} |
The two translations scopes cover every resource type below. No product, page, menu, category or theme scope is needed to read or translate them. An app that only reads needs only the two read scopes. See Scopes and the translations reference.
Resource types
GET /translatable-resources?type=… lists one type at a time. Each item carries the source text
the merchant wrote, split into keys.
type | What it is | Keys |
|---|---|---|
product | A product | title, description (HTML), excerpt |
product_variation | A product option, such as Size | title |
product_variation_item | A value of an option, such as Large | name |
collection | A collection | name, description |
category | The category name shown on a product | name |
page | A store page | title, excerpt, seo.title, seo.description |
post | A blog post | title, excerpt, seo.title, seo.description |
menu | A navigation menu | items.<n>.label, with nested items.<n>.children.<n>.label |
theme_string | The store's theme text | <theme-slug>.<dotted-key>, for example roast.cart.title |
A key appears only when the source has a value for it. An empty field is not translatable, and it
shows up on the item once the merchant fills it in. Each key carries a type, text or html:
keep the markup of an html value and translate only its text.
page and post never overlap: page is every page that is not a blog post, and post is the
blog posts. A draft page or post is listed like a published one.
theme_string is one record per store. It lists every key of the store's active theme in a
single page, and its id is the store's own p_id. The theme slug is part of every key, so
switching theme leaves the old theme's translations dormant until you remove them with a
DELETE narrowed to those keys.
Ids
The id of an item in a list is always an integer: the record's p_id. It is the same
number the record carries as p_id elsewhere, for example on a page from GET /pages.
Use it as the stable identifier in your own database.
The retrieve and write routes accept that integer or the record's UUID in {id}. An id that
belongs to another store answers 404 not_found, as does an unknown type. For theme_string, {id} is
the store's p_id, its slug or its UUID.
Collections and categories
A store's collections and its categories are one set of rows: type=collection and
type=category list the same records, with the same ids. The general product taxonomy that every
store shares is never translatable per store, and never listed.
The two types keep separate translations. A translation written under category/2016 is not
returned when you read collection/2016, and the reverse. Each is read by a different part of the
storefront: collection supplies the name and description on collection responses, and
category supplies the category name shown on a product.
Use collection as the type you offer merchants. Write category as well when you want the product's
category name translated too, since the category type has only the name key.
The flow
The examples use the base URL https://api.usequeek.com/api/v1/merchant and the
installation token your SDK client sends in X-Client-Key.
1. Read the store's languages
curl https://api.usequeek.com/api/v1/merchant/locales \
-H "X-Client-Key: $TOKEN" -H "Accept: application/json"The response lists each language with locale, name, native_name, html_lang, dir,
published, is_primary and is_default, the default first. The store's source language appears
with is_primary: true. It is never a translation target: asking for it is
422 translation_locale_is_primary. The language object carries no address; it does not say
where shoppers reach the language.
Add a language with POST /locales and { "locale": "fr" }. It is unpublished until you publish
it with PATCH /locales/fr and { "published": true }. A store publishes at most 20 languages.
2. Read the source text
curl "https://api.usequeek.com/api/v1/merchant/translatable-resources?type=product&limit=100" \
-H "X-Client-Key: $TOKEN" -H "Accept: application/json"{
"data": [
{
"type": "product",
"id": 4021,
"content": [
{ "key": "title", "value": "Linen shirt", "digest": "…", "type": "text" },
{ "key": "description", "value": "<p>Soft, light linen.</p>", "digest": "…", "type": "html" }
],
"machine_translation_allowed": true
}
],
"has_more": true,
"next_cursor": "…"
}The digest identifies the exact source value. Keep it with the text you translate.
3. Read what is already translated
curl "https://api.usequeek.com/api/v1/merchant/translatable-resources/product/4021" \
-H "X-Client-Key: $TOKEN" -H "Accept: application/json"The response repeats content and adds translations, one row per stored locale and key:
{
"status": "success",
"message": "Translatable resource retrieved",
"data": {
"type": "product",
"id": 4021,
"content": [ … ],
"machine_translation_allowed": true,
"translations": [
{ "key": "title", "locale": "fr", "value": "Chemise en lin", "outdated": false, "updated_at": "2026-10-18T09:12:00+00:00" }
]
}
}Without locale the response carries the translations of every language. Add ?locale=fr to
narrow it to one.
A translation is outdated: true when the source value changed after it was written. The
storefront keeps showing an outdated translation until you replace it, except on menu, where
it is not shown, because a re-ordered menu would put one item's text on another.
4. Write translations
curl -X PUT https://api.usequeek.com/api/v1/merchant/translations/product/4021 \
-H "X-Client-Key: $TOKEN" -H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"translations": [
{ "locale": "fr", "key": "title", "value": "Chemise en lin", "digest": "…" }
]
}'Each item names a locale, a key, the translated value and the digest you read for that key.
The batch is all or nothing: if any item is wrong, nothing is written, and the error names the
item. A write replaces an existing translation of the same locale and key.
To remove translations, call DELETE /translations/{type}/{id} with locales[] and keys[] to
narrow it. With neither, it clears every translation of that record.
5. Publish the language
A language shoppers can pick must be published: PATCH /locales/{locale} with
{ "published": true }. To make it the language the store root serves, call
POST /locales/{locale}/default; the language must be published first. How a language is reached is
described under Which languages a store has.
Pagination
GET /translatable-resources is cursor paged. Send limit (default 15, at most 100). Each response
has has_more and next_cursor; pass next_cursor back as starting_after for the next page, and
stop when has_more is false. Items are ordered by p_id. A cursor belongs to one type and one
store: reusing it on another type answers 422.
Counting progress per language
The list carries source text only. It cannot filter by language, and it does not say what is translated. To show "312 of 480 texts translated in French", read the items you want to count:
- Page through the list for a type at 100 items per page.
- For each item, call
GET /translatable-resources/{type}/{id}once, withoutlocale. It returns the translations of every language together. - For each language, count the item's keys that have a row with
outdated: false. A key with no row is missing; a row withoutdated: trueneeds another look. - Cache the counts for a short time, and drop them when your app writes or removes a translation.
The cost is one request per item, not per item per language. Every request counts against the key's rate limit, which is per minute and depends on the store's plan, so a large catalogue takes minutes on a small plan. Read a bounded number of items per type, show progress for what you have read, and read the rest in the background.
Limits
| Limit | Value |
|---|---|
| Items per write | 100 |
Translated value | 50,000 characters |
key | 64 characters |
| Items per list page | 100 |
| Published languages per store | 20 |
Errors
The translation_* codes answer HTTP 422. See Merchant API errors.
| Code | Cause |
|---|---|
translation_digest_stale | The source changed after you read it. Re-read the item and translate again. |
translation_unknown_key | The record has no such key. It may be empty, or the key is misspelt. |
translation_unknown_locale | The language has not been added to the store. |
translation_locale_is_primary | The store's source language is not a translation target. |
translation_locale_is_default | The default language cannot be unpublished or removed. Pick another default first. |
translation_locale_not_published | Only the primary or a published language can be the default. |
translation_max_published_locales | The store already publishes 20 languages. |
A write error carries the position of the failing item in error.errors, for example
translations.3.digest. An unknown type, or an id from another store, is 404 not_found; a missing or
unrecognised type on the list is 422 validation_failed.
Webhooks
The locales/create, locales/update, locales/destroy, translations/update and
translations/delete topics exist. The public API document does not define the data they
carry, so do not build on its fields. Treat each event as a signal that something changed, and read
the current state from the API. See Webhooks.
Machine translation
Queek does not run a translation model for apps; a translation app brings its own. Each item carries
machine_translation_allowed. It is false for a store's privacy, terms, returns and shipping policy
pages, as advice to machine translators. Manual writes to those pages stay allowed.
Send only text you read from Queek to a model, so your route cannot be used to translate arbitrary text, and show merchants a suggestion before you save it.
Try it on a dev store
A dev store created with test data has products, collections, customers, coupons and
shipping zones. It has no pages, posts or menus. To create a page, POST /pages with
merchant-pages-create; the page is a draft, and it lists as a page. To create a post, POST /posts with
merchant-posts-create. The Merchant API has no endpoint to create a menu.
Embedded apps and the dashboard bridge
How an app's page runs inside the Queek dashboard. The postMessage contract, exact origins, the handshake and its 5 and 15 second limits, how the frame is sized, and where to put dialogs.
App capabilities
What a Queek app can do today, by area: the capabilities you can build with, how to reach each one, what is missing and the workarounds, plus example apps.