Skip to content

Create and update translations

Goal: create, update, and delete one translation of a content entry over the JSON:API, leaving the entry’s other languages untouched.

  • 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.
  • API writes enabled on the site and a token whose client grants content:administer: the writing-content guide 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: step 1 of the multilingual guide shows where.
  • Nothing to enable for the module itself: every Source CMS site ships the JSON:API Multilingual module active (confirmed 2026-08-19). On a non-Source stand-in, install and enable it first; step 1 doubles as the check.
  1. Run one strict read against your entry for a translation you know is missing:

    Terminal window
    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. 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 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.

    The client has no per-translation method; its fetch sends an arbitrary request with the client’s token attached:

    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

    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. 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:

    Terminal window
    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 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.
    Terminal window
    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.

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, 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.

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). Ordinary validation failures also arrive as 422s 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 operationsRead and write (the writing-content guide 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.

Was this page helpful?