# Localization · Management API

> Management API reference for localization: GET /sites/{id}/locales, POST /sites/{id}/locales, PUT /sites/{id}/locales/order, PUT /sites/{id}/locales/primary, ….

> Markdown version of https://kanorio.com/en/developers/management-api/localization. Append `.md` to any developer docs URL for this format. Index: https://kanorio.com/llms.txt · MCP: https://app.kanorio.com/api/mcp

Base URL: `https://app.kanorio.com/api/v1` · [Errors](https://kanorio.com/en/developers/basics/errors) · [Pagination](https://kanorio.com/en/developers/basics/pagination)

### List locales

`GET /sites/{id}/locales`

- Stability: beta
- Permission: `console:view`
- Rate limit: default (120/minute per token)
- MCP tool: `localization_locales_list`

List the website's locales (primary first, then the visitor language-switcher order) with status, auto-translate and URL prefix, plus the plan's locale limit and every locale code Kanorio supports. The primary locale is the source language; change it with localization.locales.set_primary while the website has a single locale. Errors: FORBIDDEN, WEBSITE_NOT_FOUND.

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | Website id. Get it from sites.list or from sites.create. |

**Response** (200)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object[] | yes | — |
| `data[].locale` | string | yes | — |
| `data[].isPrimary` | boolean | yes | — |
| `data[].status` | "DRAFT" \| "TRANSLATING" \| "PUBLISHED" \| "DISABLED" | yes | DRAFT = added but hidden from visitors and the Delivery API; TRANSLATING = the full-site translation job is running (switches to PUBLISHED when done); PUBLISHED = live; DISABLED = retired. |
| `data[].autoTranslate` | boolean | yes | Machine-translate new and changed content automatically. Fields edited by a person, an agent or an external tool are never overwritten by machine translation. |
| `data[].pathPrefix` | string \| null | yes | URL prefix of a secondary locale; null for the primary locale (served at the root). |
| `data[].sortOrder` | integer | yes | Position in the visitor language switcher (primary first). |
| `data[].addedAt` | string | yes | — |
| `data[].publishedAt` | string \| null | yes | First time the locale went live; null if it has never been live. |
| `hasMore` | boolean | yes | True when another page exists; pass nextCursor to fetch it. |
| `nextCursor` | string \| null | yes | Cursor for the next page, or null on the last page. |
| `primaryLocale` | string | yes | — |
| `maxLocales` | integer | yes | Plan limit, counting the primary locale. |
| `supportedLocales` | string[] | yes | Locale codes that can be added. |

**Errors**

| Code | Status | When |
| --- | --- | --- |
| `FORBIDDEN` | 403 | — |
| `WEBSITE_NOT_FOUND` | 404 | — |

```bash
curl -X GET "https://app.kanorio.com/api/v1/sites/{id}/locales" \
  -H "Authorization: Bearer $KANORIO_TOKEN"
```

```json
{
  "data": [
    {
      "locale": "en-US",
      "isPrimary": true,
      "status": "PUBLISHED",
      "autoTranslate": true,
      "pathPrefix": null,
      "sortOrder": 0,
      "addedAt": "2026-10-01T02:30:00.000Z",
      "publishedAt": "2026-10-01T02:30:00.000Z"
    },
    {
      "locale": "fr-FR",
      "isPrimary": false,
      "status": "PUBLISHED",
      "autoTranslate": true,
      "pathPrefix": "fr",
      "sortOrder": 1,
      "addedAt": "2026-10-01T02:30:00.000Z",
      "publishedAt": "2026-10-06T08:00:00.000Z"
    },
    {
      "locale": "de",
      "isPrimary": false,
      "status": "TRANSLATING",
      "autoTranslate": true,
      "pathPrefix": "de",
      "sortOrder": 2,
      "addedAt": "2026-10-06T08:00:00.000Z",
      "publishedAt": null
    }
  ],
  "hasMore": false,
  "nextCursor": null,
  "primaryLocale": "en-US",
  "maxLocales": 5,
  "supportedLocales": [
    "en-US",
    "en-GB",
    "fr-FR",
    "fr-CA",
    "de",
    "es-ES",
    "ja",
    "zh-Hant"
  ]
}
```

### Add a locale

`POST /sites/{id}/locales`

- Stability: beta
- Permission: `site:manage_settings`
- Rate limit: default (120/minute per token)
- MCP tool: `localization_locales_add`

Add a secondary locale. With autoTranslate (default true) it starts as TRANSLATING and a full-site translation job translates every record, then the locale goes live automatically; poll it with localization.jobs.list or localization.jobs.get. With autoTranslate false it starts as DRAFT: write translations with localization.translations.upsert, then publish it with localization.locales.update (status PUBLISHED). pathPrefix defaults to the language code (en), or the full code when two regional variants share a language (en-us, en-gb). Pass confirm: true. Safe to retry: adding a locale that is already on the website returns LOCALE_ALREADY_ADDED with details.locale (its current state). Errors: FORBIDDEN, CONFIRM_REQUIRED, UNSUPPORTED_LOCALE, LOCALE_ALREADY_ADDED, LOCALE_LIMIT_REACHED, INVALID_PATH_PREFIX, PATH_PREFIX_TAKEN, PATH_PREFIX_CONFLICT.

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | Website id. Get it from sites.list or from sites.create. |

**Request body**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `locale` | string | yes | BCP 47 locale code exactly as listed by localization.locales.list (e.g. en-US, ja, zh-Hant). |
| `autoTranslate` | boolean | no | Default true. |
| `pathPrefix` | string | no | URL prefix for this locale (lowercase letters, digits and hyphens, 2-30 chars), e.g. en or en-gb. Visitors reach the locale at /{prefix}/... Changing a live locale's prefix 301-redirects the old prefix. |
| `confirm` | boolean | yes | Must be true: with autoTranslate the whole site is translated and the locale goes live when done. |

**Response** (201)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `locale` | object | yes | — |
| `locale.locale` | string | yes | — |
| `locale.isPrimary` | boolean | yes | — |
| `locale.status` | "DRAFT" \| "TRANSLATING" \| "PUBLISHED" \| "DISABLED" | yes | DRAFT = added but hidden from visitors and the Delivery API; TRANSLATING = the full-site translation job is running (switches to PUBLISHED when done); PUBLISHED = live; DISABLED = retired. |
| `locale.autoTranslate` | boolean | yes | Machine-translate new and changed content automatically. Fields edited by a person, an agent or an external tool are never overwritten by machine translation. |
| `locale.pathPrefix` | string \| null | yes | URL prefix of a secondary locale; null for the primary locale (served at the root). |
| `locale.sortOrder` | integer | yes | Position in the visitor language switcher (primary first). |
| `locale.addedAt` | string | yes | — |
| `locale.publishedAt` | string \| null | yes | First time the locale went live; null if it has never been live. |
| `jobId` | string \| null | yes | Full-site translation job started for this locale (null when autoTranslate is false); follow it with localization.jobs.get. |

**Errors**

| Code | Status | When |
| --- | --- | --- |
| `FORBIDDEN` | 403 | — |
| `CONFIRM_REQUIRED` | 400 | — |
| `UNSUPPORTED_LOCALE` | 400 | — |
| `LOCALE_ALREADY_ADDED` | 409 | — |
| `LOCALE_LIMIT_REACHED` | 403 | — |
| `INVALID_PATH_PREFIX` | 400 | — |
| `PATH_PREFIX_TAKEN` | 409 | — |
| `PATH_PREFIX_CONFLICT` | 409 | — |

```bash
curl -X POST "https://app.kanorio.com/api/v1/sites/{id}/locales" \
  -H "Authorization: Bearer $KANORIO_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"locale":"de","autoTranslate":true,"confirm":true}'
```

```json
{
  "locale": {
    "locale": "de",
    "isPrimary": false,
    "status": "TRANSLATING",
    "autoTranslate": true,
    "pathPrefix": "de",
    "sortOrder": 2,
    "addedAt": "2026-10-06T08:00:00.000Z",
    "publishedAt": null
  },
  "jobId": "ltj_example01"
}
```

### Reorder locales

`PUT /sites/{id}/locales/order`

- Stability: beta
- Permission: `site:manage_settings`
- Rate limit: default (120/minute per token)
- MCP tool: `localization_locales_reorder`

Set the order of secondary locales in the visitor language switcher. Locales left out keep their relative order after the listed ones; the primary locale is always first. Errors: FORBIDDEN, LOCALE_NOT_FOUND.

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | Website id. Get it from sites.list or from sites.create. |

**Request body**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `order` | string[] | yes | Secondary locale codes in the new order. |

**Response** (200)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object[] | yes | — |
| `data[].locale` | string | yes | — |
| `data[].isPrimary` | boolean | yes | — |
| `data[].status` | "DRAFT" \| "TRANSLATING" \| "PUBLISHED" \| "DISABLED" | yes | DRAFT = added but hidden from visitors and the Delivery API; TRANSLATING = the full-site translation job is running (switches to PUBLISHED when done); PUBLISHED = live; DISABLED = retired. |
| `data[].autoTranslate` | boolean | yes | Machine-translate new and changed content automatically. Fields edited by a person, an agent or an external tool are never overwritten by machine translation. |
| `data[].pathPrefix` | string \| null | yes | URL prefix of a secondary locale; null for the primary locale (served at the root). |
| `data[].sortOrder` | integer | yes | Position in the visitor language switcher (primary first). |
| `data[].addedAt` | string | yes | — |
| `data[].publishedAt` | string \| null | yes | First time the locale went live; null if it has never been live. |
| `hasMore` | boolean | yes | True when another page exists; pass nextCursor to fetch it. |
| `nextCursor` | string \| null | yes | Cursor for the next page, or null on the last page. |

**Errors**

| Code | Status | When |
| --- | --- | --- |
| `FORBIDDEN` | 403 | — |
| `LOCALE_NOT_FOUND` | 404 | — |

```bash
curl -X PUT "https://app.kanorio.com/api/v1/sites/{id}/locales/order" \
  -H "Authorization: Bearer $KANORIO_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"order":["de","fr-FR"]}'
```

```json
{
  "data": [
    {
      "locale": "en-US",
      "isPrimary": true,
      "status": "PUBLISHED",
      "autoTranslate": true,
      "pathPrefix": null,
      "sortOrder": 0,
      "addedAt": "2026-10-01T02:30:00.000Z",
      "publishedAt": "2026-10-01T02:30:00.000Z"
    },
    {
      "locale": "de",
      "isPrimary": false,
      "status": "TRANSLATING",
      "autoTranslate": true,
      "pathPrefix": "de",
      "sortOrder": 1,
      "addedAt": "2026-10-06T08:00:00.000Z",
      "publishedAt": null
    },
    {
      "locale": "fr-FR",
      "isPrimary": false,
      "status": "PUBLISHED",
      "autoTranslate": true,
      "pathPrefix": "fr",
      "sortOrder": 2,
      "addedAt": "2026-10-01T02:30:00.000Z",
      "publishedAt": "2026-10-06T08:00:00.000Z"
    }
  ],
  "hasMore": false,
  "nextCursor": null
}
```

### Set primary

`PUT /sites/{id}/locales/primary`

- Stability: beta
- Permission: `site:manage_settings`
- Rate limit: default (120/minute per token)
- MCP tool: `localization_locales_set_primary`

Change the primary (source) locale. Allowed while the website has a single locale (the content language changes: fonts, HTML lang, system text); a multilingual website returns PRIMARY_SWAP_NOT_AVAILABLE. Setting the current primary locale again is a no-op. Pass confirm: true. Errors: FORBIDDEN, CONFIRM_REQUIRED, UNSUPPORTED_LOCALE, PRIMARY_SWAP_NOT_AVAILABLE.

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | Website id. Get it from sites.list or from sites.create. |

**Request body**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `locale` | string | yes | New primary locale code (any code in supportedLocales). |
| `confirm` | boolean | yes | Must be true: the website's content language changes. |

**Response** (200)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `from` | string | yes | — |
| `to` | string | yes | — |
| `changed` | boolean | yes | — |

**Errors**

| Code | Status | When |
| --- | --- | --- |
| `FORBIDDEN` | 403 | — |
| `CONFIRM_REQUIRED` | 400 | — |
| `UNSUPPORTED_LOCALE` | 400 | — |
| `PRIMARY_SWAP_NOT_AVAILABLE` | 409 | — |

```bash
curl -X PUT "https://app.kanorio.com/api/v1/sites/{id}/locales/primary" \
  -H "Authorization: Bearer $KANORIO_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"locale":"en-GB","confirm":true}'
```

```json
{
  "from": "en-US",
  "to": "en-GB",
  "changed": true
}
```

### Update a locale

`PATCH /sites/{id}/locales/{locale}`

- Stability: beta
- Permission: `site:manage_settings`
- Rate limit: default (120/minute per token)
- MCP tool: `localization_locales_update`

Update a locale: status PUBLISHED puts it live (adds it to the language switcher, hreflang, sitemap and the Delivery API) and DRAFT takes it offline (its URL prefix then 301-redirects to the primary locale); autoTranslate true starts a job that translates only what is missing or outdated; pathPrefix changes the URL prefix (a live locale's old prefix 301-redirects to the new one). Errors: FORBIDDEN, LOCALE_NOT_FOUND, PRIMARY_LOCALE_LOCKED, INVALID_PATH_PREFIX, PATH_PREFIX_TAKEN, PATH_PREFIX_CONFLICT.

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | Website id. Get it from sites.list or from sites.create. |
| `locale` | path | string | yes | BCP 47 locale code exactly as listed by localization.locales.list (e.g. en-US, ja, zh-Hant). |

**Request body**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `status` | "DRAFT" \| "PUBLISHED" | no | — |
| `autoTranslate` | boolean | no | — |
| `pathPrefix` | string | no | URL prefix for this locale (lowercase letters, digits and hyphens, 2-30 chars), e.g. en or en-gb. Visitors reach the locale at /{prefix}/... Changing a live locale's prefix 301-redirects the old prefix. |

**Response** (200)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `locale` | object | yes | — |
| `locale.locale` | string | yes | — |
| `locale.isPrimary` | boolean | yes | — |
| `locale.status` | "DRAFT" \| "TRANSLATING" \| "PUBLISHED" \| "DISABLED" | yes | DRAFT = added but hidden from visitors and the Delivery API; TRANSLATING = the full-site translation job is running (switches to PUBLISHED when done); PUBLISHED = live; DISABLED = retired. |
| `locale.autoTranslate` | boolean | yes | Machine-translate new and changed content automatically. Fields edited by a person, an agent or an external tool are never overwritten by machine translation. |
| `locale.pathPrefix` | string \| null | yes | URL prefix of a secondary locale; null for the primary locale (served at the root). |
| `locale.sortOrder` | integer | yes | Position in the visitor language switcher (primary first). |
| `locale.addedAt` | string | yes | — |
| `locale.publishedAt` | string \| null | yes | First time the locale went live; null if it has never been live. |

**Errors**

| Code | Status | When |
| --- | --- | --- |
| `FORBIDDEN` | 403 | — |
| `LOCALE_NOT_FOUND` | 404 | — |
| `PRIMARY_LOCALE_LOCKED` | 409 | — |
| `INVALID_PATH_PREFIX` | 400 | — |
| `PATH_PREFIX_TAKEN` | 409 | — |
| `PATH_PREFIX_CONFLICT` | 409 | — |

```bash
curl -X PATCH "https://app.kanorio.com/api/v1/sites/{id}/locales/{locale}" \
  -H "Authorization: Bearer $KANORIO_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"status":"PUBLISHED"}'
```

```json
{
  "locale": {
    "locale": "de",
    "isPrimary": false,
    "status": "PUBLISHED",
    "autoTranslate": true,
    "pathPrefix": "de",
    "sortOrder": 2,
    "addedAt": "2026-10-06T08:00:00.000Z",
    "publishedAt": "2026-10-06T08:00:00.000Z"
  }
}
```

### Locale remove

`DELETE /sites/{id}/locales/{locale}`

- Stability: beta
- Permission: `site:manage_settings`
- Rate limit: default (120/minute per token)
- MCP tool: `localization_locales_remove`

Remove a secondary locale. If it was ever live, its URL prefix 301-redirects to the matching primary-locale pages. Its translations are kept, so adding the same locale back later only translates what is missing. Pass confirm: true. Errors: FORBIDDEN, CONFIRM_REQUIRED, LOCALE_NOT_FOUND, PRIMARY_LOCALE_LOCKED.

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | Website id. Get it from sites.list or from sites.create. |
| `locale` | path | string | yes | BCP 47 locale code exactly as listed by localization.locales.list (e.g. en-US, ja, zh-Hant). |
| `confirm` | query | boolean \| "true" \| "false" | no | Must be true: visitors lose this language version. |

**Response** (200)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `locale` | string | yes | — |
| `removed` | true | yes | — |

**Errors**

| Code | Status | When |
| --- | --- | --- |
| `FORBIDDEN` | 403 | — |
| `CONFIRM_REQUIRED` | 400 | — |
| `LOCALE_NOT_FOUND` | 404 | — |
| `PRIMARY_LOCALE_LOCKED` | 409 | — |

```bash
curl -X DELETE "https://app.kanorio.com/api/v1/sites/{id}/locales/{locale}?confirm=true" \
  -H "Authorization: Bearer $KANORIO_TOKEN"
```

```json
{
  "locale": "de",
  "removed": true
}
```

### List translations

`GET /sites/{id}/translations`

- Stability: beta
- Permission: `content:edit`
- Rate limit: default (120/minute per token)
- MCP tool: `localization_translations_list`

List translation rows of one record type (newest first), optionally narrowed to one record, one locale or one status. Rows carry metadata only; read the text and the source with localization.translations.get. Translating counts as editing the record, so the record type's own permission applies (e.g. commerce:manage for products). Errors: FORBIDDEN, INVALID_CURSOR.

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | Website id. Get it from sites.list or from sites.create. |
| `entityType` | query | "page" \| "global_sections" \| "site_settings" \| "store_settings" \| "post" \| "post_category" \| "post_tag" \| "author" \| "product" \| "product_option" \| "pricing_tier" \| "product_category" \| "deliverable" \| "deliverable_unit" \| "form" \| "top_bar_banner" \| "modal_announcement" \| "floating_widget" \| "collection_item" \| "message_template" | yes | Translatable record type. page (pages.list ids), global_sections, site_settings and store_settings (the site itself: entityId defaults to websiteId), post / post_category / post_tag / author (blog), product / product_option / pricing_tier / product_category, deliverable / deliverable_unit, form, top_bar_banner / modal_announcement / floating_widget, collection_item (custom collection items), message_template (an automated email whose content the owner changed; the id of the customized template — untouched emails use the built-in version for every language). |
| `entityId` | query | string | no | Only this record. |
| `locale` | query | string | no | Only this locale. |
| `status` | query | "DRAFT" \| "PUBLISHED" | no | — |
| `limit` | query | integer | no | Maximum number of items to return, 1–100. Defaults to 20. |
| `cursor` | query | string | no | Opaque cursor from the previous page's nextCursor. Omit for the first page. |

**Response** (200)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object[] | yes | — |
| `data[].entityType` | string | yes | — |
| `data[].entityId` | string | yes | — |
| `data[].locale` | string | yes | — |
| `data[].status` | "DRAFT" \| "PUBLISHED" | yes | PUBLISHED = the published copy is live (it may differ from the draft copy until the record is published again). |
| `data[].translatedBy` | "MACHINE" \| "HUMAN" \| "AGENT" \| "EXTERNAL" \| "SEO_FIX" | yes | Most significant author across fields: HUMAN > SEO_FIX > AGENT > EXTERNAL > MACHINE. |
| `data[].fieldCount` | integer | yes | Number of translated field paths in the draft copy. |
| `data[].sourceHash` | string | yes | Source fingerprint the draft copy was written against. |
| `data[].updatedAt` | string | yes | — |
| `data[].publishedAt` | string \| null | yes | — |
| `hasMore` | boolean | yes | True when another page exists; pass nextCursor to fetch it. |
| `nextCursor` | string \| null | yes | Cursor for the next page, or null on the last page. |

**Errors**

| Code | Status | When |
| --- | --- | --- |
| `FORBIDDEN` | 403 | — |
| `INVALID_CURSOR` | 400 | — |

```bash
curl -X GET "https://app.kanorio.com/api/v1/sites/{id}/translations?entityType=product&locale=fr-FR&limit=20" \
  -H "Authorization: Bearer $KANORIO_TOKEN"
```

```json
{
  "data": [
    {
      "entityType": "product",
      "entityId": "prod_example01",
      "locale": "fr-FR",
      "status": "PUBLISHED",
      "translatedBy": "AGENT",
      "fieldCount": 2,
      "sourceHash": "c1a0e6f2b47d93e8",
      "updatedAt": "2026-10-06T08:00:00.000Z",
      "publishedAt": "2026-10-01T02:30:00.000Z"
    }
  ],
  "hasMore": false,
  "nextCursor": null
}
```

### Retrieve a translation

`GET /sites/{id}/translations/{entityType}/{entityId}/{locale}`

- Stability: beta
- Permission: `content:edit`
- Rate limit: default (120/minute per token)
- MCP tool: `localization_translations_get`

Get one record's translation in one locale together with its translatable source fields: each field's path, current source text, text format (profile), fingerprint and status (missing, current or outdated). Pass source.sourceHash back as sourceHash to localization.translations.upsert. translation is null when nothing has been translated yet. Errors: FORBIDDEN, VALIDATION, ENTITY_NOT_FOUND, LOCALE_NOT_FOUND, LOCALE_IS_PRIMARY.

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | Website id. Get it from sites.list or from sites.create. |
| `entityType` | path | string | yes | Translatable record type. page (pages.list ids), global_sections, site_settings and store_settings (the site itself: entityId defaults to websiteId), post / post_category / post_tag / author (blog), product / product_option / pricing_tier / product_category, deliverable / deliverable_unit, form, top_bar_banner / modal_announcement / floating_widget, collection_item (custom collection items), message_template (an automated email whose content the owner changed; the id of the customized template — untouched emails use the built-in version for every language). |
| `entityId` | path | string | yes | Record id. Required except for global_sections and site_settings, whose id is the websiteId. |
| `locale` | path | string | yes | BCP 47 locale code exactly as listed by localization.locales.list (e.g. en-US, ja, zh-Hant). |

**Response** (200)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `entityType` | string | yes | — |
| `entityId` | string | yes | — |
| `locale` | string | yes | — |
| `translation` | object \| null | yes | — |
| `translation.entityType` | string | yes | — |
| `translation.entityId` | string | yes | — |
| `translation.locale` | string | yes | — |
| `translation.status` | "DRAFT" \| "PUBLISHED" | yes | PUBLISHED = the published copy is live (it may differ from the draft copy until the record is published again). |
| `translation.translatedBy` | "MACHINE" \| "HUMAN" \| "AGENT" \| "EXTERNAL" \| "SEO_FIX" | yes | Most significant author across fields: HUMAN > SEO_FIX > AGENT > EXTERNAL > MACHINE. |
| `translation.fieldCount` | integer | yes | Number of translated field paths in the draft copy. |
| `translation.sourceHash` | string | yes | Source fingerprint the draft copy was written against. |
| `translation.updatedAt` | string | yes | — |
| `translation.publishedAt` | string \| null | yes | — |
| `translation.fieldData` | object | yes | Draft copy: field path → translated text. |
| `translation.publishedFieldData` | object \| null | yes | Live copy (null until published). |
| `translation.fieldMeta` | object | yes | — |
| `translation.slug` | string \| null | yes | — |
| `translation.publishedSlug` | string \| null | yes | — |
| `source` | object | yes | — |
| `source.sourceHash` | string | yes | Fingerprint of all source fields; required by localization.translations.upsert. |
| `source.fields` | object[] | yes | — |
| `source.fields[].path` | string | yes | Field path to use as a key in fieldData (array indexes and section ids already filled in). |
| `source.fields[].value` | string | yes | Current source text in the primary locale (editable draft). |
| `source.fields[].profile` | "plain" \| "inline" \| "body" \| "article" \| "email" | yes | Text format: plain = no markup; inline = inline Markdown only (bold, italic, links); body / article = Markdown blocks (article also keeps images and headings); email = email-safe Markdown. Keep the same markup in the translation. |
| `source.fields[].sourceHash` | string | yes | — |
| `source.fields[].status` | "missing" \| "current" \| "outdated" | yes | missing = no translation yet; current = translated from this exact source; outdated = the source changed after it was translated. |

**Errors**

| Code | Status | When |
| --- | --- | --- |
| `FORBIDDEN` | 403 | — |
| `VALIDATION` | 400 | — |
| `ENTITY_NOT_FOUND` | 404 | — |
| `LOCALE_NOT_FOUND` | 404 | — |
| `LOCALE_IS_PRIMARY` | 422 | — |

```bash
curl -X GET "https://app.kanorio.com/api/v1/sites/{id}/translations/{entityType}/{entityId}/{locale}" \
  -H "Authorization: Bearer $KANORIO_TOKEN"
```

```json
{
  "entityType": "product",
  "entityId": "prod_example01",
  "locale": "fr-FR",
  "translation": {
    "entityType": "product",
    "entityId": "prod_example01",
    "locale": "fr-FR",
    "status": "PUBLISHED",
    "translatedBy": "AGENT",
    "fieldCount": 2,
    "sourceHash": "c1a0e6f2b47d93e8",
    "updatedAt": "2026-10-06T08:00:00.000Z",
    "publishedAt": "2026-10-01T02:30:00.000Z",
    "fieldData": {
      "title": "Pain au levain de campagne",
      "description": "Fermentation lente de 36 heures, farine complète **moulue sur meule**."
    },
    "publishedFieldData": {
      "title": "Pain au levain de campagne",
      "description": "Fermentation lente de 36 heures, farine complète **moulue sur meule**."
    },
    "fieldMeta": {
      "title": {
        "translatedBy": "AGENT",
        "sourceHash": "5e1b0c9a"
      },
      "description": {
        "translatedBy": "MACHINE",
        "sourceHash": "9f3d2a71"
      }
    },
    "slug": null,
    "publishedSlug": null
  },
  "source": {
    "sourceHash": "c1a0e6f2b47d93e8",
    "fields": [
      {
        "path": "title",
        "value": "Country Sourdough",
        "profile": "plain",
        "sourceHash": "5e1b0c9a",
        "status": "current"
      },
      {
        "path": "description",
        "value": "Slow-fermented for 48 hours with **stone-ground** whole wheat.",
        "profile": "body",
        "sourceHash": "0b7c44d2",
        "status": "outdated"
      }
    ]
  }
}
```

### Translation upsert

`PUT /sites/{id}/translations/{entityType}/{entityId}/{locale}`

- Stability: beta
- Permission: `content:edit`
- Rate limit: default (120/minute per token)
- MCP tool: `localization_translations_upsert`

Write translated text for some fields of one record in one secondary locale. Only the given paths change; an empty string clears that field (visitors see the primary text). Paths must come from localization.translations.get source.fields. sourceHash must equal the current source.sourceHash; if the source changed since you read it the write is rejected with SOURCE_HASH_MISMATCH (details.sourceHash) — read it again and re-translate the outdated fields. Fields you write are locked against machine re-translation; if their source changes later they are re-translated using your wording as reference. slug sets this locale's URL slug (one segment, lowercase; pages translate only their last segment). null makes the locale follow the primary slug. Changing a live slug adds a 301 from the old URL in that locale. Pages, global sections, site settings, blog posts and collection items keep translations as drafts that go live when the record is published (or with localization.translations.publish); every other record type goes live on write. Errors: FORBIDDEN, VALIDATION, ENTITY_NOT_FOUND, LOCALE_NOT_FOUND, LOCALE_IS_PRIMARY, INVALID_FIELD_PATH (details.paths), SOURCE_HASH_MISMATCH, INVALID_SLUG, SLUG_TAKEN.

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | Website id. Get it from sites.list or from sites.create. |
| `entityType` | path | string | yes | Translatable record type. page (pages.list ids), global_sections, site_settings and store_settings (the site itself: entityId defaults to websiteId), post / post_category / post_tag / author (blog), product / product_option / pricing_tier / product_category, deliverable / deliverable_unit, form, top_bar_banner / modal_announcement / floating_widget, collection_item (custom collection items), message_template (an automated email whose content the owner changed; the id of the customized template — untouched emails use the built-in version for every language). |
| `entityId` | path | string | yes | Record id. Required except for global_sections and site_settings, whose id is the websiteId. |
| `locale` | path | string | yes | BCP 47 locale code exactly as listed by localization.locales.list (e.g. en-US, ja, zh-Hant). |

**Request body**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `fieldData` | object | no | Field path → translated text, keeping the source's Markdown for inline / body / article fields. May be empty when only slug changes. |
| `slug` | string \| null | no | This locale's URL slug: one segment of letters (any script), digits and hyphens, lowercase. null follows the primary slug; omit to keep it. Records without a URL (forms, settings) reject it with INVALID_SLUG. |
| `sourceHash` | string | yes | source.sourceHash from localization.translations.get. |
| `translatedBy` | "AGENT" \| "EXTERNAL" | no | AGENT (default) for AI agents; EXTERNAL for translation-management tools and human translators outside Kanorio. |

**Response** (200)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `translation` | object | yes | — |
| `translation.entityType` | string | yes | — |
| `translation.entityId` | string | yes | — |
| `translation.locale` | string | yes | — |
| `translation.status` | "DRAFT" \| "PUBLISHED" | yes | PUBLISHED = the published copy is live (it may differ from the draft copy until the record is published again). |
| `translation.translatedBy` | "MACHINE" \| "HUMAN" \| "AGENT" \| "EXTERNAL" \| "SEO_FIX" | yes | Most significant author across fields: HUMAN > SEO_FIX > AGENT > EXTERNAL > MACHINE. |
| `translation.fieldCount` | integer | yes | Number of translated field paths in the draft copy. |
| `translation.sourceHash` | string | yes | Source fingerprint the draft copy was written against. |
| `translation.updatedAt` | string | yes | — |
| `translation.publishedAt` | string \| null | yes | — |
| `translation.fieldData` | object | yes | Draft copy: field path → translated text. |
| `translation.publishedFieldData` | object \| null | yes | Live copy (null until published). |
| `translation.fieldMeta` | object | yes | — |
| `translation.slug` | string \| null | yes | — |
| `translation.publishedSlug` | string \| null | yes | — |
| `live` | boolean | yes | true when visitors already see this text; false when it waits for the record to be published. |

**Errors**

| Code | Status | When |
| --- | --- | --- |
| `FORBIDDEN` | 403 | — |
| `VALIDATION` | 400 | — |
| `ENTITY_NOT_FOUND` | 404 | — |
| `LOCALE_NOT_FOUND` | 404 | — |
| `LOCALE_IS_PRIMARY` | 422 | — |
| `INVALID_FIELD_PATH` | 400 | details.paths |
| `SOURCE_HASH_MISMATCH` | 409 | — |
| `INVALID_SLUG` | 400 | — |
| `SLUG_TAKEN` | 409 | — |

```bash
curl -X PUT "https://app.kanorio.com/api/v1/sites/{id}/translations/{entityType}/{entityId}/{locale}" \
  -H "Authorization: Bearer $KANORIO_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"fieldData":{"description":"Fermentation lente de 48 heures, farine complète **moulue sur meule**."},"sourceHash":"c1a0e6f2b47d93e8","translatedBy":"AGENT"}'
```

```json
{
  "translation": {
    "entityType": "product",
    "entityId": "prod_example01",
    "locale": "fr-FR",
    "status": "PUBLISHED",
    "translatedBy": "AGENT",
    "fieldCount": 2,
    "sourceHash": "c1a0e6f2b47d93e8",
    "updatedAt": "2026-10-06T08:00:00.000Z",
    "publishedAt": "2026-10-01T02:30:00.000Z",
    "fieldData": {
      "title": "Pain au levain de campagne",
      "description": "Fermentation lente de 48 heures, farine complète **moulue sur meule**."
    },
    "publishedFieldData": {
      "title": "Pain au levain de campagne",
      "description": "Fermentation lente de 48 heures, farine complète **moulue sur meule**."
    },
    "fieldMeta": {
      "title": {
        "translatedBy": "AGENT",
        "sourceHash": "5e1b0c9a"
      },
      "description": {
        "translatedBy": "AGENT",
        "sourceHash": "0b7c44d2"
      }
    },
    "slug": null,
    "publishedSlug": null
  },
  "live": true
}
```

### Delete a translation

`DELETE /sites/{id}/translations/{entityType}/{entityId}/{locale}`

- Stability: beta
- Permission: `content:edit`
- Rate limit: default (120/minute per token)
- MCP tool: `localization_translations_delete`

Delete one record's translation in one locale (draft and live copies); visitors see the primary text until it is translated again. With auto-translate on, the next change to the record is machine-translated again. Errors: FORBIDDEN, VALIDATION, TRANSLATION_NOT_FOUND.

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | Website id. Get it from sites.list or from sites.create. |
| `entityType` | path | string | yes | Translatable record type. page (pages.list ids), global_sections, site_settings and store_settings (the site itself: entityId defaults to websiteId), post / post_category / post_tag / author (blog), product / product_option / pricing_tier / product_category, deliverable / deliverable_unit, form, top_bar_banner / modal_announcement / floating_widget, collection_item (custom collection items), message_template (an automated email whose content the owner changed; the id of the customized template — untouched emails use the built-in version for every language). |
| `entityId` | path | string | yes | Record id. Required except for global_sections and site_settings, whose id is the websiteId. |
| `locale` | path | string | yes | BCP 47 locale code exactly as listed by localization.locales.list (e.g. en-US, ja, zh-Hant). |

**Response** (200)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `deleted` | true | yes | — |

**Errors**

| Code | Status | When |
| --- | --- | --- |
| `FORBIDDEN` | 403 | — |
| `VALIDATION` | 400 | — |
| `TRANSLATION_NOT_FOUND` | 404 | — |

```bash
curl -X DELETE "https://app.kanorio.com/api/v1/sites/{id}/translations/{entityType}/{entityId}/{locale}" \
  -H "Authorization: Bearer $KANORIO_TOKEN"
```

```json
{
  "deleted": true
}
```

### Publish a translation

`POST /sites/{id}/translations/{entityType}/{entityId}/{locale}/publish`

- Stability: beta
- Permission: `content:edit`
- Rate limit: default (120/minute per token)
- MCP tool: `localization_translations_publish`

Put one record's draft translation in one locale live without publishing the record's primary-language draft (for pages, global sections, site settings, blog posts and collection items, whose translations otherwise go live when the record is published). Visitors see it only where the record itself is live. For those record types this needs the record's publish permission (site:publish, blog:publish or collections:publish), not just edit access. Errors: FORBIDDEN, VALIDATION, TRANSLATION_NOT_FOUND.

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | Website id. Get it from sites.list or from sites.create. |
| `entityType` | path | string | yes | Translatable record type. page (pages.list ids), global_sections, site_settings and store_settings (the site itself: entityId defaults to websiteId), post / post_category / post_tag / author (blog), product / product_option / pricing_tier / product_category, deliverable / deliverable_unit, form, top_bar_banner / modal_announcement / floating_widget, collection_item (custom collection items), message_template (an automated email whose content the owner changed; the id of the customized template — untouched emails use the built-in version for every language). |
| `entityId` | path | string | yes | Record id. Required except for global_sections and site_settings, whose id is the websiteId. |
| `locale` | path | string | yes | BCP 47 locale code exactly as listed by localization.locales.list (e.g. en-US, ja, zh-Hant). |

**Response** (200)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `translation` | object | yes | — |
| `translation.entityType` | string | yes | — |
| `translation.entityId` | string | yes | — |
| `translation.locale` | string | yes | — |
| `translation.status` | "DRAFT" \| "PUBLISHED" | yes | PUBLISHED = the published copy is live (it may differ from the draft copy until the record is published again). |
| `translation.translatedBy` | "MACHINE" \| "HUMAN" \| "AGENT" \| "EXTERNAL" \| "SEO_FIX" | yes | Most significant author across fields: HUMAN > SEO_FIX > AGENT > EXTERNAL > MACHINE. |
| `translation.fieldCount` | integer | yes | Number of translated field paths in the draft copy. |
| `translation.sourceHash` | string | yes | Source fingerprint the draft copy was written against. |
| `translation.updatedAt` | string | yes | — |
| `translation.publishedAt` | string \| null | yes | — |
| `translation.fieldData` | object | yes | Draft copy: field path → translated text. |
| `translation.publishedFieldData` | object \| null | yes | Live copy (null until published). |
| `translation.fieldMeta` | object | yes | — |
| `translation.slug` | string \| null | yes | — |
| `translation.publishedSlug` | string \| null | yes | — |

**Errors**

| Code | Status | When |
| --- | --- | --- |
| `FORBIDDEN` | 403 | — |
| `VALIDATION` | 400 | — |
| `TRANSLATION_NOT_FOUND` | 404 | — |

```bash
curl -X POST "https://app.kanorio.com/api/v1/sites/{id}/translations/{entityType}/{entityId}/{locale}/publish" \
  -H "Authorization: Bearer $KANORIO_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

```json
{
  "translation": {
    "entityType": "page",
    "entityId": "pg_example789",
    "locale": "fr-FR",
    "status": "PUBLISHED",
    "translatedBy": "AGENT",
    "fieldCount": 2,
    "sourceHash": "c1a0e6f2b47d93e8",
    "updatedAt": "2026-10-06T08:00:00.000Z",
    "publishedAt": "2026-10-01T02:30:00.000Z",
    "fieldData": {
      "title": "Pain au levain de campagne",
      "description": "Fermentation lente de 36 heures, farine complète **moulue sur meule**."
    },
    "publishedFieldData": {
      "title": "Pain au levain de campagne",
      "description": "Fermentation lente de 36 heures, farine complète **moulue sur meule**."
    },
    "fieldMeta": {
      "title": {
        "translatedBy": "AGENT",
        "sourceHash": "5e1b0c9a"
      },
      "description": {
        "translatedBy": "MACHINE",
        "sourceHash": "9f3d2a71"
      }
    },
    "slug": null,
    "publishedSlug": null
  }
}
```

### Translation translate

`POST /sites/{id}/translations/{entityType}/{entityId}/{locale}/translate`

- Stability: beta
- Permission: `content:edit`
- Rate limit: heavy (20/minute per token, plus 20/minute per site)
- MCP tool: `localization_translations_translate`

Machine-translate one record into one secondary locale now (synchronous; only missing and outdated fields; fields written by a person, an agent or an external tool are re-translated using their wording as reference). version draft (default) translates the editable draft and follows the record's publishing; version published translates the live content and goes live immediately. Counts toward the website's daily translation limit (no AI credits). Errors: FORBIDDEN, VALIDATION, ENTITY_NOT_FOUND, LOCALE_NOT_FOUND, LOCALE_IS_PRIMARY, DAILY_LIMIT_REACHED.

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | Website id. Get it from sites.list or from sites.create. |
| `entityType` | path | string | yes | Translatable record type. page (pages.list ids), global_sections, site_settings and store_settings (the site itself: entityId defaults to websiteId), post / post_category / post_tag / author (blog), product / product_option / pricing_tier / product_category, deliverable / deliverable_unit, form, top_bar_banner / modal_announcement / floating_widget, collection_item (custom collection items), message_template (an automated email whose content the owner changed; the id of the customized template — untouched emails use the built-in version for every language). |
| `entityId` | path | string | yes | Record id. Required except for global_sections and site_settings, whose id is the websiteId. |
| `locale` | path | string | yes | BCP 47 locale code exactly as listed by localization.locales.list (e.g. en-US, ja, zh-Hant). |

**Request body**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `version` | "draft" \| "published" | no | — |

**Response** (200)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `outcome` | "done" \| "skipped" \| "partial" | yes | skipped = already up to date; partial = some fields failed (failedPaths), retry later. |
| `failedPaths` | string[] | yes | — |
| `translation` | object \| null | yes | — |
| `translation.entityType` | string | yes | — |
| `translation.entityId` | string | yes | — |
| `translation.locale` | string | yes | — |
| `translation.status` | "DRAFT" \| "PUBLISHED" | yes | PUBLISHED = the published copy is live (it may differ from the draft copy until the record is published again). |
| `translation.translatedBy` | "MACHINE" \| "HUMAN" \| "AGENT" \| "EXTERNAL" \| "SEO_FIX" | yes | Most significant author across fields: HUMAN > SEO_FIX > AGENT > EXTERNAL > MACHINE. |
| `translation.fieldCount` | integer | yes | Number of translated field paths in the draft copy. |
| `translation.sourceHash` | string | yes | Source fingerprint the draft copy was written against. |
| `translation.updatedAt` | string | yes | — |
| `translation.publishedAt` | string \| null | yes | — |
| `translation.fieldData` | object | yes | Draft copy: field path → translated text. |
| `translation.publishedFieldData` | object \| null | yes | Live copy (null until published). |
| `translation.fieldMeta` | object | yes | — |
| `translation.slug` | string \| null | yes | — |
| `translation.publishedSlug` | string \| null | yes | — |

**Errors**

| Code | Status | When |
| --- | --- | --- |
| `FORBIDDEN` | 403 | — |
| `VALIDATION` | 400 | — |
| `ENTITY_NOT_FOUND` | 404 | — |
| `LOCALE_NOT_FOUND` | 404 | — |
| `LOCALE_IS_PRIMARY` | 422 | — |
| `DAILY_LIMIT_REACHED` | 429 | — |

```bash
curl -X POST "https://app.kanorio.com/api/v1/sites/{id}/translations/{entityType}/{entityId}/{locale}/translate" \
  -H "Authorization: Bearer $KANORIO_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"version":"draft"}'
```

```json
{
  "outcome": "done",
  "failedPaths": [],
  "translation": {
    "entityType": "product",
    "entityId": "prod_example01",
    "locale": "fr-FR",
    "status": "PUBLISHED",
    "translatedBy": "AGENT",
    "fieldCount": 2,
    "sourceHash": "c1a0e6f2b47d93e8",
    "updatedAt": "2026-10-06T08:00:00.000Z",
    "publishedAt": "2026-10-01T02:30:00.000Z",
    "fieldData": {
      "title": "Pain au levain de campagne",
      "description": "Fermentation lente de 36 heures, farine complète **moulue sur meule**."
    },
    "publishedFieldData": {
      "title": "Pain au levain de campagne",
      "description": "Fermentation lente de 36 heures, farine complète **moulue sur meule**."
    },
    "fieldMeta": {
      "title": {
        "translatedBy": "AGENT",
        "sourceHash": "5e1b0c9a"
      },
      "description": {
        "translatedBy": "MACHINE",
        "sourceHash": "9f3d2a71"
      }
    },
    "slug": null,
    "publishedSlug": null
  }
}
```

### List jobs

`GET /sites/{id}/translation-jobs`

- Stability: beta
- Permission: `console:view`
- Rate limit: default (120/minute per token)
- MCP tool: `localization_jobs_list`

List the current (running or most recent) full-site translation job of each locale, with progress and failed records. Errors: FORBIDDEN.

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | Website id. Get it from sites.list or from sites.create. |

**Response** (200)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `data` | object[] | yes | — |
| `data[].id` | string | yes | — |
| `data[].locale` | string | yes | — |
| `data[].kind` | "FULL" \| "PRIMARY_SWAP" | yes | — |
| `data[].status` | "PENDING" \| "RUNNING" \| "PAUSED" \| "SUCCEEDED" \| "FAILED" | yes | PAUSED = waiting for the daily translation limit or the model provider; resumes on its own at resumeAt. |
| `data[].stage` | string \| null | yes | — |
| `data[].progress` | object \| null | yes | — |
| `data[].progress.stage` | string | yes | — |
| `data[].progress.total` | integer | yes | Records to translate. |
| `data[].progress.done` | integer | yes | — |
| `data[].progress.failed` | integer | yes | — |
| `data[].progress.skipped` | integer | yes | Records already up to date. |
| `data[].progress.calls` | integer | yes | Model calls used. |
| `data[].progress.failedEntities` | object[] | yes | At most 50, most recent first. |
| `data[].errorCode` | string \| null | yes | CANCELLED when cancelled; otherwise the failure reason. |
| `data[].resumeAt` | string \| null | yes | — |
| `data[].createdAt` | string | yes | — |
| `data[].completedAt` | string \| null | yes | — |
| `hasMore` | boolean | yes | True when another page exists; pass nextCursor to fetch it. |
| `nextCursor` | string \| null | yes | Cursor for the next page, or null on the last page. |

**Errors**

| Code | Status | When |
| --- | --- | --- |
| `FORBIDDEN` | 403 | — |

```bash
curl -X GET "https://app.kanorio.com/api/v1/sites/{id}/translation-jobs" \
  -H "Authorization: Bearer $KANORIO_TOKEN"
```

```json
{
  "data": [
    {
      "id": "ltj_example01",
      "locale": "de",
      "kind": "FULL",
      "status": "RUNNING",
      "stage": "translating",
      "progress": {
        "stage": "translating",
        "total": 42,
        "done": 18,
        "failed": 0,
        "skipped": 3,
        "calls": 11,
        "failedEntities": []
      },
      "errorCode": null,
      "resumeAt": null,
      "createdAt": "2026-10-06T08:00:00.000Z",
      "completedAt": null
    },
    {
      "id": "ltj_example00",
      "locale": "fr-FR",
      "kind": "FULL",
      "status": "SUCCEEDED",
      "stage": "done",
      "progress": {
        "stage": "translating",
        "total": 42,
        "done": 18,
        "failed": 0,
        "skipped": 3,
        "calls": 11,
        "failedEntities": []
      },
      "errorCode": null,
      "resumeAt": null,
      "createdAt": "2026-10-06T08:00:00.000Z",
      "completedAt": "2026-10-01T02:30:00.000Z"
    }
  ],
  "hasMore": false,
  "nextCursor": null
}
```

### Retry a job

`POST /sites/{id}/translation-jobs`

- Stability: beta
- Permission: `site:manage_settings`
- Rate limit: default (120/minute per token)
- MCP tool: `localization_jobs_retry`

Run the full-site translation job for a secondary locale (again): it translates only records that are missing or whose source changed, never overwrites fields written by a person, an agent or an external tool, and pauses (not fails) at the daily translation limit. Use it after a job failed or was cancelled, or to fill gaps on a locale with autoTranslate off. If a job for that locale is already queued, running or paused, returns it with alreadyRunning true. Errors: FORBIDDEN, LOCALE_NOT_FOUND, UNSUPPORTED_LOCALE.

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | Website id. Get it from sites.list or from sites.create. |

**Request body**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `locale` | string | yes | BCP 47 locale code exactly as listed by localization.locales.list (e.g. en-US, ja, zh-Hant). |

**Response** (201)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `jobId` | string | yes | — |
| `alreadyRunning` | boolean | yes | — |

**Errors**

| Code | Status | When |
| --- | --- | --- |
| `FORBIDDEN` | 403 | — |
| `LOCALE_NOT_FOUND` | 404 | — |
| `UNSUPPORTED_LOCALE` | 400 | — |

```bash
curl -X POST "https://app.kanorio.com/api/v1/sites/{id}/translation-jobs" \
  -H "Authorization: Bearer $KANORIO_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"locale":"de"}'
```

```json
{
  "jobId": "ltj_example01",
  "alreadyRunning": false
}
```

### Retrieve a job

`GET /sites/{id}/translation-jobs/{jobId}`

- Stability: beta
- Permission: `console:view`
- Rate limit: default (120/minute per token)
- MCP tool: `localization_jobs_get`

Get one full-site translation job by id. Poll every 10-30 seconds until status is SUCCEEDED or FAILED. Errors: FORBIDDEN, JOB_NOT_FOUND.

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | Website id. Get it from sites.list or from sites.create. |
| `jobId` | path | string | yes | Job id from localization.jobs.list or localization.jobs.retry. |

**Response** (200)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `job` | object | yes | — |
| `job.id` | string | yes | — |
| `job.locale` | string | yes | — |
| `job.kind` | "FULL" \| "PRIMARY_SWAP" | yes | — |
| `job.status` | "PENDING" \| "RUNNING" \| "PAUSED" \| "SUCCEEDED" \| "FAILED" | yes | PAUSED = waiting for the daily translation limit or the model provider; resumes on its own at resumeAt. |
| `job.stage` | string \| null | yes | — |
| `job.progress` | object \| null | yes | — |
| `job.progress.stage` | string | yes | — |
| `job.progress.total` | integer | yes | Records to translate. |
| `job.progress.done` | integer | yes | — |
| `job.progress.failed` | integer | yes | — |
| `job.progress.skipped` | integer | yes | Records already up to date. |
| `job.progress.calls` | integer | yes | Model calls used. |
| `job.progress.failedEntities` | object[] | yes | At most 50, most recent first. |
| `job.errorCode` | string \| null | yes | CANCELLED when cancelled; otherwise the failure reason. |
| `job.resumeAt` | string \| null | yes | — |
| `job.createdAt` | string | yes | — |
| `job.completedAt` | string \| null | yes | — |

**Errors**

| Code | Status | When |
| --- | --- | --- |
| `FORBIDDEN` | 403 | — |
| `JOB_NOT_FOUND` | 404 | — |

```bash
curl -X GET "https://app.kanorio.com/api/v1/sites/{id}/translation-jobs/{jobId}" \
  -H "Authorization: Bearer $KANORIO_TOKEN"
```

```json
{
  "job": {
    "id": "ltj_example01",
    "locale": "de",
    "kind": "FULL",
    "status": "RUNNING",
    "stage": "translating",
    "progress": {
      "stage": "translating",
      "total": 42,
      "done": 18,
      "failed": 0,
      "skipped": 3,
      "calls": 11,
      "failedEntities": []
    },
    "errorCode": null,
    "resumeAt": null,
    "createdAt": "2026-10-06T08:00:00.000Z",
    "completedAt": null
  }
}
```

### Cancel a job

`POST /sites/{id}/translation-jobs/{jobId}/cancel`

- Stability: beta
- Permission: `site:manage_settings`
- Rate limit: default (120/minute per token)
- MCP tool: `localization_jobs_cancel`

Cancel a queued or paused full-site translation job (a running batch finishes first). Translations already written are kept. Errors: FORBIDDEN, JOB_NOT_FOUND, JOB_NOT_CANCELLABLE.

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | Website id. Get it from sites.list or from sites.create. |
| `jobId` | path | string | yes | — |

**Response** (200)

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `job` | object | yes | — |
| `job.id` | string | yes | — |
| `job.locale` | string | yes | — |
| `job.kind` | "FULL" \| "PRIMARY_SWAP" | yes | — |
| `job.status` | "PENDING" \| "RUNNING" \| "PAUSED" \| "SUCCEEDED" \| "FAILED" | yes | PAUSED = waiting for the daily translation limit or the model provider; resumes on its own at resumeAt. |
| `job.stage` | string \| null | yes | — |
| `job.progress` | object \| null | yes | — |
| `job.progress.stage` | string | yes | — |
| `job.progress.total` | integer | yes | Records to translate. |
| `job.progress.done` | integer | yes | — |
| `job.progress.failed` | integer | yes | — |
| `job.progress.skipped` | integer | yes | Records already up to date. |
| `job.progress.calls` | integer | yes | Model calls used. |
| `job.progress.failedEntities` | object[] | yes | At most 50, most recent first. |
| `job.errorCode` | string \| null | yes | CANCELLED when cancelled; otherwise the failure reason. |
| `job.resumeAt` | string \| null | yes | — |
| `job.createdAt` | string | yes | — |
| `job.completedAt` | string \| null | yes | — |

**Errors**

| Code | Status | When |
| --- | --- | --- |
| `FORBIDDEN` | 403 | — |
| `JOB_NOT_FOUND` | 404 | — |
| `JOB_NOT_CANCELLABLE` | 409 | — |

```bash
curl -X POST "https://app.kanorio.com/api/v1/sites/{id}/translation-jobs/{jobId}/cancel" \
  -H "Authorization: Bearer $KANORIO_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

```json
{
  "job": {
    "id": "ltj_example01",
    "locale": "de",
    "kind": "FULL",
    "status": "FAILED",
    "stage": "cancelled",
    "progress": {
      "stage": "translating",
      "total": 42,
      "done": 18,
      "failed": 0,
      "skipped": 3,
      "calls": 11,
      "failedEntities": []
    },
    "errorCode": "CANCELLED",
    "resumeAt": null,
    "createdAt": "2026-10-06T08:00:00.000Z",
    "completedAt": "2026-10-06T08:00:00.000Z"
  }
}
```
