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.
What you’ll have when you’re done
Section titled “What you’ll have when you’re done”- A second-language translation created on an existing entry (
POSTwith?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
Section titled “Prerequisites”- API writes enabled on the site and a token whose client grants
content:administer: the writing-content guide sets up both, plus the.envfile and write headers reused below. - An existing entry to translate; keep its UUID as
ENTRY_ID(idin 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.
-
Prove the contract is active
Section titled “Prove the contract is active”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 guidecurl -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
404while the translation does not exist, and200withContent-Language: fionce 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 likelangCodeand silently ignores it, and thePATCHandDELETEbelow then hit the whole entry. ThePATCHedits the default translation and theDELETEdeletes the entire entry, every language. (ThePOSTmerely fails, because core has noPOSTroute on an entry’s URL.) A200in the site’s default language for a translation that does not exist means the module isn’t active: stop here. -
Create the translation with
Section titled “Create the translation with POST”POSTA translation is created on the entry’s own URL, not the collection URL:
POSTto the individual entry with?langCode=naming the new language. The payload carries only fields marked translatable; if it includeslangcode, that value must match the parameter. The response is201on success,409if the translation already exists, and422if a non-translatable field is in the payload.The client has no per-translation method; its
fetchsends 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// headers and id as in the writing-content guideconst 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: 201Terminal window 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"}}}'The
201returns the created translation (itslangcodeattribute readses) and points at it with aLocationheader ending in?langCode=es, JSON:API’s standard 201 semantics. The entry’s other languages are untouched; the entry’sidstays the same, because translations share oneid. -
Update one translation with
Section titled “Update one translation with PATCH”PATCHSame URL and parameter,
PATCHmethod: only the named translation changes. APATCHfor a language with no translation is a404(creating isPOST’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
200echoes the updated translation. Read the default-language entry back (a plainGETon the same URL, no parameter) to confirm it kept its own title (thePATCHchanges only the translation it names). The same rules as the create apply: translatable fields only (422otherwise), and a payloadlangcodemay not disagree with the parameter. -
Delete one translation with
Section titled “Delete one translation with DELETE”DELETEDELETEwith?langCode=removes exactly that translation; the entry and its other languages remain: the response is204, the strictGETthen 404s again, and the entry itself stays200. Two guardrails:- The default translation cannot be deleted this way: that request is a
400. Removing the default language means deleting the entry. DELETEwithout 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"204A strict
GETfor?langCode=eson the entry now returns the step 1404again, and the unparameterizedGETstill returns the default language: the entry survived its translation. - The default translation cannot be deleted this way: that request is a
What just happened
Section titled “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, 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
Section titled “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). 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 operations → Read 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.
Next steps
Section titled “Next steps”- Multilingual content: the read side of the same contract, strict and fallback.
- Writing content via the API: whole-entry writes, publishing, workflows, and file uploads.
- React to content changes with webhooks: the writes you just made can notify your frontend automatically.
Was this page helpful?
What went wrong?
Still stuck? Contact Acquia Support (opens in a new tab)