# Create and update translations

**Goal:** create, update, and delete one translation of a content entry over the [JSON:API](/start-here/glossary/#jsonapi), leaving the entry's other languages untouched.

## What you'll have when you're done

- A second-language translation created on an existing entry (`POST` with `?langCode=`, 201), edited in isolation (`PATCH`, 200), and removed without deleting the entry (`DELETE`, 204).
- The status-code contract that keeps translation writes safe: 409 for an existing translation, 404 for a missing one, 400 for the default translation's delete.
- A pre-flight check that proves the contract is actually active before you trust it with a `DELETE`.

## Prerequisites

- API writes enabled on the [site](/start-here/glossary/#site) and a token whose client grants `content:administer`: the [writing-content guide](/source-cms/content-api/writing-content/) sets up both, plus the `.env` file and write headers reused below.
- An existing entry to translate; keep its UUID as `ENTRY_ID` (`id` in the script samples), as in that guide.
- A second language enabled on the site and translation switched on for the [content type](/start-here/glossary/#content-type-bundle): step 1 of the [multilingual guide](/source-cms/content-api/multilingual/) shows where.
- Nothing to enable for the module itself: every Source CMS site ships the [JSON:API Multilingual module](https://www.drupal.org/project/jsonapi_multilingual) active (confirmed 2026-08-19). On a non-Source stand-in, install and enable it first; step 1 doubles as the check.

## Steps

<Steps>

1. ### Prove the contract is active

   Run one strict read against your entry for a translation you know is missing:

   ```bash
   set -a; source .env; set +a
   # TOKEN and ENTRY_ID as in the writing-content guide

   curl -s -o /dev/null -w "%{http_code}\n" \
     "$DRUPAL_SITE_URL/api/node/article/$ENTRY_ID?langCode=es" \
     -H "Accept: application/vnd.api+json" \
     -H "Authorization: Bearer $TOKEN"
   ```

   Expect `404` while the translation does not exist, and `200` with `Content-Language: fi` once it does. Every Source CMS site ships the module, so against Source this is a sanity check on the URL and site. It matters most on local stand-ins, because core JSON:API without the module accepts a spec-compliant parameter like `langCode` and silently ignores it, and the `PATCH` and `DELETE` below then hit the *whole entry*. The `PATCH` edits the default translation and the `DELETE` deletes the entire entry, every language. (The `POST` merely fails, because core has no `POST` route on an entry's URL.) A `200` in the site's default language for a translation that does not exist means the module isn't active: stop here.

2. ### Create the translation with `POST`

   A translation is created on the entry's own URL, not the collection URL: `POST` to the individual entry with `?langCode=` naming the new language. The payload carries only [fields](/start-here/glossary/#field) marked translatable; if it includes `langcode`, that value must match the parameter. The response is `201` on success, `409` if the translation already exists, and `422` if a non-translatable field is in the payload.

   <Tabs syncKey="api-style">
     <TabItem label="JSON:API Client">
       The client has no per-translation method; its `fetch` sends an arbitrary request with the client's token attached:

       ```js
       const { response, error } = await client.fetch(
         `${process.env.DRUPAL_SITE_URL}/api/node/article/${id}?langCode=es`,
         {
           method: "POST",
           headers: {
             Accept: "application/vnd.api+json",
             "Content-Type": "application/vnd.api+json",
           },
           body: JSON.stringify({
             data: {
               type: "node--article",
               id,
               attributes: {
                 langcode: "es",
                 title: "Anuncio de lanzamiento",
               },
             },
           }),
         },
       );
       if (error) throw error;
       console.log(response.status); // expect: 201
       ```
     </TabItem>
     <TabItem label="JavaScript">
       ```js
       // headers and id as in the writing-content guide
       const createResponse = await fetch(
         `${DRUPAL_SITE_URL}/api/node/article/${id}?langCode=es`,
         {
           method: "POST",
           headers,
           body: JSON.stringify({
             data: {
               type: "node--article",
               id,
               attributes: {
                 langcode: "es",
                 title: "Anuncio de lanzamiento",
               },
             },
           }),
         }
       );
       console.log(createResponse.status); // expect: 201
       ```
     </TabItem>
     <TabItem label="curl">
       ```bash
       curl -s -X POST \
         "$DRUPAL_SITE_URL/api/node/article/$ENTRY_ID?langCode=es" \
         -H "Accept: application/vnd.api+json" \
         -H "Content-Type: application/vnd.api+json" \
         -H "Authorization: Bearer $TOKEN" \
         --data '{
           "data": {
             "type": "node--article",
             "id": "'"$ENTRY_ID"'",
             "attributes": {
               "langcode": "es",
               "title": "Anuncio de lanzamiento"
             }
           }
         }'
       ```
     </TabItem>
   </Tabs>

   The `201` returns the created translation (its `langcode` attribute reads `es`) and points at it with a `Location` header ending in `?langCode=es`, JSON:API's standard 201 semantics. The entry's other languages are untouched; the entry's `id` stays the same, because translations share one `id`.

3. ### Update one translation with `PATCH`

   Same URL and parameter, `PATCH` method: only the named translation changes. A `PATCH` for a language with no translation is a `404` (creating is `POST`'s job), so an update can never silently land on the wrong language:

   ```bash
   curl -s -X PATCH \
     "$DRUPAL_SITE_URL/api/node/article/$ENTRY_ID?langCode=es" \
     -H "Accept: application/vnd.api+json" \
     -H "Content-Type: application/vnd.api+json" \
     -H "Authorization: Bearer $TOKEN" \
     --data '{
       "data": {
         "type": "node--article",
         "id": "'"$ENTRY_ID"'",
         "attributes": {
           "title": "Anuncio de lanzamiento (actualizado)"
         }
       }
     }'
   ```

   The `200` echoes the updated translation. Read the default-language entry back (a plain `GET` on the same URL, no parameter) to confirm it kept its own title (the `PATCH` changes only the translation it names). The same rules as the create apply: translatable fields only (`422` otherwise), and a payload `langcode` may not disagree with the parameter.

4. ### Delete one translation with `DELETE`

   `DELETE` with `?langCode=` removes exactly that translation; the entry and its other languages remain: the response is `204`, the strict `GET` then 404s again, and the entry itself stays `200`. Two guardrails:

   - The default translation cannot be deleted this way: that request is a `400`. Removing the default language means deleting the entry.
   - `DELETE` *without* a language parameter keeps core's meaning: it deletes the whole entity, every translation. Double-check the URL before sending a delete either way.

   ```bash
   curl -s -o /dev/null -w "%{http_code}\n" -X DELETE \
     "$DRUPAL_SITE_URL/api/node/article/$ENTRY_ID?langCode=es" \
     -H "Authorization: Bearer $TOKEN"
   ```

   ```
   204
   ```

   A strict `GET` for `?langCode=es` on the entry now returns the step 1 `404` again, and the unparameterized `GET` still returns the default language: the entry survived its translation.

</Steps>

## What just happened

Translations of an entry share one `id`, so every per-translation write addresses the entry's own URL and selects the language with the same `langCode` parameter reads use. The method selects the operation: `POST` creates (409 when it exists), `PATCH` edits (404 when it doesn't), `DELETE` removes one translation (400 for the default one).

The gates are the same ones every JSON:API write passes: the site's `Allowed operations` toggle (read-only mode answers `405` to all of these), the `content:administer` [scope](/start-here/glossary/#scope), and entity access evaluated against the targeted translation. Validation runs on every write, and non-translatable fields are writable only through the default translation, which keeps shared data from changing under a translation's feet. One header rule is stricter than core: a write carrying a `Content-Language` request header is rejected with `400` rather than ignored, so a language can never be selected by a header you didn't mean to send.

## When something goes wrong

**`409 Conflict` with `The "fi" resource translation already exists.`**: the translation is there (captured 2026-08-19). Edit it with `PATCH` (step 3) instead; `POST` never overwrites.

**`404 Not Found` with `The "fi" translation of the specified resource does not exist.` on the `PATCH` or `DELETE`**: no translation in that language (captured 2026-08-19). Create it with `POST` (step 2). If you expected it to exist, run the strict `GET` from step 1: its `errors[0].meta.availableTranslations` names the languages the entry actually has.

**`400 Bad Request` with `Deleting the default translation is not supported.`**: the `langCode` names the default translation (captured 2026-08-19). To remove the whole entry, `DELETE` without the parameter, knowing it takes every language with it.

**`422 Unprocessable Entity` with `The following fields are not translatable: "..."`**: the payload writes a non-translatable field on a non-default translation (captured 2026-08-19 with `promote`). Non-translatable values change only through the default translation; which fields are translatable is per-type configuration (see the [content model reference](/source-cms/reference/content-model/)). Ordinary validation failures also arrive as `422`s naming the field.

**`422` with `Translation resource language mismatch: "fi" (request metadata) vs "en" (request payload).`**: the body's `langcode` attribute disagrees with the `?langCode=` parameter (captured 2026-08-19). Make them match, or drop the attribute and let the parameter select.

**`400 Bad Request` with `The "Content-Language" request header is not supported. Use the "langCode" query parameter to select the language.`**: the module rejects that request header on writes instead of silently landing the write on the default translation (captured 2026-08-19). Remove the header; the URL parameter is the only language selector.

**`400` with `The "includeFallback" query parameter is only supported on read requests.`**: fallback is a read concept; drop it from the write (captured 2026-08-19).

**`405 Method Not Allowed` with `"JSON:API is configured to accept only read operations."`**: the site-level write gate is closed, exactly as for any other JSON:API write (literal captured 2026-08-19 from a translation `PATCH` against a read-only Source CMS site; the detail goes on to name the settings page). Fix: `API > JSON:API > Allowed operations` → `Read and write` (the [writing-content guide](/source-cms/content-api/writing-content/) walks through it). Reads keep working in read-only mode.

**The `PATCH` changed the default language, or the `DELETE` removed the whole entry**: the site you wrote to doesn't have the module active, so core ignored the `langCode` parameter and the request addressed the entry itself. Every Source CMS site ships the module (confirmed 2026-08-19), so check which site the URL points at; local stand-ins without the module behave this way. Step 1 exists to catch this before a write does.

**`403 Forbidden` while the same request works for a colleague**: entity access is evaluated against the targeted translation and operation (update access to create or modify, delete access to delete). The token's scope opens the endpoint; roles decide the rest, as for every content write.

## Next steps

- [Multilingual content](/source-cms/content-api/multilingual/): the read side of the same contract, strict and fallback.
- [Writing content via the API](/source-cms/content-api/writing-content/): whole-entry writes, publishing, workflows, and file uploads.
- [React to content changes with webhooks](/source-cms/content-api/webhooks/): the writes you just made can notify your frontend automatically.
