> ## Documentation Index
> Fetch the complete documentation index at: https://docs.tolk.every-day.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Synchronise keys

> Creates and updates the sent keys with their source text and archives the sent hashes. Tags are added, never
removed. A key that would change between singular and plural is refused per item (`plural_changed`). The
same sync twice changes nothing the second time. When the new and restored keys would take the customer over
its key limit, nothing is written and the answer is a `422` with `reason: key_limit_reached`.



## OpenAPI

````yaml /api-reference/openapi.json post /keys/sync
openapi: 3.1.0
info:
  title: Tolk API
  version: 1.0.0
  description: >
    The Tolk API lets a project read and write its keys and translations from
    code: the `everyday-nl/tolk-laravel` package, a GitHub Action or your own
    scripts.


    ## Base URL and versioning


    All endpoints live on their own domain, with the version in the path:


    ```

    https://api.tolk.every-day.io/v1

    ```


    There is no API on the app domain. Breaking changes only ever come in a new
    version (`/v2`); within `/v1`, fields and endpoints are only added.


    ## Authentication


    Every request needs a project token in the `Authorization` header:


    ```

    curl https://api.tolk.every-day.io/v1/project \
      -H "Authorization: Bearer $TOLK_TOKEN"
    ```


    A manager creates tokens under _Settings · API tokens_ of the project. The
    token is shown once; Tolk only stores its hash. The project follows from the
    token: there is no project in the URL, and a `?project=` parameter is
    ignored.


    - **Scopes.** A token always has `read` and optionally `write`. The reading
    endpoints need `read`; `PATCH /keys/{keyHash}`, `POST /keys/diff`, `POST
    /keys/sync`, `PUT /translations/{locale}` and `POST /imports` need `write`.

    - **401** – no token, a malformed, unknown, expired or revoked token, or the
    project or its customer was deleted.

    - **403** – the token lacks the scope of the endpoint, or the project is
    archived (a token of an archived project works again once the project is
    restored).

    - **Rate limit.** 120 requests per minute per token. Every answer carries
    `X-RateLimit-Limit` and `X-RateLimit-Remaining`; above the limit the answer
    is `429` with `Retry-After` (seconds). Before the token is checked, every
    client IP address may send 600 requests per minute, with a valid token or
    not; above that the answer is also `429` with `Retry-After`.


    ## Conventions


    - **JSON only.** Every answer is JSON, also an error and an unknown URL,
    with or without an `Accept` header. The one exception is the file of `GET
    /exports/{profileKey}`.

    - **Envelope.** One object is `{"data": {…}}`. A list is `{"data": […],
    "meta": {"nextCursor": "…", "limit": 500}}`.

    - **Fields** are camelCase. **Dates** are ISO 8601 in UTC:
    `2026-10-07T12:00:00Z`.

    - **Errors** are `{"message": "…"}`; a validation error (`422`) adds
    `errors` per field, with the index of a list item in the field name:
    `{"message": "…", "errors": {"keys.12.value": ["…"]}}`.

    - **Conflicts.** A `409` means the project is busy and the request can be
    repeated: `{"message": "…", "reason": "source_language_change"}` while the
    source language of the project is being changed, `"reason": "busy"` when
    another writer holds the lock.

    - **Language.** Messages follow `Accept-Language` (`nl` or `en`, default
    `nl`). This documentation is in English.


    ### Pagination


    Lists (`GET /keys`, `GET /translations/{locale}`) use an opaque cursor and
    are ordered by key:


    1. Request the first page, optionally with `limit` (1–1000, default 500).

    2. As long as `meta.nextCursor` is not `null`, request the next page with
    `cursor=<meta.nextCursor>` and the same filters.


    A cursor that Tolk cannot read gives `422` on the field `cursor`. Never
    build a cursor yourself.


    ## Key identity


    A key is identified by its `hash`, so a key with a sentence as its name
    never has to be put in a URL. The package computes both hashes locally:


    - `hash` = SHA-1 (lowercase hex) of `namespace + "\x1F" + key + "\x1F" +
    (context ?? "")`. The separator is the unit separator `U+001F`. The
    namespace of a key without one is `*`. An empty context and no context give
    the same hash.

    - `sourceHash` = SHA-1 of the source text; for a plural key the SHA-1 of the
    JSON of the forms sorted by category (UTF-8 unescaped, e.g. `{"one":"Eén
    artikel","other":":count artikelen"}`); `null` without a source text.


    Examples:


    |
    Input                                                                          
    | Hash                                       |

    |
    -------------------------------------------------------------------------------
    | ------------------------------------------ |

    | `hash` of namespace `*`, key `Pay now`, no
    context                              |
    `0e5e60b24eff16607ccbb7dbbe7141b7a0c5de48` |

    | `hash` of namespace `checkout`, key `Order`, context
    `noun`                     | `6a769493d0f894ecd2d4bb324ac5113a61056602` |

    | `sourceHash` of `Nu
    betalen`                                                    |
    `09269d72c5af31fb3cc4752832089843eaaeb603` |

    | `sourceHash` of the forms `{"one": "Eén artikel", "other": ":count
    artikelen"}` | `8d7fabf1eb21b47eac0cf2d9a47e340abf7a51b8` |


    ### Pushing keys


    1. `POST /keys/diff` with the `hash` and `sourceHash` of every local key:
    Tolk answers which hashes are `new`, `changed` (another source text) or
    `restored` (archived in Tolk), and how many are unchanged. The diff changes
    nothing.

    2. `POST /keys/sync` with only the new, changed and restored keys (with
    their source text). A sync is idempotent: repeating it after a `409`
    finishes the job.

    3. To archive the keys that disappeared from the code, page through `GET
    /keys`, compare the hashes with the local ones and send the missing ones in
    `archive` of a sync.


    ## Imports, exports and jobs


    Long work runs as a job. An answer `202 Accepted` holds the job (`{"data":
    {"id": 42, "type": "import", "status": "queued", …}}`) and a `Location`
    header with its URL: poll `GET /jobs/{jobId}` until `status` is `succeeded`
    or `failed`. A failed job has `error`: a stable `code` (such as
    `needs_mapping` or `zip_slip`) and a `message`.


    - **Import.** `POST /imports` takes `multipart/form-data` with `files[]`
    (JSON, PHP, PO, MO, CSV, XLSX, XLIFF or a zip of them) and optionally
    `locale`, `format`, `namespace`, `overwrite` (`1`/`0`, default `0`),
    `createUnknownKeys` (default `1`) and `tag`. Tolk recognises the format,
    language and namespace from each path, as the import dialog does, and
    imports without a preview. An unsafe or unreadable upload is a `422` on
    `files.N` and nothing is stored. A spreadsheet needs a column mapping saved
    as a preset in Tolk.


    ```

    curl https://api.tolk.every-day.io/v1/imports \
      -H "Authorization: Bearer $TOLK_TOKEN" \
      -F "files[]=@lang/de.json" -F "overwrite=1"
    ```


    - **Export.** `GET /exports/{profileKey}` answers the file of an export
    profile (`GET /project` lists them under `exportProfiles`), optionally for
    `locales` (a comma list) and with `missing` (`fallback`, `empty` or `omit`).
    Keep the `ETag` and send it back as `If-None-Match`: an unchanged export is
    a `304` without a body. The ETag is the hash of the bytes, so a change that
    does not alter the file keeps it. A large export that is not built yet is a
    `202` with a job; fetch the export again once the job succeeded.


    ```

    curl https://api.tolk.every-day.io/v1/exports/laravel-json?locales=de,fr \
      -H "Authorization: Bearer $TOLK_TOKEN" \
      -H 'If-None-Match: "<etag of the previous download>"' -o lang.zip
    ```


    ## Webhooks


    A manager adds webhooks under _Settings · Webhooks_. Tolk sends a `POST`
    with a JSON body to the URL for every event the webhook follows:


    ```json

    {
        "id": "0f9c2b5e-…",
        "event": "keys.synced",
        "createdAt": "2026-10-07T12:00:00Z",
        "project": { "slug": "webshop", "name": "Webshop" },
        "data": { "created": 12, "updated": 3, "restored": 0, "archived": 1 }
    }

    ```


    Every delivery has these headers:


    - `X-Tolk-Event` – the event;

    - `X-Tolk-Delivery` – the id of the delivery (also `id` in the body; a retry
    keeps it, so it deduplicates);

    - `X-Tolk-Timestamp` – the moment of this attempt, in Unix seconds;

    - `X-Tolk-Signature` – `sha256=` followed by the lowercase hex HMAC-SHA256,
    with the secret of the webhook, over `<timestamp>.<body>`.


    To verify a delivery: refuse a timestamp more than 300 seconds from your
    clock, compute the HMAC over the timestamp, a dot and the raw body, and
    compare it with the signature in constant time. Answer with a `2xx` within 5
    seconds (Tolk does not wait longer and follows no redirects); any other
    answer, or none, is retried with a growing delay. A delivery can arrive more
    than once.


    The payloads carry counters and locales, never texts or keys: fetch the
    content through the API.


    | Event                  |
    When                                                         |
    `data`                                                                                                                                        
    |

    | ---------------------- |
    ------------------------------------------------------------ |
    ----------------------------------------------------------------------------------------------------------------------------------------------
    |

    | `translations.updated` | translations changed; at most once per 5 minutes
    per project | `{"contentVersion": 1234, "since": "2026-10-07T12:00:00Z",
    "languages": [{"locale": "de", "translated": 1544, "total": 2412, "percent":
    64}]}` |

    | `language.completed`   | a target language reaches
    100%                               | `{"language": {"locale": "de", "name":
    "Deutsch", "translated": 2412, "total":
    2412}}`                                                         |

    | `keys.synced`          | a sync created, changed or restored
    keys                     | `{"created": 12, "updated": 3, "restored": 0,
    "archived":
    1}`                                                                                 
    |

    | `import.finished`      | an import succeeded or
    failed                                | see
    below                                                                                                                                     
    |

    | `ai_job.finished`      | an AI translation job
    ended                                  | see
    below                                                                                                                                     
    |


    `import.finished` names the job of `GET /jobs/{jobId}` and the counters of
    the import (`result` is `null` when it failed; `error.code` is the code of
    the job):


    ```json

    {
        "job": {
            "id": 42,
            "type": "import",
            "status": "succeeded",
            "error": null,
            "finishedAt": "2026-10-07T12:00:00Z"
        },
        "import": {
            "id": 7,
            "locales": ["de"],
            "result": {
                "newKeys": 3,
                "newTranslations": 412,
                "changedTranslations": 0,
                "unchanged": 1544,
                "skipped": 2,
                "placeholderErrors": 1
            }
        }
    }

    ```


    `ai_job.finished` has the outcome of an AI translation job, without costs;
    `status` is `completed`, `failed`, `cancelled` or `budget_exceeded`:


    ```json

    {
        "job": {
            "id": 17,
            "locale": "de",
            "status": "completed",
            "mode": "direct",
            "scope": "untranslated",
            "error": null,
            "totalItems": 960,
            "translatedItems": 950,
            "flaggedItems": 3,
            "failedItems": 0,
            "skippedItems": 7,
            "startedAt": "2026-10-07T11:58:00Z",
            "finishedAt": "2026-10-07T12:00:00Z"
        }
    }

    ```
