The Shopify translations API is three pieces of Admin GraphQL. translatableResource returns every translatable field on a resource with its key, source value and a digest. translationsRegister saves a translated value against that key, a locale and the digest. Each stored translation then carries an outdated flag that flips when the source text changes. Add a marketId to scope a translation to one market.
The rest of a translation sync is bookkeeping around those calls, such as deciding what happens when a merchant edits a product title after you translated it. The queries below were written and validated against Admin API 2026-10, which Shopify’s release notes list as the latest stable version from 1 October 2026. That release also changes how you find translatable metafields, which is covered further down.
What does the Shopify translations API need first?
The scopes are read_translations and write_translations, plus the read scope for whatever owns the content (read_products for products). Enabling or publishing a language needs write_locales.
The locale has to exist on the shop first. TranslationInput.locale only accepts locales that shopLocales returns, and anything else comes back as INVALID_LOCALE_FOR_SHOP. Enable one with shopLocaleEnable:
mutation EnableLocale($locale: String!) {
shopLocaleEnable(locale: $locale) {
shopLocale { locale name published }
userErrors { field message }
}
}
A newly enabled locale is not published, so buyers cannot see it yet. That is useful: you can load every translation first and publish with shopLocaleUpdate(locale: "es", shopLocale: {published: true}) once the content is in. Shopify’s translation guide caps a shop at 20 enabled locales and 20 published ones. If another app or a merchant might add languages behind your back, the locales/create and locales/update webhooks tell you.
How does the translation digest work?
Ask for a product’s translatableContent and you get one entry per field: title, body_html, handle, product_type, meta_title and meta_description, each with its current value and a digest. The TranslationInput docs describe the digest as a “hash digest representation of the content being translated”, and translatableContentDigest is required on every translation you register.
So a translation is pinned to the exact source text you read, not to the field in general. Two practical rules follow. Read the digest in the same run that registers the translation; a digest saved in a spreadsheet three weeks ago describes text that may no longer exist. And key your own records on resource ID, field key and locale, never on the English string.
Not every field is worth translating. Product tags cannot be translated at all. You can translate handle, but Shopify warns that changing a product’s handle breaks its language-specific URL redirects, and a clash with an existing handle returns INVALID_VALUE_FOR_HANDLE_TRANSLATION. Unless the client has a reason to localise URLs, leave the handle alone.
A sync script that skips current translations
Here is the loop most integrations need, whether the source is a translation management system, a PIM export or a Magento migration carrying store views across. It reads the resource once, works out which keys are missing, changed or outdated, and registers only those in a single mutation.
// sync-translations.ts: needs Node 18+ (global fetch) and an Admin API token
// with read_translations and write_translations.
const SHOP = process.env.SHOPIFY_STORE ?? ""; // e.g. my-store.myshopify.com
const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN ?? "";
const API_VERSION = "2026-10";
type Content = { key: string; value: string | null; digest: string | null };
type Existing = { key: string; value: string | null; outdated: boolean };
type UserError = { field: string[] | null; message: string; code: string | null };
async function admin<T>(query: string, variables: Record<string, unknown>): Promise<T> {
const res = await fetch(`https://${SHOP}/admin/api/${API_VERSION}/graphql.json`, {
method: "POST",
headers: { "Content-Type": "application/json", "X-Shopify-Access-Token": TOKEN },
body: JSON.stringify({ query, variables }),
});
if (!res.ok) throw new Error(`Admin API returned ${res.status}`);
const json = (await res.json()) as { data?: T; errors?: unknown };
if (json.errors || !json.data) throw new Error(JSON.stringify(json.errors));
return json.data;
}
const READ = `#graphql
query ReadTranslatable($id: ID!, $locale: String!) {
translatableResource(resourceId: $id) {
translatableContent { key value digest locale }
translations(locale: $locale) { key value outdated }
}
}`;
const REGISTER = `#graphql
mutation Register($id: ID!, $translations: [TranslationInput!]!) {
translationsRegister(resourceId: $id, translations: $translations) {
translations { key locale outdated }
userErrors { field message code }
}
}`;
/** Registers the wanted translations for one resource, skipping keys that are already current. */
async function syncResource(
resourceId: string,
locale: string,
wanted: Record<string, string>,
): Promise<number> {
const data = await admin<{
translatableResource: { translatableContent: Content[]; translations: Existing[] } | null;
}>(READ, { id: resourceId, locale });
const resource = data.translatableResource;
if (!resource) throw new Error(`Not a translatable resource: ${resourceId}`);
const existing = new Map(resource.translations.map((t) => [t.key, t]));
const input = resource.translatableContent
.filter((c) => c.digest !== null && wanted[c.key] !== undefined)
.filter((c) => {
const current = existing.get(c.key);
return !current || current.outdated || current.value !== wanted[c.key];
})
.map((c) => ({
key: c.key,
locale,
value: wanted[c.key],
translatableContentDigest: c.digest,
}));
if (input.length === 0) return 0;
const result = await admin<{ translationsRegister: { userErrors: UserError[] } }>(REGISTER, {
id: resourceId,
translations: input,
});
const errors = result.translationsRegister.userErrors;
if (errors.length > 0) {
throw new Error(errors.map((e) => `${e.code ?? "ERROR"}: ${e.message}`).join("; "));
}
return input.length;
}
syncResource("gid://shopify/Product/20995642", "es", {
title: "Tabla Element",
product_type: "Tablas de snowboard",
})
.then((count) => console.log(`Registered ${count} translation(s)`))
.catch((err) => {
console.error(err);
process.exitCode = 1;
});
translationsRegister creates or updates, so re-running it is safe. The skip filter exists to save query cost, not to avoid duplicates. One resource per mutation adds up across a full catalogue, so pace a bulk run the way our Shopify GraphQL rate limits post describes rather than firing every product at once.
How do you find translations that went out of date?
The Translation object has a non-null outdated boolean: “Whether the original content has changed since this translation was updated.” Shopify keeps the old translated value on the resource and sets the flag, so the flag is your re-translation queue.
The translations field takes outdated: true as a filter, alongside locale and marketId:
query Stale($id: ID!) {
translatableResource(resourceId: $id) {
resourceId
translations(locale: "fr", outdated: true) { key value updatedAt }
}
}
The filter lives on each resource, so a nightly job walks translatableResources(resourceType: PRODUCT) page by page and collects the flagged keys. Send those to the translator, then run the sync above. It treats an outdated key as due, and registering it against the current digest updates the translation that the flag is measured from.
How do market-specific translations work?
Add marketId to a TranslationInput and the translation applies only to that market. Leave it out and it applies everywhere. That is how one shop sells in French to both France and Canada while showing “la collection canadienne” to Canadian buyers only. Read them back with translations(locale: "fr", marketId: "gid://shopify/Market/..."), and delete them with translationsRemove, which takes resourceId, translationKeys, locales and an optional marketIds list.
Market overrides bring their own error codes. MARKET_DOES_NOT_EXIST is a bad ID. RESOURCE_NOT_MARKET_CUSTOMIZABLE means that resource cannot vary by market, and MARKET_CUSTOM_CONTENT_NOT_ALLOWED means the shop is not allowed to operate on market custom content at all. Theme text behaves differently again. Shopify stores market-specific theme content in the shop’s primary locale as a theme override, not as a translation, so a sync that only reads translations will not see it.
Which language a buyer actually gets is decided on the storefront side; our post on Storefront API @inContext covers how a headless build asks for it.
What changed for metafield translations in 2026-10?
Until this release, finding translatable metafields meant calling translatableResources(resourceType: METAFIELD) and then working out from each metafield’s type whether it could really be translated. In 2026-10, TranslatableResourceType.METAFIELD is deprecated and the Metafield object gains a non-null translatable field. Per the developer changelog of 21 September 2026, it is true “when the metafield’s type is translatable and its owner allows metafield translations.”
The replacement flow, from Shopify’s migration guide, starts at the owner. Query product { metafields { nodes { id translatable } } }, keep the IDs where translatable is true, pass them to translatableResourcesByIds to get each metafield’s value digest, then call translationsRegister with the metafield ID as resourceId. The syncResource function above works unchanged for that last step.
Nothing breaks today. Shopify says the METAFIELD value “still works on every API version” and its removal will be announced separately. Apps pinned to 2026-07 or earlier never see the new field. If your app does not look up translatable metafields shop-wide, there is nothing to change. If you manage metafield definitions in code, as in our metafield definitions in TOML walkthrough, remember that only publicly accessible metafields are translatable.
Which errors should a translation sync handle?
All of them arrive in userErrors with a TranslationErrorCode, so check that array on every call, not just the top-level errors. In practice they sort into three groups. INVALID_LOCALE_FOR_SHOP, INVALID_CODE and INVALID_FORMAT mean the locale is wrong or not enabled, which is a set-up bug to fix once. INVALID_KEY_FOR_MODEL, RESOURCE_NOT_TRANSLATABLE and RESOURCE_NOT_FOUND mean your mapping points at a field or object that does not take translations. INVALID_TRANSLATABLE_CONTENT, FAILS_RESOURCE_VALIDATION and TOO_MANY_KEYS_FOR_RESOURCE are about the payload itself, and the safe response is to log the resource and move on rather than fail the whole batch.
Recommendation
For anything beyond a handful of products, build the sync: re-read digests every run, register only what is missing or outdated, and sweep for outdated: true on a schedule so edits to English copy reach every language. Use marketId only where the wording genuinely differs between markets, such as Canadian and European French, because every market override is another key to keep current. Move metafield discovery onto Metafield.translatable when you next bump to 2026-10. If a merchant runs one extra language and translates by hand in the admin, none of this is worth building.
We build translation and Markets integrations as part of our Shopify development work, including migrations that carry several store languages across from the old platform.