# Import/Export

Export translations as files or JSON and import them through the web or CLI flow of the Comvi REST API.

Canonical: https://comvi.io/docs/api/import-export

Comvi has two import and export flows. The web flow validates files, lets a person review conflicts, then commits. The CLI flow takes a plain translation object and commits it in two calls. Both read and write JSON and i18next v4.

Every endpoint accepts a session cookie or an API key, except the three CLI endpoints, which reject a session and require `Authorization: Bearer`. A key needs `translations:read` to export and `translations:write` to import. Common errors: `401 AUTHENTICATION_REQUIRED`, `403 FORBIDDEN` (missing `project.export` or `project.import`, or a key from another project), `403 READ_ONLY_MODE` on imports. See [Errors](/docs/api/errors).

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

Export translations as a downloadable file. Omitting `localeCodes` exports every locale; omitting `namespaces` exports every namespace, not only `default`. Language restrictions narrow both. A completed export fires the `export.completed` webhook; a download cut off before the stream ends sends nothing.

| Parameter | In | Type | Required | Meaning |
|-----------|----|------|----------|---------|
| `projectId` | path | `integer` | Yes | Project ID |
| `keyIds` | body | `integer[]` | No | Only these keys |
| `localeCodes` | body | `string[]` | No | Default all locales |
| `namespaces` | body | `string[]` | No | Default all namespaces |
| `format` | body | `string` | No | `json` or `i18next-v4`, default `json` |
| `statuses` | body | `string[]` | No | `translated`, `not_reviewed`, `not_translated` |
| `includeEmpty` | body | `boolean` | No | Include keys with no value, default `false` |
| `structure` | body | `string` | No | `flat` or `nested`, default `flat` |
| `style` | body | `string` | No | `pretty` or `minified`, default `pretty` |

The response carries `Content-Disposition: attachment`; use `curl -o`. The file depends on how many locale and namespace pairs the request resolves to:

| Request resolves to | Content-Type | Filename |
|---------------------|--------------|----------|
| A full export of one locale and one namespace | `application/json` | `<locale>.json` |
| A full export of anything else | `application/zip` | `<project name>-<YYYY-MM-DD>.zip`, with characters outside `A-Za-z0-9-_` in the name replaced by `_` |
| A `keyIds` batch export for one locale | `application/json` | `<locale>.json`. Namespaces are flattened into it, and keys outside `default` are prefixed `<namespace>.` |
| A `keyIds` batch export for two or more locales | `application/zip` | `translations-selected-<YYYY-MM-DD>.zip` |

```bash
curl -X POST -H "Authorization: Bearer $COMVI_API_KEY" -H "Content-Type: application/json" \
-d '{"localeCodes": ["en"], "namespaces": ["default"], "format": "json", "structure": "flat"}' \
-o en.json https://api.comvi.io/v1/projects/1/keys/export
```

```json
{ "welcome.message": "Welcome to our app!", "welcome.subtitle": "Get started in seconds" }
```

| Status and code | When |
|-----------------|------|
| `404 PROJECT_NOT_FOUND` | No such project |
| `422 VALIDATION_ERROR` | `format`, `structure` or `style` is not a listed value. `meta.field` names it, no `message` |

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

CLI export: one JSON body grouped by namespace and locale. API key only; a session gets `401 UNAUTHORIZED` with the message `API key authentication required`, and a key for another project gets `403 FORBIDDEN`. Omitting `locales` returns every locale you are allowed to see, never more than your allowed set.

| Parameter | In | Type | Required | Meaning |
|-----------|----|------|----------|---------|
| `projectId` | path | `integer` | Yes | Project ID |
| `locales` | query | `string` | No | Comma-separated locale codes |
| `namespaces` | query | `string` | No | Comma-separated namespace names |
| `statuses` | query | `string` | No | Comma-separated statuses |
| `includeEmpty` | query | `boolean` | No | Include keys with no value |

```bash
curl -H "Authorization: Bearer $COMVI_API_KEY" "https://api.comvi.io/v1/projects/1/export?locales=en,uk&namespaces=default"
```

