# Translations

List, create, update and delete translation keys and values, change status, run batch operations and subscribe to key events through the Comvi REST API.

Canonical: https://comvi.io/docs/api/translations

A translation key belongs to a project and a namespace and holds one value per locale.

Every endpoint accepts a session cookie or an API key, except the `/v1/keys/` routes, which take a key only. Reads need `translations:read`, writes `translations:write`, deletes `translations:delete`. The two status endpoints also need `translations:review` when the new status is `translated`; every other endpoint writes `"status": "translated"` with `translations:write` alone.

Common errors: `401 AUTHENTICATION_REQUIRED`, `403 FORBIDDEN` (no permission, or no access to the locale), `403 READ_ONLY_MODE` on writes, `409 TRANSACTION_CONFLICT` on writes (a deadlock, retry), `422 FIELD_REQUIRED`, `FIELD_TOO_LONG` and `VALIDATION_ERROR` with `meta.field` and no `message`. See [Errors](/docs/api/errors).

> **caution**
>
> Some `404` and `400` rows declare a response schema of `{ message }` only, so the serializer drops `code` and `meta`. Those rows show a body instead of a code; a client that branches on `code` alone will not see them.

## Key object

```json
{
"id": 42, "key": "welcome.message", "namespace": "default", "namespaceId": 1,
"description": "Main welcome message on the homepage", "isPlural": false, "characterLimit": null,
"translations": { "en": { "id": 101, "value": "Welcome to our app!", "status": "translated", "createdAt": "2025-01-15T10:30:00.000Z", "updatedAt": "2025-01-15T10:30:00.000Z", "createdBy": 1 } },
"createdAt": "2025-01-15T10:30:00.000Z", "updatedAt": "2025-01-15T11:00:00.000Z"
}
```

Statuses are `translated`, `not_reviewed` and `not_translated`.

### `GET /v1/projects/{projectId}/keys`

List keys with cursor pagination and filters. The envelope is `{ data, pagination }`; keep calling with `cursor: nextCursor` while `pagination.hasMore` is `true`. `totalItems` is never filled in.

| Parameter | In | Type | Required | Meaning |
|-----------|----|------|----------|---------|
| `projectId` | path | `integer` | Yes | Project ID |
| `cursor` | query | `string` | No | `nextCursor` from the previous response |
| `limit` | query | `integer` | No | 1 to 100, default 50 |
| `search` | query | `string` | No | Case-insensitive substring on the key name or any value |
| `namespaceIds` | query | `integer[]` | No | Filter by namespace |
| `statuses` | query | `string[]` | No | Filter by status |
| `localeCodes` | query | `string[]` | No | Filter by locale |
| `sortBy` | query | `string` | No | `key`, `updatedAt`, `createdAt` |
| `sortOrder` | query | `string` | No | `asc`, `desc` |
| `includeContext` | query | `boolean` | No | Inline each key's collected context, in the shape of `GET …/keys/{keyId}/context` |

```bash
curl -H "Authorization: Bearer $COMVI_API_KEY" "https://api.comvi.io/v1/projects/1/keys?limit=20&search=welcome&sortBy=key"
```

```json
{ "data": [{ "id": 42, "key": "welcome.message", "…": "…" }], "pagination": { "nextCursor": "eyJpZCI6NDN9", "hasMore": true } }
```

| Status and code | When |
|-----------------|------|
| `400 INVALID_INPUT` | `cursor` is not one this endpoint issued |
| `422 VALIDATION_ERROR` | `limit` outside 1 to 100, or `sortBy`/`sortOrder` not in its enum |

### `GET /v1/keys/{namespace}/{key}`

Read one key with all its values. The project comes from the API key.

Path: `namespace` (name) and `key` (name, URL-encoded), both required.

```bash
curl -H "Authorization: Bearer $COMVI_API_KEY" https://api.comvi.io/v1/keys/default/welcome.message
```

Returns the key object.

| Status and code | When |
|-----------------|------|
| `401 UNAUTHORIZED` | A session cookie was used. Message: `API key authentication required` |
| `403 FORBIDDEN` | The key lacks `translations:read` |
| `404 { "message": "…" }` | No key by that name in that namespace. No `code` |

### `PUT /v1/keys/`

Create a key, or merge new translations into an existing one. The project comes from the API key. Idempotent: the same body twice leaves the same result. A `namespace` that does not exist is created when the key holds `translations:write`, so a push from a fresh checkout needs no setup. Creating needs `translations:write`; updating an existing key also asserts `translation.view`, from `translations:read`, so a key with `translations:write` alone creates on the first push and gets `403 FORBIDDEN` on the second. Give a pushing key both.