servers:
  - url: https://api.tolk.every-day.io/v1
    description: Tolk API
security:
  - bearer: []
paths:
  /keys/sync:
    post:
      tags:
        - KeysSync
      summary: Synchronise keys
      description: >-
        Creates and updates the sent keys with their source text and archives
        the sent hashes. Tags are added, never

        removed. A key that would change between singular and plural is refused
        per item (`plural_changed`). The

        same sync twice changes nothing the second time. When the new and
        restored keys would take the customer over

        its key limit, nothing is written and the answer is a `422` with
        `reason: key_limit_reached`.
      operationId: v1.keys.sync
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/KeysSyncData'
      responses:
        '200':
          description: ''
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiKeysSyncResponse'
          headers:
            X-RateLimit-Limit:
              description: Requests per minute of the token.
              schema:
                type: integer
            X-RateLimit-Remaining:
              description: Requests left in the current minute.
              schema:
                type: integer
        '401':
          description: >-
            Unauthenticated: no token, or an invalid, expired or revoked token,
            or the project or its customer was deleted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                required:
                  - message
        '403':
          description: >-
            The token lacks the scope of this endpoint, or the project is
            archived.
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                required:
                  - message
        '409':
          description: >-
            The project is busy: a source-language change is running, or another
            writer holds the lock. Try again.
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                  reason:
                    type: string
                    enum:
                      - source_language_change
                      - busy
                required:
                  - message
                  - reason
        '422':
          description: >-
            Validation error: the messages per field (`keys.12.value`). When the
            new and restored keys do not fit the key limit of the customer, also
            `reason: key_limit_reached` with the limit, the keys in use and the
            keys the sync would add.
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                  errors:
                    type: object
                    additionalProperties:
                      type: array
                      items:
                        type: string
                  reason:
                    type: string
                    enum:
                      - key_limit_reached
                  limit:
                    type: integer
                  used:
                    type: integer
                  requested:
                    type: integer
                required:
                  - message
                  - errors
        '429':
          description: 'Too many requests: the rate limit of the token is reached.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                required:
                  - message
          headers:
            Retry-After:
              description: Seconds until the next request is allowed.
              required: true
              schema:
                type: integer
