Skip to content

Create and update content

Goal: create, publish, update, and delete a content entry from your terminal over the JSON:API.

  • API writes enabled on your site; they’re off by default.
  • A token from an API client granted the content:administer 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.
  • An API client whose granted scopes include content:administer (scopes are set per client under API > API clients; the auth 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.
  • Node.js 20.6 or later for the JSON:API Client and JavaScript paths, or curl.

Jump to a task:

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

    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 /api
    apiPrefix: "api",
    authentication: {
    type: "OAuth",
    credentials: {
    grantType: "client_credentials",
    clientId: process.env.DRUPAL_CLIENT_ID,
    clientSecret: process.env.DRUPAL_CLIENT_SECRET,
    },
    },
    },
    );
  3. A create request goes to the collection URL for the content type. 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):

    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

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

    {
    "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-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 GETs 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. 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:

    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)

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

  5. 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’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 (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:

    The client has no upload method; its fetch sends 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>

    A 201 Created returns 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": 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).

    Attach the file by writing the entry’s relationship. The image’s alt text rides in the relationship’s meta, 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 dawn

    The 200 echoes 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: 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).

    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 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 extra POST wraps 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.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):

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

    {
    "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).

  6. await client.deleteResource("node--article", id);

    A successful delete has no body; the client resolves without error.

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

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

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.

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.

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 operationsRead 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).

Was this page helpful?