Body: the fields of `POST /v1/projects/{projectId}/keys`.

```bash
curl -X PUT -H "Authorization: Bearer $COMVI_API_KEY" -H "Content-Type: application/json" \
-d '{"key": "welcome.message", "namespace": "marketing", "translations": {"en": {"value": "Welcome to our app!", "status": "translated"}}}' \
https://api.comvi.io/v1/keys/
```

Returns `201 Created` when created, `200 OK` when updated, with the key object.

| Status and code | When |
|-----------------|------|
| `400 { "message": "…" }` | The namespace could not be created, or the project has no default namespace. No `code` |
| `401 UNAUTHORIZED` | A session cookie was used |
| `403 FORBIDDEN` | Missing `translations:write`, or `translations:read` when the key exists |
| `403 LIMIT_EXCEEDED` | The plan's key limit. `meta` carries `metric`, `current`, `limit` |
| `404 NAMESPACE_NOT_FOUND` | `namespaceId` names no namespace |
| `422 FIELD_REQUIRED` | `key` is missing |
| `422 FIELD_TOO_LONG` | `key`, `namespace`, `description` or `aiContext` over its limit |

### `DELETE /v1/keys/{namespace}/{key}`

Soft-delete a key by namespace and name. API key with `translations:delete`; `translations:write` is not enough.

```bash
curl -X DELETE -H "Authorization: Bearer $COMVI_API_KEY" https://api.comvi.io/v1/keys/marketing/welcome.message
```

Returns `204 No Content`.

| Status and code | When |
|-----------------|------|
| `401 UNAUTHORIZED` | A session cookie was used |
| `403 FORBIDDEN` | The key lacks `translations:delete` |
| `404 { "message": "…" }` | No key by that name in that namespace. No `code` |

### `POST /v1/projects/{projectId}/keys`

Create a key, optionally with initial values.

| Parameter | In | Type | Required | Meaning |
|-----------|----|------|----------|---------|
| `projectId` | path | `integer` | Yes | Project ID |
| `key` | body | `string` | Yes | 1 to 768 characters |
| `namespace` | body | `string` | No | Namespace name, 1 to 255 characters, default `default` |
| `namespaceId` | body | `integer` | No | Namespace ID, alternative to `namespace` |
| `description` | body | `string` | No | Context for translators, up to 5000 characters |
| `aiContext` | body | `string` | No | Context for AI providers, up to 2000 characters |
| `isPlural` | body | `boolean` | No | ICU plural key |
| `characterLimit` | body | `integer` | No | 1 or greater |
| `translations` | body | `object` | No | Per locale code: `{ "value": string, "status": string }`, both required. `value` up to 65,536 characters |

```bash
curl -X POST -H "Authorization: Bearer $COMVI_API_KEY" -H "Content-Type: application/json" \
-d '{"key": "welcome.message", "namespace": "default", "translations": {"en": {"value": "Welcome to our app!", "status": "translated"}}}' \
https://api.comvi.io/v1/projects/1/keys
```

Returns `201 Created` with the key object.

| Status and code | When |
|-----------------|------|
| `400 TRANSLATION_KEY_ALREADY_EXISTS` | The name exists in the namespace. A `409` here is a retryable transaction conflict, so read `code` before retrying |
| `403 LIMIT_EXCEEDED` | The plan's key limit |
| `422 FIELD_TOO_LONG` | `key`, `namespace`, `description` or `aiContext` over its limit |
| `422 VALIDATION_ERROR` | Wrong type, or `characterLimit` not a positive integer |

### `PATCH /v1/projects/{projectId}/keys/{keyId}`

Update a key's metadata. Values go through `PUT …/keys/{key}`.

| Parameter | In | Type | Required | Meaning |
|-----------|----|------|----------|---------|
| `projectId` | path | `integer` | Yes | Project ID |
| `keyId` | path | `integer` | Yes | Key ID |
| `key` | body | `string` | No | 1 to 768 characters |
| `namespaceId` | body | `integer` | No | Move to another namespace |
| `description` | body | `string \| null` | No | Up to 5000 characters |
| `aiContext` | body | `string \| null` | No | Up to 2000 characters |
| `isPlural` | body | `boolean` | No | ICU plural key |
| `characterLimit` | body | `integer \| null` | No | 1 or greater |