components:
  schemas:
    KeysSyncData:
      type: object
      properties:
        keys:
          type: array
          items:
            type: object
            properties:
              namespace:
                type: string
              key:
                type: string
              context:
                type:
                  - string
                  - 'null'
              value:
                type:
                  - string
                  - 'null'
              forms:
                type: object
                additionalProperties:
                  type:
                    - string
                    - 'null'
              description:
                type:
                  - string
                  - 'null'
              maxLength:
                type:
                  - integer
                  - 'null'
              tags:
                type: array
                items:
                  type: string
            required:
              - key
          maxItems: 5000
        archive:
          type: array
          items:
            type: string
          maxItems: 5000
      title: KeysSyncData
    ApiKeysSyncResponse:
      type: object
      properties:
        data:
          $ref: '#/components/schemas/ApiKeysSyncResultData'
      required:
        - data
      title: ApiKeysSyncResponse
    ApiKeysSyncResultData:
      type: object
      properties:
        created:
          type: integer
        updated:
          type: integer
        restored:
          type: integer
        unchanged:
          type: integer
        archived:
          type: integer
        rejected:
          type: array
          items:
            $ref: '#/components/schemas/ApiKeySyncRejectionData'
      required:
        - created
        - updated
        - restored
        - unchanged
        - archived
        - rejected
      title: ApiKeysSyncResultData
    ApiKeySyncRejectionData:
      type: object
      properties:
        hash:
          type: string
        reason:
          type: string
          const: plural_changed
      required:
        - hash
        - reason
      title: ApiKeySyncRejectionData
  securitySchemes:
    bearer:
      type: http
      scheme: bearer
      bearerFormat: tolk_…

````

This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.