```json
{
"locales": ["en", "uk"],
"namespaces": { "default": { "en": { "welcome.message": "Welcome to our app!" }, "uk": { "welcome.message": "Ласкаво просимо!" } } }
}
```

| Status and code | When |
|-----------------|------|
| `401 UNAUTHORIZED` | A session cookie was used |

### `GET /v1/translations`

The same export for the key's own project, with no project ID in the path. API key only, `translations:read`. Query and response are those of `GET /v1/projects/{projectId}/export`.

```bash
curl -H "Authorization: Bearer $COMVI_API_KEY" "https://api.comvi.io/v1/translations?locales=en,uk"
```

## Web import

Three steps: validate, optionally re-validate after the user edits mappings, then commit. Step 1 returns a `validationId` that the later steps pass back. The validation session lives in Redis for 1 hour and is bound to the user who created it.

| Limit | Value | When crossed |
|-------|-------|--------------|
| File size | 10 MB per file | `413 FILE_TOO_LARGE`. `options.maxFileSize` can lower it, never raise it |
| Request body | 50 MB | Fastify rejects the request before the handler |
| Total keys | 100,000, or `options.maxTotalKeys` | `200` with the message in `errors[]`, so read that array |
| Files per ZIP | 500 | `400 TOO_MANY_KEYS` |
| Files per request | 1000, counting files extracted from ZIPs | Validation stops at the 1000th; `200` with the message in `errors[]` |
| Uncompressed ZIP size | 200 MB | `413 FILE_TOO_LARGE` |
| ZIP compression ratio | 100:1 | `400 ZIP_BOMB_DETECTED` |
| Import rate | 100 in a burst, refilling 20 per minute | `429 RATE_LIMIT_EXCEEDED` |
| Session lifetime | 1 hour | `401 IMPORT_SESSION_EXPIRED` |

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

Upload files for validation. Comvi detects format, locale and namespace, and lists conflicts with existing translations. Accepted formats are `.json` and `.zip`, checked against their magic numbers; a ZIP is expanded and each JSON inside validated on its own.

| Parameter | In | Type | Required | Meaning |
|-----------|----|------|----------|---------|
| `projectId` | path | `integer` | Yes | Project ID |
| `files[].filename` | body | `string` | Yes | Original filename, used for format detection |
| `files[].content` | body | `string` | Yes | Base64-encoded content. Raw JSON text fails magic-number detection with `400 FILE_TYPE_NOT_ALLOWED` |
| `files[].size` | body | `integer` | Yes | Decoded size in bytes, checked against the 10 MB cap before decoding |
| `options.maxFileSize` | body | `integer` | No | Lower the per-file cap; a larger value is ignored |
| `options.maxTotalKeys` | body | `integer` | No | Lower the 100,000 key cap |
| `options.defaultConflictResolution` | body | `string` | No | `keep_existing` or `use_imported` |
| `options.allowNewLocales` | body | `boolean` | No | Allow locales not yet in the project |

```bash
curl -X POST -H "Authorization: Bearer $COMVI_API_KEY" -H "Content-Type: application/json" \
-d '{"files": [{"filename": "en.json", "content": "ewogICJ3ZWxjb21lLm1lc3NhZ2UiOiAiSGVsbG8hIiwKICAid2VsY29tZS5zdWJ0aXRsZSI6ICJHZXQgc3RhcnRlZCIKfQ==", "size": 70}], "options": {"defaultConflictResolution": "use_imported"}}' \
https://api.comvi.io/v1/projects/1/keys/import/validate
```

That `content` decodes to `{ "welcome.message": "Hello!", "welcome.subtitle": "Get started" }`. `errors` and `warnings` can be non-empty on a `200`; the total-keys cap reports there rather than failing the request.

```json
{
"validationId": "val_abc123",
"files": [{ "filename": "en.json", "detection": { "filename": "en.json", "detectedLocale": "en", "detectedNamespace": "default", "confidence": 0.95, "keyCount": 2, "errors": [], "warnings": [] }, "conflicts": [], "isValid": true }],
"summary": { "totalFiles": 1, "totalKeys": 2, "uniqueKeys": 2, "totalConflicts": 0, "validFiles": 1, "invalidFiles": 0 },
"errors": [], "warnings": []
}
```

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

