Create and update content
Goal: create, publish, update, and delete a content entry from your terminal over the JSON:API.
What you’ll have when you’re done
Section titled “What you’ll have when you’re done”- API writes enabled on your site; they’re off by default.
- A token from an API client granted the
content:administerscope. - One entry created and published (
POSTwith"status": true, 201), retitled (PATCH, 200), and removed (DELETE, 204), each verified from the response. - A file uploaded over the API (
POSTof raw bytes, 201) and attached to the entry as its image, withalttext, on either shape of image field.
Prerequisites
Section titled “Prerequisites”- An API client whose granted scopes include
content:administer(scopes are set per client underAPI > API clients; the auth guide covers scope granting). - Administrator access to the site’s API settings, to enable writes in step 1.
- The
.envfile from the auth quickstart. - Node.js 20.6 or later for the JSON:API Client and JavaScript paths, or
curl.
Jump to a task:
- Enable writes on the site
- Set up your caller
- Create an entry with
POST - Update it with
PATCH - Upload a file and attach it
- Delete it with
DELETE
-
Enable writes on the site
Section titled “Enable writes on the site”Writes are opt-in, and until you flip this toggle every
POST,PATCH, orDELETEfails no matter what your token carries. In your site’s admin UI, go toAPI > JSON:API. In theAllowed operationssection, selectRead and write, then clickSave configuration.GETis always enabled;Read-only(the default) blocks everything else. -
Set up your caller
Section titled “Set up your caller”Same credentials as the auth quickstart, and no
scopeparameter anywhere. A token requested without one automatically carries every scope selected on the API client, so as long as the client grantscontent:administer, so does the token:The Drupal API Client handles the token and sets the JSON:API request headers on writes for you. The examples below assume this setup (run scripts with
node --env-file=.env):import {JsonApiClient,} from "@drupal-api-client/json-api-client";const client = new JsonApiClient(process.env.DRUPAL_SITE_URL,{// Source CMS serves JSON:API at /apiapiPrefix: "api",authentication: {type: "OAuth",credentials: {grantType: "client_credentials",clientId: process.env.DRUPAL_CLIENT_ID,clientSecret: process.env.DRUPAL_CLIENT_SECRET,},},},);Get a token and build the write headers once; the examples below assume both (run scripts with
node --env-file=.env):const {DRUPAL_SITE_URL,DRUPAL_CLIENT_ID,DRUPAL_CLIENT_SECRET,} = process.env;const tokenResponse = await fetch(`${DRUPAL_SITE_URL}/oauth/token`,{method: "POST",headers: {"Content-Type": "application/x-www-form-urlencoded",},body: new URLSearchParams({grant_type: "client_credentials",client_id: DRUPAL_CLIENT_ID,client_secret: DRUPAL_CLIENT_SECRET,}),});const { access_token } = await tokenResponse.json();const headers = {Accept: "application/vnd.api+json","Content-Type": "application/vnd.api+json",Authorization: `Bearer ${access_token}`,};Terminal window set -a; source .env; set +a # export every variable in .env into this shellTOKEN=$(curl -s -X POST "$DRUPAL_SITE_URL/oauth/token" \-H "Content-Type: application/x-www-form-urlencoded" \-d "grant_type=client_credentials" \-d "client_id=$DRUPAL_CLIENT_ID" \-d "client_secret=$DRUPAL_CLIENT_SECRET" \| sed -n 's/.*"access_token": *"\([^"]*\)".*/\1/p')echo $TOKEN # a long string; empty means the token request failed -
Create an entry with
Section titled “Create an entry with POST”POSTA create request goes to the collection URL for the content type. The body wraps everything in
data, names the resourcetype, and puts field values underattributes. TheContent-Typeheader must beapplication/vnd.api+json, notapplication/json(the client sets it for you):const created = await client.createResource("node--article",{data: {type: "node--article",attributes: {title: "Launch announcement",status: true,},},},);console.log(created.data.id); // prints the new entry's id; no output means the script never got hereconst createResponse = await fetch(`${DRUPAL_SITE_URL}/api/node/article`,{method: "POST",headers,body: JSON.stringify({data: {type: "node--article",attributes: {title: "Launch announcement",status: true,},},}),});const created = await createResponse.json();console.log(createResponse.status, created.data?.id); // expect: 201 <uuid>Terminal window curl -s -X POST "$DRUPAL_SITE_URL/api/node/article" \-H "Accept: application/vnd.api+json" \-H "Content-Type: application/vnd.api+json" \-H "Authorization: Bearer $TOKEN" \--data '{"data": {"type": "node--article","attributes": {"title": "Launch announcement","status": true}}}'One attribute here is load-bearing:
"status": truepublishes the entry at creation. Entries created viaPOSTare unpublished by default. Without it, the request still returns201 Created, but the entry is invisible to every reader-facingGET. You can also publish later byPATCHing the entry with"status": true(same shape as step 4).A
201 Createdresponse returns the created entity, including the server-assignedidyou’ll use for every later request:{"data": {"type": "node--article","id": "8c2f1a4e-6b7d-4e2a-9c1f-3d5e7a9b0c2d","attributes": {"langcode": "en","status": true,"title": "Launch announcement"}}}Save it: in the Node.js paths,
const id = created.data.id;. In the curl path:Terminal window ENTRY_ID=8c2f1a4e-6b7d-4e2a-9c1f-3d5e7a9b0c2dUnpublished entries are invisible, even to you. If you omit
"status": true, the entry is created withstatus: false, and two things follow:- Reader-facing
GETs don’t return it: the201succeeded, but the entry appears neither in the collection nor on the site. - The client that created it can’t
GETit back either. Viewing unpublished content is a separate permission thatcontent:administerdoes not include. The collection response still counts the entry inmeta.countbut omits it fromdata. Themeta.omittedblock spells out why: each omitted entry’smeta.detailreads:The current user is not allowed to GET the selected resource. The 'view any unpublished content' permission is required.
If that happens, the entry isn’t lost:
PATCHit with"status": trueto publish it (you kept itsidfrom the201). - Reader-facing
-
Update it with
Section titled “Update it with PATCH”PATCHAn update goes to the individual entry’s URL and the body must repeat the entry’s
id. JSON:API requires it to match the URL. Send only the attributes you’re changing; everything else is left untouched:const updated = await client.updateResource("node--article",id,{data: {type: "node--article",id,attributes: {title: "Launch announcement (updated)",},},},);console.log(updated.data.attributes.title); // expect: Launch announcement (updated)const patchResponse = await fetch(`${DRUPAL_SITE_URL}/api/node/article/${id}`,{method: "PATCH",headers,body: JSON.stringify({data: {type: "node--article",id,attributes: {title: "Launch announcement (updated)",},},}),});const updated = await patchResponse.json();console.log(patchResponse.status, updated.data?.attributes?.title); // expect: 200 Launch announcement (updated)Terminal window curl -s -X PATCH \"$DRUPAL_SITE_URL/api/node/article/$ENTRY_ID" \-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": "Launch announcement (updated)"}}}'A
200 OKresponse returns the full updated entity; theconsole.log(or the curl response body) shows the new title. -
Upload a file and attach it
Section titled “Upload a file and attach it”A file is raw bytes, so it does not travel inside a JSON payload. Getting an image onto an entry takes two requests. First,
POSTthe bytes to the field’s upload endpoint, which stores them and answers with afile--fileentity. Then write that entity’sidinto the entry’s relationship, with the samePATCHmachinery as step 4.The upload endpoint is the collection URL plus the field’s machine name (read your fields’ names off
relationshipson any entry; the examples usefield_image, the field on the site they were run against). Two headers make the request a file upload rather than a JSON:API write:Content-Typemust beapplication/octet-stream, and aContent-Dispositionheader carries the filename, whose extension is validated against the field’s allow list. The bytes themselves are the request body:The client has no upload method; its
fetchsends an arbitrary request with the client’s token attached:import { readFile } from "node:fs/promises";const { response, error } = await client.fetch(`${process.env.DRUPAL_SITE_URL}/api/node/article/field_image`,{method: "POST",headers: {Accept: "application/vnd.api+json","Content-Type": "application/octet-stream","Content-Disposition": 'file; filename="mountains-dawn.png"',},body: await readFile("mountains-dawn.png"),},);if (error) throw error;const file = await response.json();console.log(response.status, file.data.id); // expect: 201 <uuid>import { readFile } from "node:fs/promises";const uploadResponse = await fetch(`${DRUPAL_SITE_URL}/api/node/article/field_image`,{method: "POST",headers: {Accept: "application/vnd.api+json","Content-Type": "application/octet-stream","Content-Disposition": 'file; filename="mountains-dawn.png"',Authorization: `Bearer ${access_token}`,},body: await readFile("mountains-dawn.png"),});const file = await uploadResponse.json();console.log(uploadResponse.status, file.data?.id); // expect: 201 <uuid>Terminal window curl -s -X POST "$DRUPAL_SITE_URL/api/node/article/field_image" \-H "Accept: application/vnd.api+json" \-H "Content-Type: application/octet-stream" \-H 'Content-Disposition: file; filename="mountains-dawn.png"' \-H "Authorization: Bearer $TOKEN" \--data-binary @mountains-dawn.pngA
201 Createdreturns the stored file (trimmed):{"data": {"type": "file--file","id": "76a323c8-dacf-4971-a02d-3f3753ff8a62","attributes": {"filename": "mountains-dawn.png","uri": {"value": "public://2026-08/mountains-dawn.png","url": "/sites/default/files/2026-08/mountains-dawn.png"},"filemime": "image/png","filesize": 30891,"status": false}}}"status": falsemarks the file temporary: nothing references it yet, and the attach below flips it totrue(verified from the read-back). No upload-specific grant was involved: the field’s upload endpoint enforces the same permissions as writing the entries the field belongs to, so the client that creates articles can upload to an article field. With the article create permission removed from the client’s role, the same request answers403(verified; the literal is in when something goes wrong).Attach the file by writing the entry’s relationship. The image’s
alttext rides in the relationship’smeta, not on the file:const updated = await client.updateResource("node--article", id, {data: {type: "node--article",id,relationships: {field_image: {data: {type: "file--file",id: file.data.id,meta: { alt: "Snow-capped mountains at dawn" },},},},},});console.log(updated.data.relationships.field_image.data.meta.alt);// expect: Snow-capped mountains at dawnconst attachResponse = await fetch(`${DRUPAL_SITE_URL}/api/node/article/${id}`,{method: "PATCH",headers,body: JSON.stringify({data: {type: "node--article",id,relationships: {field_image: {data: {type: "file--file",id: file.data.id,meta: { alt: "Snow-capped mountains at dawn" },},},},},}),});console.log(attachResponse.status); // expect: 200Terminal window FILE_ID=76a323c8-dacf-4971-a02d-3f3753ff8a62 # data.id from the uploadcurl -s -X PATCH "$DRUPAL_SITE_URL/api/node/article/$ENTRY_ID" \-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"'","relationships": {"field_image": {"data": {"type": "file--file","id": "'"$FILE_ID"'","meta": { "alt": "Snow-capped mountains at dawn" }}}}}}'The
200echoes the relationship, now carrying the image’s dimensions read from the file (trimmed):"field_image": {"data": {"type": "file--file","id": "76a323c8-dacf-4971-a02d-3f3753ff8a62","meta": {"alt": "Snow-capped mountains at dawn","title": null,"width": 1200,"height": 800,"drupal_internal__target_id": 9}}}The two requests collapse into one when the entry already exists:
POSTthe same bytes and headers to the entry’s own field URL,/api/node/article/{id}/field_image, and the response is200with the file already attached and permanent (the relationship write, and with it themeta.alt, then happens in a follow-upPATCHif you need it).When the field references a media entity
Section titled “When the field references a media entity”On many sites an image field does not hold the file itself:
relationshipsshows amedia--imagetarget, meaning the field references a media type entity that in turn holds the file. Fields created with Source CMS’s own tooling have this shape, and the content model reference maps it. The upload is the same request against the media type’s source field, and one extraPOSTwraps the file in a media entity before the attach:Terminal window curl -s -X POST "$DRUPAL_SITE_URL/api/media/image/field_media_image" \-H "Accept: application/vnd.api+json" \-H "Content-Type: application/octet-stream" \-H 'Content-Disposition: file; filename="mountains-dawn.png"' \-H "Authorization: Bearer $TOKEN" \--data-binary @mountains-dawn.pngThe last path segment is the media type’s source field: the file-holding relationship on any
media--imageentry. It wasfield_media_imageon the site this was run against; on sites whose fields come from Source CMS’s tooling it ismedia_image. Read it off a media entry’srelationshipsrather than assuming either.The
201is the samefile--fileshape as above. Wrap it in a media entity, withalton the media’s file relationship (POST /api/media/image, status201):{"data": {"type": "media--image","attributes": { "name": "Mountains at dawn" },"relationships": {"field_media_image": {"data": {"type": "file--file","id": "fd18fedf-81c0-46f5-83ab-dc7c96d7e492","meta": { "alt": "Snow-capped mountains at dawn" }}}}}}Then attach the created
media--imageto the entry, the samePATCHas above with the media entity in the relationship (status200):{"data": {"type": "node--article","id": "26b5bca3-9513-41df-ad05-30c90051ef44","relationships": {"image": {"data": {"type": "media--image","id": "9153d8de-c099-4ee1-8af3-0f9e0d1989b8"}}}}}Media uploads carry their own permission gate: until the client’s account may create media of that type, the upload answers
403and names the permissions it will accept verbatim (the literal is in when something goes wrong). -
Delete it with
Section titled “Delete it with DELETE”DELETEawait client.deleteResource("node--article", id);A successful delete has no body; the client resolves without error.
const deleteResponse = await fetch(`${DRUPAL_SITE_URL}/api/node/article/${id}`,{method: "DELETE",headers: { Authorization: `Bearer ${access_token}` },});console.log(deleteResponse.status); // 204Terminal window curl -s -o /dev/null -w "%{http_code}\n" -X DELETE \"$DRUPAL_SITE_URL/api/node/article/$ENTRY_ID" \-H "Authorization: Bearer $TOKEN"204204 No Content: no body, the entry is gone. AGETon the same URL now returns404.
What just happened
Section titled “What just happened”Two gates stood between you and the write: the site-level Allowed operations toggle (step 1) and the content:administer scope granted on your API client (step 2). Both must be open: one is an administrator’s decision about the site, the other a per-client credential decision, and neither substitutes for the other. And a third switch decides who sees the result: the entry’s own status, published (true) or unpublished (false, the default on create).
Where content workflows fit
Section titled “Where content workflows fit”If a content type uses a workflow, the workflow (not the status flag) governs publication, and the API refuses payloads that touch status directly. A POST or PATCH containing status on a workflow-governed type fails with 403 Forbidden and the detail Cannot edit the published field of moderated entities. Create the entry without status; it lands in the workflow’s initial state (verified: a fresh entry reads "moderation_state": "draft" with "status": false).
Source CMS’s editorial workflow model has three states: Draft (being written, invisible to readers), Review (awaiting approval), and Published (live). Which of these states a given content type actually has, and what transitions are permitted between them, is workflow configuration set per site in the admin UI. The API accepts each state’s lowercase machine name (draft, published). Anything the type’s workflow doesn’t define is rejected with a literal 422: State review does not exist on Default workflow (verified against a site whose default workflow has only the two end states). On a type with no workflow at all, the attribute is still present but reads "moderation_state": null (verified).
The workflow state lives on the entry as the moderation_state attribute. To publish, set it to the workflow’s published state:
{ "data": { "type": "node--article", "id": "<id>", "attributes": { "moderation_state": "published" } }}That transition sets status: true as a side effect (verified: the publish PATCH returns "status": true alongside "moderation_state": "published"). The same works at creation: a POST whose payload includes "moderation_state": "published" creates the entry already live (verified: 201 with "status": true), so publishing on a workflow type needs no create-then-publish two-step. Rule of thumb: status is the lever on types without a workflow; moderation_state is the only lever on types with one.
Transitions are gated by roles, not scopes. The content:administer scope opens the write endpoints; whether a specific state change is allowed is then decided by role permissions configured in the site’s admin UI (documented on docs.acquia.com; the auth guide draws the full scopes-vs-roles boundary). So a write can succeed at the HTTP level while the content stays unpublished: the entry sits in Draft until someone, or something, with permission for the transition moves it along. A transition the acting client isn’t allowed to make is refused with a 422 naming the two states (see when something goes wrong); the write itself didn’t fail.
The whole machine, including that one-way door (Review appears only on workflows configured with it, and every transition is subject to the role gate above):
Workflows govern CMS content only; they do not apply to Drupal Canvas pages. If you’re writing to node, media, or taxonomy_term resources, the type’s workflow applies; a workflow gate never applies to a Canvas page. When you need to know whether a specific type has a workflow attached, that’s part of your content model: see the content model reference.
Scheduled publishing
Section titled “Scheduled publishing”Scheduling is part of the API surface, with the same role-gating as manual transitions. On a content type with scheduling enabled (a per-type admin setting, documented on docs.acquia.com), every entry exposes four additional attributes (verified): publish_on, publish_state, unpublish_on, and unpublish_state, all null until set. On types without scheduling enabled, the attributes are absent. To schedule, send an unpublished entry a future publish_on timestamp and the state it should land in:
{ "data": { "type": "node--article", "id": "<id>", "attributes": { "publish_on": "2026-08-01T09:00:00+00:00", "publish_state": "published" } }}Once accepted, the platform makes the transition at that time with no second API call; this is the same scheduler the admin UI’s Publish On field uses. unpublish_on and unpublish_state work the same way in the other direction.
The request is validated like an immediate transition, plus one extra check: permission for the scheduled transition is evaluated separately from direct publishing. The named state must exist on the type’s workflow, the transition must be valid from the entry’s current state, and the acting client must be allowed to make it on a schedule. A client can be refused the scheduled transition even when its direct moderation_state: "published" write succeeds (verified: the same token that publishes directly gets 422 Unprocessable Content with You do not have access to transition from Draft to Published on both POST and PATCH scheduling writes).
If you hit that error, the fix is role configuration in the admin UI, not a different token or scope; for the transition permissions themselves, see Configuring permissions for transitions on docs.acquia.com.
When something goes wrong
Section titled “When something goes wrong”405 Method Not Allowed with "JSON:API is configured to accept only read operations."
the site-level write gate is closed (verified: this is the exact response while Allowed operations is read-only). The detail even names the settings page. Fix: API > JSON:API > Allowed operations → Read and write (step 1).
403 Forbidden on a POST/PATCH/DELETE while GET works
the toggle is open but the token doesn’t carry content:administer. Scopes live on the API client: a token requested without a scope parameter carries every scope selected on the client (step 2). So either the client doesn’t have content:administer checked under API > API clients, or your token call passed a scope parameter that narrowed it away. Grant the scope on the client, drop the narrowing, and request a fresh token; existing tokens don’t gain scopes retroactively.
403 Forbidden with "The current user is not allowed to POST the selected field (status). Cannot edit the published field of moderated entities."
the content type uses a workflow, and on moderated types status cannot be written directly (verified: the source.pointer names /data/attributes/status). Remove status from the payload and publish by setting "moderation_state": "published" instead (see “Where content workflows fit” above).
422 Unprocessable Content with "State <name> does not exist on <workflow> workflow"
the payload’s moderation_state (or a scheduling write’s publish_state/unpublish_state) names a state the type’s workflow doesn’t define (verified literal). State names are per-workflow configuration; use the lowercase machine names of the states the workflow actually has, and remember Review only exists if the site’s workflow was configured with it.
422 Unprocessable Content with "You do not have access to transition from <state> to <state>"
the acting client lacks permission for that workflow transition. Scheduled transitions are checked separately from direct ones, so this can appear on a publish_on/publish_state write even when the same client’s direct moderation_state publish works (verified). Transitions are gated by role permissions administered in the admin UI (docs.acquia.com); no scope change fixes it.
400 Bad Request with "Updating a resource object that has a working copy is not yet supported."
the entry is a published entry that was later set back to draft, which created a working copy, and JSON:API can no longer update it at all (verified; upstream core issue #2795279). Finish or discard the draft in the admin UI; over the API, only DELETE still works. See the caution in where content workflows fit.
415 Unsupported Media Type
the Content-Type header is missing or is application/json. JSON:API requires Content-Type: application/vnd.api+json on every request that has a body.
415 Unsupported Media Type with "No route found that matches \"Content-Type: image/png\"" on a file upload
the upload was sent with the file’s own MIME type (verified literal). The binary upload endpoint accepts exactly Content-Type: application/octet-stream, whatever the file is; the file’s real type travels in the filename.
400 Bad Request with "\"Content-Disposition\" header is required. A file name in the format \"filename=FILENAME\" must be provided."
the upload is missing its filename header (verified literal). Send it as step 5 shows: Content-Disposition: file; filename="mountains-dawn.png", with the filename quoted.
422 Unprocessable Content with "Only files with the following extensions are allowed: png gif jpg jpeg webp."
the filename’s extension is not on the field’s allow list (verified literal, from uploading a .txt to an image field; the response goes on to name the allowed image types). The list is per-field configuration, and the filename in Content-Disposition is what gets checked, so the fix is a file the field accepts, not a different header.
403 Forbidden with "The current user is not permitted to upload a file for this field." on a file upload
the client’s account lacks the permission that writing that field’s entries needs (verified: removing the article create permission from the client’s role produces exactly this on POST /api/node/article/field_image; restoring it clears it). On a media source field the same refusal continues and names its acceptable permissions verbatim: The following permissions are required: 'administer media' OR 'create media' OR 'create image media'. Grant the named permission to the client’s role: roles are administered in the admin UI (docs.acquia.com).
422 Unprocessable Entity
the payload failed validation: a required field is missing, a value has the wrong shape, or an attribute name doesn’t exist on the type. The response’s errors[].detail names the offending field (verified example: "media_image: This value should not be null." with "source": { "pointer": "/data/attributes/media_image" }); field machine names are in the content model reference.
400 Bad Request on a PATCH
the id inside data is missing or doesn’t match the id in the URL. JSON:API requires both, identical (step 4).
Created the entry (201) but it isn’t on the site, and your own GET doesn’t return it
the entry is unpublished. POST creates entries with status: false unless the payload includes "status": true (step 3). Because viewing unpublished content is a permission content:administer doesn’t include, the entry vanishes from your own reads too: it still counts in meta.count, but data omits it and meta.omitted names the missing view any unpublished content permission. Fix: PATCH the entry with "status": true, or include it in the POST next time. If the content type uses a workflow, sending status fails with a 403 instead; publish by setting "moderation_state": "published" (verified; see “Where content workflows fit” above).
Next steps
Section titled “Next steps”- React to content changes with webhooks: the writes you just made can notify your frontend automatically.
- Content API reference. The stable JSON:API surface: endpoints, headers, and error codes.
- Authentication guide. Scope strategy for real projects: least-privilege clients, secret storage, rotation.
Was this page helpful?
What went wrong?
Still stuck? Contact Acquia Support (opens in a new tab)