```bash
curl -X PATCH -H "Authorization: Bearer $COMVI_API_KEY" -H "Content-Type: application/json" \
-d '{"description": "Updated welcome message", "characterLimit": 100}' https://api.comvi.io/v1/projects/1/keys/42
```

Returns the updated key object.

| Status and code | When |
|-----------------|------|
| `400 VALIDATION_ERROR` | `namespaceId` is in another project, or `characterLimit` is shorter than an existing value; then `meta` carries `limit`, `maxExistingLength`, `keyId` |
| `404 { "message": "Key not found" }` | No such key. No `code` |
| `409 VALIDATION_ERROR` | The new name exists in the target namespace |
| `422 FIELD_TOO_LONG` | `key`, `description` or `aiContext` over its limit |
| `422 VALIDATION_ERROR` | Wrong type or out of range |

### `PUT /v1/projects/{projectId}/keys/{key}`

Create or overwrite the value of one key in one locale.

| Parameter | In | Type | Required | Meaning |
|-----------|----|------|----------|---------|
| `projectId` | path | `integer` | Yes | Project ID |
| `key` | path | `string` | Yes | Key name, URL-encoded |
| `namespace` | query | `string` | No | Default `default` |
| `locale` | body | `string` | Yes | Locale code |
| `value` | body | `string` | Yes | Translation text, up to 65,536 characters |
| `status` | body | `string` | No | Status |

```bash
curl -X PUT -H "Authorization: Bearer $COMVI_API_KEY" -H "Content-Type: application/json" \
-d '{"locale": "uk", "value": "Ласкаво просимо!", "status": "translated"}' \
"https://api.comvi.io/v1/projects/1/keys/welcome.message?namespace=default"
```

```json
{ "id": 102, "value": "Ласкаво просимо!", "status": "translated", "createdAt": "2025-01-15T11:00:00.000Z", "updatedAt": "2025-01-16T09:15:00.000Z", "createdBy": 1 }
```

| Status and code | When |
|-----------------|------|
| `400 LOCALE_NOT_ENABLED` | `locale` is not on the project |
| `400 VALIDATION_ERROR` | `value` is longer than the key's `characterLimit`; `meta` carries `limit` and `actual` |
| `404 TRANSLATION_KEY_NOT_FOUND` | No such key |
| `422 FIELD_REQUIRED` | `locale` or `value` is missing |
| `422 VALIDATION_ERROR` | `status` not in the enum |

### `DELETE /v1/projects/{projectId}/keys/{keyId}`

Soft-delete a key and all its values.

```bash
curl -X DELETE -H "Authorization: Bearer $COMVI_API_KEY" https://api.comvi.io/v1/projects/1/keys/42
```

Returns `204 No Content`.

| Status and code | When |
|-----------------|------|
| `404 { "message": "Translation key not found" }` | No such key. No `code` |

### `PUT /v1/projects/{projectId}/translation/{translationId}/status`

Change the review status of one value. Every status needs `translations:write` (`translation.edit` for the locale); `translated` also needs `translations:review`. A key with `translations:review` alone fails the write check first, with `403 FORBIDDEN` and `meta.requiredPermission` set to `translation.edit`.

| Parameter | In | Type | Required | Meaning |
|-----------|----|------|----------|---------|
| `projectId` | path | `integer` | Yes | Project ID |
| `translationId` | path | `integer` | Yes | Translation value ID |
| `status` | body | `string` | Yes | New status |

```bash
curl -X PUT -H "Authorization: Bearer $COMVI_API_KEY" -H "Content-Type: application/json" \
-d '{"status": "translated"}' https://api.comvi.io/v1/projects/1/translation/102/status
```

Returns `204 No Content`. Read `reviewedBy` and `reviewedAt` back from the key list.

| Status and code | When |
|-----------------|------|
| `404 TRANSLATION_KEY_NOT_FOUND` | No such translation, or it belongs to another project |
| `422 FIELD_REQUIRED` | `status` is missing |
| `422 VALIDATION_ERROR` | `status` not one of the three values |

### `GET /v1/projects/{projectId}/keys/ids`

The IDs of every key matching a filter, without pagination. Query: `search`, `namespaceIds`, `statuses`, `localeCodes`, all optional, as on the key list.

```bash
curl -H "Authorization: Bearer $COMVI_API_KEY" "https://api.comvi.io/v1/projects/1/keys/ids?search=welcome"
```