Re-validate after the user changes file-to-locale mappings, to find new conflicts.

| Parameter | In | Type | Required | Meaning |
|-----------|----|------|----------|---------|
| `validationId` | body | `string` | Yes | From step 1 |
| `fileMappings[].filename` | body | `string` | Yes | Filename |
| `fileMappings[].locale` | body | `string` | Yes | Target locale code |
| `fileMappings[].namespace` | body | `string` | No | Target namespace |
| `fileMappings[].overrideDetection` | body | `boolean` | No | Use the mapping even when detection disagrees |
| `conflictResolutions` | body | `object[]` | No | Resolved conflicts, shape below |

Conflict resolution object, used here and in commit. `existingValue` and `newValue` are required even though Comvi knows both; copy them from the `conflicts` array of step 1.

| Field | Type | Required | Meaning |
|-------|------|----------|---------|
| `key` | `string` | Yes | Translation key |
| `locale` | `string` | Yes | Locale code |
| `existingValue` | `string` | Yes | Value already in the project |
| `newValue` | `string` | Yes | Value in the imported file |
| `namespace` | `string` | No | Default `default` |
| `resolution` | `string` | No | `keep_existing` or `use_imported`. Falls back to `options.defaultConflictResolution` from step 1 |

```json
{ "conflicts": [{ "filename": "en.json", "key": "welcome.message", "namespace": "default", "locale": "en", "existingValue": "Welcome to our app!", "newValue": "Hello!", "resolution": "use_imported" }], "warnings": [] }
```

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

Commit the validated import.

| Parameter | In | Type | Required | Meaning |
|-----------|----|------|----------|---------|
| `validationId` | body | `string` | Yes | From step 1 |
| `fileMappings` | body | `object[]` | Yes | Same shape as revalidate |
| `conflictResolutions` | body | `object[]` | Yes | Same shape as revalidate. Send `[]` when none |
| `options.dryRun` | body | `boolean` | No | Run without writing |
| `options.idempotencyKey` | body | `string` | No | Replay guard for a retried commit |
| `options.progressTrackingId` | body | `string` | No | Your own ID, 1 to 128 characters, to poll progress with |

```bash
curl -X POST -H "Authorization: Bearer $COMVI_API_KEY" -H "Content-Type: application/json" \
-d '{"validationId": "val_abc123", "fileMappings": [{"filename": "en.json", "locale": "en", "namespace": "default"}], "conflictResolutions": []}' \
https://api.comvi.io/v1/projects/1/keys/import/commit
```

```json
{
"success": true,
"results": { "added": 10, "updated": 5, "skipped": 0 },
"conflictsSummary": { "total": 2, "keepExisting": 1, "useImported": 1 },
"details": [{ "filename": "en.json", "locale": "en", "namespace": "default", "added": 10, "updated": 5, "skipped": 0, "errors": [] }],
"errors": [], "warnings": [], "duration": 1250.5
}
```

### `GET /v1/projects/{projectId}/keys/import/progress/{trackingId}`

Poll a large import by the `options.progressTrackingId` sent to commit. `status` is `running`, `completed` or `failed`. Records live for 2 hours.

```json
{ "status": "running", "totalFiles": 5, "processedFiles": 3, "currentFile": "de.json", "results": { "added": 150, "updated": 30, "skipped": 0 }, "startedAt": "2025-01-16T14:00:00.000Z" }
```

## CLI import

Two calls, validate then commit, with the translations inline as JSON: no Base64 and no session to carry. Both require an API key; a session gets `401 UNAUTHORIZED`, and a key for another project gets `403 FORBIDDEN`.

### `POST /v1/projects/{projectId}/import/validate`

The body is `namespaces`, keyed by namespace, then locale, then key. Nothing is written. A locale or namespace the project lacks shows in `warnings` with `type: "unknown_locale"` or `"unknown_namespace"`, and its rows are dropped; `"merged_locale"` reports two spellings of one locale (`pt_BR` and `pt-BR`) merged into one, the later spelling winning on a key present in both.

