# Create and update content

**Goal:** create, publish, update, and delete a content entry from your terminal over the [JSON:API](/start-here/glossary/#jsonapi).

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

- API writes enabled on your [site](/start-here/glossary/#site); they're off by default.
- A token from an [API client](/start-here/glossary/#api-client) granted the `content:administer` [scope](/start-here/glossary/#scope).
- One entry created *and published* (`POST` with `"status": true`, 201), retitled (`PATCH`, 200), and removed (`DELETE`, 204), each verified from the response.
- A file uploaded over the API (`POST` of raw bytes, 201) and attached to the entry as its image, with `alt` text, on either shape of image field.

## Prerequisites

- An [API client](/start-here/glossary/#api-client) whose granted scopes include `content:administer` (scopes are set per client under `API > API clients`; the [auth guide](/source-cms/authenticate/guide/) covers scope granting).
- Administrator access to the site's API settings, to enable writes in step 1.
- The `.env` file from the [auth quickstart](/source-cms/authenticate/quickstart/).
- [Node.js](https://nodejs.org/) 20.6 or later for the JSON:API Client and JavaScript paths, or [`curl`](https://curl.se/).

## Steps

Jump to a task:

- [Enable writes on the site](#enable-writes-on-the-site)
- [Set up your caller](#set-up-your-caller)
- [Create an entry with `POST`](#create-an-entry-with-post)
- [Update it with `PATCH`](#update-it-with-patch)
- [Upload a file and attach it](#upload-a-file-and-attach-it)
- [Delete it with `DELETE`](#delete-it-with-delete)

<Steps>

1. ### Enable writes on the site

   Writes are opt-in, and until you flip this toggle every `POST`, `PATCH`, or `DELETE` fails no matter what your token carries. In your site's admin UI, go to `API > JSON:API`. In the `Allowed operations` section, select `Read and write`, then click `Save configuration`.

   `GET` is always enabled; `Read-only` (the default) blocks everything else.

2. ### Set up your caller

   Same credentials as the auth quickstart, and no `scope` parameter anywhere. A token requested without one automatically carries every scope selected on the API client, so as long as the client grants `content:administer`, so does the token:

   <Tabs syncKey="api-style">
     <TabItem label="JSON:API Client">
       The [Drupal API Client](/start-here/glossary/#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`):

       ```js
       import {
         JsonApiClient,
       } from "@drupal-api-client/json-api-client";

       const client = new JsonApiClient(
         process.env.DRUPAL_SITE_URL,
         {
           // Source CMS serves JSON:API at /api
           apiPrefix: "api",
           authentication: {
             type: "OAuth",
             credentials: {
               grantType: "client_credentials",
               clientId: process.env.DRUPAL_CLIENT_ID,
               clientSecret: process.env.DRUPAL_CLIENT_SECRET,
             },
           },
         },
       );
       ```
     </TabItem>
     <TabItem label="JavaScript">
       Get a token and build the write headers once; the examples below assume both (run scripts with `node --env-file=.env`):

       ```js
       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}`,
       };
       ```
     </TabItem>
     <TabItem label="curl">
       ```bash
       set -a; source .env; set +a  # export every variable in .env into this shell

       TOKEN=$(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
       ```
     </TabItem>
   </Tabs>

3. ### Create an entry with `POST`

   A create request goes to the collection URL for the [content type](/start-here/glossary/#content-type-bundle). The body wraps everything in `data`, names the resource `type`, and puts field values under `attributes`. The `Content-Type` header must be `application/vnd.api+json`, not `application/json` (the client sets it for you):

   <Tabs syncKey="api-style">
     <TabItem label="JSON:API Client">
       ```js
       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 here
       ```
     </TabItem>
     <TabItem label="JavaScript">
       ```js
       const 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>
       ```
     </TabItem>
     <TabItem label="curl">
       ```bash
       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
             }
           }
         }'
       ```
     </TabItem>
   </Tabs>

   One attribute here is load-bearing: `"status": true` publishes the entry at creation. **Entries created via `POST` are unpublished by default.** Without it, the request still returns `201 Created`, but the entry is invisible to every reader-facing `GET`. You can also publish later by `PATCH`ing the entry with `"status": true` (same shape as step 4).

   A `201 Created` response returns the created entity, including the server-assigned `id` you'll use for every later request:

   ```json
   {
     "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:

   ```bash
   ENTRY_ID=8c2f1a4e-6b7d-4e2a-9c1f-3d5e7a9b0c2d
   ```

   **Unpublished entries are invisible, even to you.** If you omit `"status": true`, the entry is created with `status: false`, and two things follow:

   - Reader-facing `GET`s don't return it: the `201` succeeded, but the entry appears neither in the collection nor on the site.
   - **The client that created it can't `GET` it back either.** Viewing unpublished content is a separate permission that `content:administer` does not include. The collection response still counts the entry in `meta.count` but omits it from `data`. The `meta.omitted` block spells out why: each omitted entry's `meta.detail` reads: `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: `PATCH` it with `"status": true` to publish it (you kept its `id` from the `201`).

4. ### Update it with `PATCH`

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

   <Tabs syncKey="api-style">
     <TabItem label="JSON:API Client">
       ```js
       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)
       ```
     </TabItem>
     <TabItem label="JavaScript">
       ```js
       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)
       ```
     </TabItem>
     <TabItem label="curl">
       ```bash
       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)"
             }
           }
         }'
       ```
     </TabItem>
   </Tabs>

   A `200 OK` response returns the full updated entity; the `console.log` (or the curl response body) shows the new title.

5. ### 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, `POST` the bytes to the [field](/start-here/glossary/#field)'s upload endpoint, which stores them and answers with a `file--file` entity. Then write that entity's `id` into the entry's relationship, with the same `PATCH` machinery as step 4.

   The upload endpoint is the collection URL plus the field's [machine name](/start-here/glossary/#machine-name) (read your fields' names off `relationships` on any entry; the examples use `field_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-Type` must be `application/octet-stream`, and a `Content-Disposition` header carries the filename, whose extension is validated against the field's allow list. The bytes themselves are the request body:

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

       ```js
       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>
       ```
     </TabItem>
     <TabItem label="JavaScript">
       ```js
       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>
       ```
     </TabItem>
     <TabItem label="curl">
       ```bash
       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.png
       ```
     </TabItem>
   </Tabs>

   A `201 Created` returns the stored file (trimmed):

   ```json
   {
     "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": false` marks the file temporary: nothing references it yet, and the attach below flips it to `true` (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 answers `403` (verified; the literal is in [when something goes wrong](#when-something-goes-wrong)).

   Attach the file by writing the entry's relationship. The image's `alt` text rides in the relationship's `meta`, not on the file:

   <Tabs syncKey="api-style">
     <TabItem label="JSON:API Client">
       ```js
       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 dawn
       ```
     </TabItem>
     <TabItem label="JavaScript">
       ```js
       const 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: 200
       ```
     </TabItem>
     <TabItem label="curl">
       ```bash
       FILE_ID=76a323c8-dacf-4971-a02d-3f3753ff8a62  # data.id from the upload

       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"'",
             "relationships": {
               "field_image": {
                 "data": {
                   "type": "file--file",
                   "id": "'"$FILE_ID"'",
                   "meta": { "alt": "Snow-capped mountains at dawn" }
                 }
               }
             }
           }
         }'
       ```
     </TabItem>
   </Tabs>

   The `200` echoes the relationship, now carrying the image's dimensions read from the file (trimmed):

   ```json
   "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: `POST` the same bytes and headers to the entry's own field URL, `/api/node/article/{id}/field_image`, and the response is `200` with the file already attached and permanent (the relationship write, and with it the `meta.alt`, then happens in a follow-up `PATCH` if you need it).

   #### When the field references a media entity

   On many sites an image field does not hold the file itself: `relationships` shows a `media--image` target, meaning the field references a [media type](/start-here/glossary/#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](/source-cms/reference/content-model/) maps it. The upload is the same request against the media type's source field, and one extra `POST` wraps the file in a media entity before the attach:

   ```bash
   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.png
   ```

   The last path segment is the media type's source field: the file-holding relationship on any `media--image` entry. It was `field_media_image` on the site this was run against; on sites whose fields come from Source CMS's tooling it is `media_image`. Read it off a media entry's `relationships` rather than assuming either.

   The `201` is the same `file--file` shape as above. Wrap it in a media entity, with `alt` on the media's file relationship (`POST /api/media/image`, status `201`):

   ```json
   {
     "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--image` to the entry, the same `PATCH` as above with the media entity in the relationship (status `200`):

   ```json
   {
     "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 `403` and names the permissions it will accept verbatim (the literal is in [when something goes wrong](#when-something-goes-wrong)).

6. ### Delete it with `DELETE`

   <Tabs syncKey="api-style">
     <TabItem label="JSON:API Client">
       ```js
       await client.deleteResource("node--article", id);
       ```

       A successful delete has no body; the client resolves without error.
     </TabItem>
     <TabItem label="JavaScript">
       ```js
       const deleteResponse = await fetch(
         `${DRUPAL_SITE_URL}/api/node/article/${id}`,
         {
           method: "DELETE",
           headers: { Authorization: `Bearer ${access_token}` },
         }
       );
       console.log(deleteResponse.status); // 204
       ```
     </TabItem>
     <TabItem label="curl">
       ```bash
       curl -s -o /dev/null -w "%{http_code}\n" -X DELETE \
         "$DRUPAL_SITE_URL/api/node/article/$ENTRY_ID" \
         -H "Authorization: Bearer $TOKEN"
       ```

       ```
       204
       ```
     </TabItem>
   </Tabs>

   `204 No Content`: no body, the entry is gone. A `GET` on the same URL now returns `404`.

</Steps>

## 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

If a content type uses a [workflow](/start-here/glossary/#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](/start-here/glossary/#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:

```json
{
  "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](https://docs.acquia.com/acquia-source/roles-and-permissions); the [auth guide draws the full scopes-vs-roles boundary](/source-cms/authenticate/guide/#scopes-are-not-team-roles)). 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](#when-something-goes-wrong)); the write itself didn't fail.

:::caution[Over the API, re-drafting a published entry is a one-way door]
Setting a published entry's `moderation_state` back to `draft` doesn't unpublish it. It starts a new unpublished *working copy* while the published revision stays live (verified: after that `PATCH`, a `GET` still returns the published title with `"status": true`). From that point the API can no longer modify the entry: every further `PATCH`, including the one that would publish the working copy, fails with `400 Bad Request` and `Updating a resource object that has a working copy is not yet supported.` That is a JSON:API limitation, tracked upstream as [Drupal core issue #2795279](https://www.drupal.org/project/drupal/issues/2795279). `DELETE` still works; anything else means finishing the draft in the admin UI.
:::

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

<ConceptDiagram name="moderation-states" height={700} />

**Workflows govern [CMS content](/start-here/glossary/#cms-content) only; they do not apply to Drupal Canvas [pages](/start-here/glossary/#pages-canvas).** 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](/source-cms/reference/content-model/).

### 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](https://docs.acquia.com/acquia-source/enabling-content-scheduling)), 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:

```json
{
  "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](https://docs.acquia.com/acquia-source-cms/configuring-permissions-transitions) on docs.acquia.com.

## 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](/start-here/glossary/#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](https://docs.acquia.com/acquia-source/roles-and-permissions)); 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](https://www.drupal.org/project/drupal/issues/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](#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](https://docs.acquia.com/acquia-source/roles-and-permissions)).

**`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](/source-cms/reference/content-model/).

**`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](/start-here/glossary/#workflow), sending `status` fails with a `403` instead; publish by setting `"moderation_state": "published"` (verified; see "Where content workflows fit" above).

## Next steps

- [React to content changes with webhooks](/source-cms/content-api/webhooks/): the writes you just made can notify your frontend automatically.
- [Content API reference](/source-cms/reference/content-api/). The stable JSON:API surface: endpoints, headers, and error codes.
- [Authentication guide](/source-cms/authenticate/guide/). Scope strategy for real projects: least-privilege clients, secret storage, rotation.