```json
{ "ids": [42, 43, 87], "count": 3 }
```

### `GET /v1/projects/{projectId}/keys/{keyId}/context`

The UI context collected for one key: the element it renders in, its role, the space available, and its neighbours. Machine translation reads the same signal. Read-only; context is collected from running apps.

```bash
curl -H "Authorization: Bearer $COMVI_API_KEY" https://api.comvi.io/v1/projects/1/keys/42/context
```

```json
{
"observed": true, "hasSignal": true,
"context": { "uiType": "button", "translationRole": "action-label", "lengthHint": "short", "neighbors": [{ "role": "sibling", "namespace": "default", "key": "checkout.cancel", "text": "Cancel" }], "screenGroups": ["checkout"], "lastSeenAt": "2026-03-20T09:00:00.000Z", "isStale": false }
}
```

`observed: false` means the key was never seen rendering in a running app. `observed: true` with `hasSignal: false` means it was seen but nothing useful resolved. Only when both are `true` is `context` present.

| Context field | Type | Meaning |
|---------------|------|---------|
| `uiType` | `string` | Kind of element |
| `translationRole` | `string` | Role in that element |
| `lengthHint` | `string \| null` | Room available |
| `neighbors` | `object[]` | Nearby strings, `{ role, namespace, key, text }` |
| `screenGroups` | `string[]` | Screens the key was seen on |
| `lastSeenAt` | `string \| null` | Last observation |
| `isStale` | `boolean` | The observation may be outdated. Read it before trusting the rest |
| `raw` | `unknown` | The unresolved profile, present on some responses |

| Status and code | When |
|-----------------|------|
| `404 { "message": "…" }` | No such key, or another project's. No `code` |

### `GET /v1/projects/{projectId}/keys/tm/search`

Find similar source text already translated in this project. It searches the project's own values; the organization TMs are searched with [`POST /v1/projects/{projectId}/tm/search`](/docs/api/translation-memory#post-v1projectsprojectidtmsearch). `minSimilarity` here is a 0 to 1 fraction, while `similarity` in the response is a 0 to 100 integer.

| Parameter | In | Type | Required | Meaning |
|-----------|----|------|----------|---------|
| `projectId` | path | `integer` | Yes | Project ID |
| `sourceText` | query | `string` | Yes | Text to match |
| `targetLocale` | query | `string` | Yes | Locale wanted, 1 to 50 characters |
| `limit` | query | `integer` | No | 1 to 20 |
| `minSimilarity` | query | `number` | No | 0 to 1 |
| `excludeKeyId` | query | `integer` | No | Leave this key out |
| `excludeKey` | query | `string` | No | Leave this key name out |
| `excludeNamespace` | query | `string` | No | Narrows `excludeKey` to one namespace |

```bash
curl -H "Authorization: Bearer $COMVI_API_KEY" "https://api.comvi.io/v1/projects/1/keys/tm/search?sourceText=Welcome%20to%20our%20app&targetLocale=uk&excludeKeyId=42"
```

```json
{ "matches": [{ "keyId": 87, "key": "home.greeting", "sourceText": "Welcome to our app", "targetText": "Ласкаво просимо до нашого застосунку", "similarity": 96 }] }
```

| Status and code | When |
|-----------------|------|
| `422 FIELD_REQUIRED` | `sourceText` or `targetLocale` is missing |
| `422 VALIDATION_ERROR` | `limit` or `minSimilarity` out of range |

### `GET /v1/projects/{projectId}/keys/events`

A Server-Sent Events stream of changes in a project. Events are filtered by your language restrictions. Open streams are capped per project and per user; over the cap the request fails with `429 RATE_LIMIT_EXCEEDED`. A named `heartbeat` event with a timestamp is written every 15 seconds, so `EventSource` delivers it and a dropped connection can be detected.

```javascript
const source = new EventSource('https://api.comvi.io/v1/projects/1/keys/events', { withCredentials: true });
source.addEventListener('translation.upserted', (e) => console.log(JSON.parse(e.data)));
source.addEventListener('heartbeat', () => { /* reset a watchdog */ });
```

Every payload carries `projectId`, plus:

| Event | Fires when | Payload |
|-------|-----------|---------|
| `translation.upserted` | A value was written | `keyId`, `translationId`, `key`, `locale`, `namespace`, `value` (may be `null`), `status` |
| `key.created` | A key was created | `keyId`, `key`, `namespace`, `isPlural` |
| `key.updated` | Metadata changed | `keyId`, `key`, `namespace`, `changes` with boolean `description`, `aiContext`, `characterLimit`, `isPlural` |
| `key.deleted` | A key was deleted | `keyId`, `key`, `namespace` |
| `namespace.created` | A namespace was created, including by a push | `namespaceId`, `namespace` |
| `schema.updated` | A key's generated type surface changed | `keyId`, `key`, `namespace`, `params` (each `{ name, type, options?, required }`) |
| `project.locale.updated` | A language was added or removed | `locale`, `action` (`added` or `removed`). Spelled with dots; the webhook enum uses `project.locale_added` and `project.locale_removed` |
| `batch_mt.scheduled` | Batch MT jobs were queued | `sourceLocale`, `jobs` (each `{ jobId, targetLocale, keyCount }`), `totalKeys` |
| `batch_mt.started` | A job began | `jobId`, `totalKeys`, `sourceLocale`, `targetLocale` |
| `batch_mt.progress` | A job advanced | `jobId`, `processed`, `total`, `currentKey` |
| `batch_mt.completed` | A job finished | `jobId`, `processed`, `failed`, `failedKeyIds`, `errors` (each `{ keyId, key, message, type }`), `duration`. `type` is one of `no_source`, `no_translation`, `insufficient_credits`, `provider_error`, `unknown` |
| `batch_mt.failed` | A job failed outright | `jobId`, `error` |
| `cdn_deploy.started` | A CDN deploy began | nothing further |
| `cdn_deploy.completed` | A CDN deploy finished | `filesUploaded`, `filesFailed`, `duration`, `warningCount`, `warnings` (each `{ file, key, message }`) |
| `cdn_deploy.failed` | A CDN deploy failed | `error` |

| Status and code | When |
|-----------------|------|
| `429 RATE_LIMIT_EXCEEDED` | Too many open streams for the project or user |

An API key gets the same stream at `GET /v1/keys/events/stream`, for the key's project and with `translations:read`: only `translation.upserted`, `key.created`, `key.updated`, `key.deleted`, `namespace.created` and `schema.updated` are sent, in the locales the key's creator may see, and `?events=key.created,key.deleted` (up to 32 names) narrows them. The stream opens with a `connected` event carrying `{ projectId, events }`, then the same `heartbeat` events; streams are capped at 50 per project and 10 per key creator.

## Batch operations

Five endpoints under `/v1/projects/{projectId}/keys/batch/` act on many keys at once. They are not atomic: items that succeeded are not rolled back when others fail, so read `errors`. Each returns `{ success, processed, failed, duration?, errors: [{ keyId, key, message }] }`, `duration` in milliseconds.

```bash
curl -X POST -H "Authorization: Bearer $COMVI_API_KEY" -H "Content-Type: application/json" \
-d '{"keyIds": [42, 43, 44]}' https://api.comvi.io/v1/projects/1/keys/batch/delete
```

```json
{ "success": true, "processed": 3, "failed": 0, "errors": [] }
```

Every body carries `keyIds` (`integer[]`, required, 1 to 10,000 IDs) plus the fields listed per endpoint.

### `POST /v1/projects/{projectId}/keys/batch/delete`

Delete many keys. No further fields.

### `POST /v1/projects/{projectId}/keys/batch/status`

Set the status of many keys in one locale. Body: `localeCode` (`string`, required) and `status` (`string`, required). `translated` also needs `translations:review`, as on the single-value endpoint.

### `POST /v1/projects/{projectId}/keys/batch/namespace`

Move many keys. Body: `targetNamespaceId` (`integer`, required).

### `POST /v1/projects/{projectId}/keys/batch/copy`

Copy values from one locale to another. Body: `fromLocaleCode` (`string`, required), `toLocaleCode` (`string`, required), `overwriteExisting` (`boolean`, default `false`).

### `POST /v1/projects/{projectId}/keys/batch/clear`

Remove values in the given locales. Body: `localeCodes` (`string[]`, required).

| Status and code | When |
|-----------------|------|
| `422 FIELD_REQUIRED` | `keyIds`, or the locale field the operation needs, is missing |
| `422 VALIDATION_ERROR` | `keyIds` is empty or over 10,000, or a status is not in the enum |

## Next steps

- [Import/Export](/docs/api/import-export) for bulk data
- [Machine Translation](/docs/api/machine-translation) for suggestions and batch jobs
- [Namespaces](/docs/api/namespaces) and [Languages](/docs/api/languages) for the containers keys live in