| Parameter | In | Type | Required | Meaning |
|-----------|----|------|----------|---------|
| `projectId` | path | `integer` | Yes | Project ID |
| `namespaces` | body | `object` | Yes | Keyed by namespace, locale, key |

```bash
curl -X POST -H "Authorization: Bearer $COMVI_API_KEY" -H "Content-Type: application/json" \
-d '{"namespaces": {"default": {"en": {"welcome.message": "Hello!"}, "uk": {"welcome.message": "Привіт!"}}}}' \
https://api.comvi.io/v1/projects/1/import/validate
```

```json
{
"valid": true,
"stats": { "totalKeys": 3, "newKeys": 1, "existingKeys": 2, "totalTranslations": 3 },
"conflicts": [{ "key": "welcome.message", "namespace": "default", "locale": "en", "localValue": "Hello!", "serverValue": "Welcome to our app!" }],
"warnings": []
}
```

### `POST /v1/projects/{projectId}/import/commit`

Commit the same `namespaces` object. `deleteOrphans` permanently removes every key absent from the payload, which matters when pushing a subset of namespaces.

| Parameter | In | Type | Required | Meaning |
|-----------|----|------|----------|---------|
| `projectId` | path | `integer` | Yes | Project ID |
| `namespaces` | body | `object` | Yes | Keyed by namespace, locale, key |
| `options` | body | `object` | Yes | Import options, fields below |
| `options.conflictResolution` | body | `string` | Yes | `keep_local`, `keep_server` or `fail` |
| `options.createNamespaces` | body | `boolean` | No | Create missing namespaces |
| `options.deleteOrphans` | body | `boolean` | No | Delete keys not in the payload. Needs `translations:delete` on the key and a creator without language restrictions, else `403 FORBIDDEN` |

The response differs from the web commit: `stats` rather than `results`, and `errors` entries are `{ key, namespace, message }`.

```json
{
"success": true,
"stats": { "keysCreated": 1, "keysUpdated": 2, "keysDeleted": 0, "translationsCreated": 1, "translationsUpdated": 2, "namespacesCreated": [] },
"errors": [], "affectedKeyIds": [812, 813, 814]
}
```

## Import errors

Standard envelope, except `422` validation failures, which carry `code` and `meta` and no `message`. `IMPORT_SESSION_EXPIRED` is a `401` and `IMPORT_SESSION_MISMATCH` a `403`, not `400`.

| Status and code | When |
|-----------------|------|
| `400 FILE_TYPE_NOT_ALLOWED` | The decoded bytes are neither JSON nor ZIP, including raw text sent instead of Base64 |
| `400 INVALID_FILE_STRUCTURE` | `content` is not valid Base64, or the decoded JSON does not parse |
| `400 TOO_MANY_KEYS` | A ZIP holds more than 500 files |
| `400 ZIP_BOMB_DETECTED` | A ZIP's compression ratio is over 100:1 |
| `401 UNAUTHORIZED` | CLI endpoints: a session cookie was used |
| `401 IMPORT_SESSION_EXPIRED` | `validationId` is older than 1 hour. Run step 1 again |
| `403 IMPORT_SESSION_MISMATCH` | `validationId` belongs to another project or user |
| `403 LIMIT_EXCEEDED` | The import would exceed the plan's translation key limit |
| `409 TRANSACTION_CONFLICT` | Either commit: the write transaction hit a deadlock. Retry |
| `413 FILE_TOO_LARGE` | A file is over 10 MB or over `options.maxFileSize`, or a ZIP expands past 200 MB |
| `422 FIELD_REQUIRED` | `files`, `validationId`, `fileMappings`, `conflictResolutions`, `namespaces` or `options` is missing |
| `422 VALIDATION_ERROR` | `conflictResolution` is not one of the three values, or a field has the wrong type |
| `429 RATE_LIMIT_EXCEEDED` | Over the token-bucket allowance, which refills at 20 per minute |

## Next steps

- [Translations](/docs/api/translations) for single keys and values
- [CLI](/docs/cli) for automated pull and push
- [Import/Export in the platform](/docs/platform/import-export) for the dashboard flow
