Now booking enterprise content platform builds for 2026. Contact us

All articles Engineering 11 min read

A Payload CMS approval workflow for AI-written drafts, tested on the API

We gave an AI assistant a Payload account that can save drafts and nothing else, put a reviewer and a separate publisher behind it, and rolled back a mistake that got past review. Every rule was tested over Payload's REST API. The runnable example, the test output and a recording are below.

  • Engineering
  • Practice

An AI assistant that drafts copy in a CMS works through an account, and the account decides what it can do. If that account can publish, the assistant can publish, whatever the admin screen shows a person. The controls have to hold where the assistant connects, which for most integrations is the REST API. We tested each rule there, on Payload 3.90.2, as HTTP requests made with each account’s own credentials.

The assistant’s text is mocked. Its drafts are fixed in advance in the example and no language model is called when it runs, so the tests cover permissions, review, publishing and rollback, and say nothing about how a model behaves. All 76 tests passed on Node 22 and Node 24 on 8 October 2026.

Content governance in a headless CMS covers who should hold each role and what evidence to keep. This is a tested build of one approval line from it, with an AI assistant in the drafting role.

The workflow, step by step

The example uses a single invented page, the returns policy of a shop called Example Outfitters, with three accounts: a service account with an API key for the assistant, a content editor as reviewer, and the head of content as publisher.

  1. The assistant saves a draft. Its draft rewrites the title, summary and body. Payload stores it as a new draft version, and the live page, requested anonymously, still shows the published text.
  2. Validation and permissions limit the change. A summary over 200 characters fails validation and nothing is stored. Changing the URL slug, approving its own draft, publishing, and editing the live page without the draft flag each return 403, and the stored page and its version history are as they were.
  3. A person reviews. The reviewer rejects the first draft with a note: sale items are still exchange-only. Each decision names the version the reviewer read, so when the assistant saves a revision, a decision on the earlier version is refused and the revision starts again as pending. The reviewer amends one sentence and then approves. The server records who decided and when from the signed-in account.
  4. Publishing is a separate permission. The reviewer is refused when trying to publish, with an API key and with a password login. The publisher publishes the approved draft, and the publish request cannot change content on the way through.
  5. A mistake is rolled back. The approved revision told customers they have 60 days to return items. The policy says 30, the reviewer missed it, and it went live. The publisher restores the version published before it. The live page then matches that version field for field, and the mistaken version stays in the history.
The demo script in the example, run on 8 October 2026 against a fresh database. The assistant's copy is mocked. Each status code on screen comes from the server's response, and the script exits with an error if any step behaves differently. Transcript

Who can do what

AssistantReviewerPublisher
Read the live page and the latest draftyesyesyes
Read the version history and the workflow lognoyesyes
Save drafts of the title, summary and bodyyesyesyes
Change the URL slugnonoyes, in a draft
Approve or reject a draftnoyesno
Publishnonoapproved drafts only
Unpublish, or restore an earlier versionnonoyes
Delete pages, change roles, edit the lognonono

Every cell is checked by at least one test, against the stored data as well as the status code. A refused request has to leave the live page, the latest draft and the version history exactly as they were.

The assistant can read articles, including drafts and a reviewer’s note on a rejection, so it can revise. It cannot read the version history, the workflow log, other accounts or which reviewer made a decision, and its API key is not returned when it reads its own account. Anything the assistant can read can end up in a prompt sent to a model provider, which is a reason to keep that list as short as the job allows.

Payload treats an API key as one user, and access functions apply to it as they apply to a person. Two of Payload’s defaults would still hand the assistant’s key more than drafting: any signed-in account can lock a document against everyone else’s edits, and any signed-in account can clear another user’s login lockout. The example turns document locking off and closes the unlock endpoint. It also turns bulk updates off: a bulk update reads the live row and skips the latest draft, so a bulk publish would publish stale copy. The agentic CMS glossary entry compares how other systems scope tokens for non-human actors.

What Payload 3.90.2 does, and what we added

These behaviours shaped the code. Each is recorded in a test on a collection with none of our hooks, so a later Payload release that changes one will fail that test first.

The publishing rule in Payload’s drafts documentation checks the stored status. Its example update rule returns a query, _status equals draft, for editors, and Payload runs that query against the document’s latest version. In our run the rule refused an editor’s draft on a published post. It also let an editor publish, with one PATCH request, a post whose latest version was a draft. Our update rule checks the status the request is trying to write instead, so anyone except the publisher who sends _status: "published" gets a 403.

A write without draft=true goes to the live page. Payload saves a draft only when the request asks for one. Without the flag, an update writes to the published document; if a draft is pending, the page goes offline carrying the draft’s content. Our server refuses any write that is neither a draft nor an explicit publish or unpublish.

Some request parameters rewrite history. On a collection without localisation, unpublishAllLocales=true on a draft save overwrites the latest version in place, where a draft save normally adds a version. In our run that turned the published version into the assistant’s draft. Our guard refuses the parameters this setup does not use.

The version cap deletes published versions. Payload keeps 100 versions per document by default and deletes the oldest whatever their status, so a run of draft saves can remove the version a rollback needs. The example keeps every version.

Field-level refusals are silent. When field access blocks a change, Payload drops the value and answers 200, with the old value in the response. An assistant that checks only the status code would report a refused change as saved. Our field rule answers 403 and names the field when a request changes it; unchanged values in a full-document write pass.

Restores carry status and respect field access. Restoring a draft version without draft=true takes the page offline, because the old version’s status goes into the live document. A field the restoring user may not update keeps its current value, so a page can come back as a mix of two versions. Our approval rule decides the review record of restored content instead: a rollback to a version that was published keeps the decision it was published under, and anything else comes back as a draft that needs a new review.

A version does not record who saved it. It holds the document and its timestamps. We keep an append-only workflow log with the account and role behind every draft, decision, publish and restore. The SQLite adapter runs no transactions unless transactionOptions is set; the example sets it, and a test shows a failed log entry rolling back the change it describes.

Code that runs inside Payload uses the Local API, which skips access control by default. In our Local API test, passing the assistant’s user without overrideAccess: false applied none of its permissions, and the slug change refused over REST went through. Our approval rule still held, because it is a hook that runs whatever the access setting: an unapproved draft could not go live, and trusted code could not record a decision and publish in the same write. Building field-level RBAC in Payload CMS shows the same default letting a user read across tenants.

What a rollback restores

The restore brought back the title, slug, summary and body of the chosen version, its published status and the review decision stored with it. No newer draft was left waiting behind the restored page.

The history and the log only grew. Every version stayed, including the mistaken one and each draft the assistant saved, because a restore adds a version. The workflow log kept the entry for the mistaken publish and added one for the restore, with the publisher’s account and the version restored. A restore applies to one document at a time. Payload gives a restored version the document’s original createdAt, so the example picks the version to roll back to by updatedAt; ordered by createdAt, a second rollback in the tests would have republished the first mistake.

Payload runs the collection’s afterChange hooks on a restore, and our log entry for the restore is written by one. A cache revalidation or search sync attached to the same hook would run again. A newsletter or partner feed sent while the mistake was live stays sent. Restoring a version is an editorial undo for one page. A lost database needs backups, which sit outside anything a restore can reach.

Core Payload, Enterprise and our code

Drafts, versions, restore, API keys, access functions, schema validation, transactions and hooks are all in Payload’s open-source core. Draft validation is off by default; the example turns it on with versions.drafts.validate, which is what stops an invalid draft being saved.

Payload offers multi-step Publishing Workflows, with notifications and inline feedback, as part of its Enterprise offering. We did not use or test it. A team that needs several approval stages with notifications should price it against building those stages on the core.

Everything else in the example is our code. A guard refuses live edits from anyone, request parameters the setup does not use, and unpublishing or restores from anyone except the publisher. A field rule refuses changes to protected fields from roles that may not make them. An approval rule resets the decision on any edit, ties each decision to the version the reviewer read, and blocks publishing anything unapproved. A logger appends each action to the workflow log. Neither the review step nor the log comes from Payload’s core, and the example labels both as custom.

Where people stay responsible

The reviewer is the control for facts. In the run every permission check held, and the 60-day error still reached the live page because a person approved it. Rollback is the recovery path for what review misses.

Someone has to own the accounts: who holds the publisher role, who can issue the assistant a key, and who revokes it. Payload logs the message of each refused request at info level when logging is on, without the account behind it. An alert on refused attempts from the assistant’s key needs the user recorded as well, for example from an afterError hook.

Adapting it to your project

Most of the adaptation is decisions about your content, and the code changes follow from them.

  • Roles and steps. Map the three accounts to your own roles. If approvals need several steps, store a decision per step and require all of them before publishing.
  • Separation of duties. In this example the reviewer may amend a draft and then approve it. If your policy says nobody approves a change they edited, record who edited each draft and check it in the approval rule.
  • Protected fields. List every field whose change alters meaning, price, legal position or URL, and protect each one. The approval rule treats every field except the review record as content, so a new field resets an approval by default.
  • Service accounts. Payload’s local strategy requires a password, so the example gives the assistant a random one nobody knows. In production, put the assistant in its own auth collection with disableLocalStrategy: true, so its key is its only credential.
  • The integration. The tool that carries the assistant’s output into Payload, whether an MCP server, a script or an automation platform, has to send drafts with the draft flag. Test it against these rules. We did not test Payload’s MCP plugin.
  • The admin panel. Editors will work in Payload’s admin panel, which derives its buttons from the access functions. The guard is a hook, so the panel can show a button the server then refuses. Check the screens your editors use against the rules.
  • Side effects. Send cache revalidation and notifications after the change commits, for example from Payload’s jobs queue.

Governance and search visibility are separate questions

This workflow decides who can change and publish a page. Whether Google shows that page, in ordinary results or in its AI features, depends on the page being indexed and eligible for a snippet; Google lists no additional requirements and no special files or markup for AI features. Drafts in this setup never reach a crawler: anonymous requests get the published version even when they ask for the draft. How the published page is served and structured is covered in Your CMS is a GEO decision and How we made wayf.ai readable by machines.

Run it yourself

Download the example (ZIP, MIT licence): the Payload config, the workflow hooks, the seed, the mocked assistant output, the tests, the demo script and the recorded runs. Unzip it, enter examples/payload-ai-assisted-publishing and run, with Node 22 or later:

npm ci
npm test
npm run demo

It uses Payload 3.90.2 and @payloadcms/db-sqlite 3.90.2 with a local SQLite file, starts no admin panel, sends no requests outside the machine and generates fresh API keys on each run. Everything in it is invented: the shop, the people, the policy and the secret. The README lists the actor-and-action matrix with the mechanism behind each rule and every test. The recorded-run folder holds the test output on Node 22.22.3 and 24.21.0 and the mutation checks, each of which breaks one control and confirms that tests fail.

It does not test a language model, Payload’s MCP plugin, the admin panel, GraphQL, localisation or any Payload version other than 3.90.2.

FAQ

  1. Does Payload CMS have an approval workflow?

    Payload's open-source core has drafts, versions, access control and hooks, which are the parts an approval workflow is built from. Payload offers multi-step Publishing Workflows as an Enterprise feature. The example in this post builds one approval step on the open-source core: a review decision stored on each draft, tied to the version the reviewer read, reset by any edit, and a rule that only an approved draft can be published.

  2. Can you stop an AI agent from publishing in Payload?

    Yes, when the rule runs on the server. Give the agent its own user with an API key, and write the update access function so that a request setting _status to published is refused for that role. In our tests on Payload 3.90.2, the documented rule that checks the stored status let an editor publish a document whose latest version was a draft, so check the status in the request. Also refuse writes without draft=true, which otherwise change the live document, and request parameters you do not use, such as unpublishAllLocales, which can overwrite a version in place.

  3. Does Payload record who changed each version?

    Not in the version record. In Payload 3.90.2 a version holds the document data and its timestamps, with no field for the user who saved it. The example keeps an append-only log, written by a hook in the same database transaction as each change, that records who saved, reviewed, published or restored.

Sources


Author

Paul Utr

Co-founder, Chief Growth Officer

Paul has been launching online platforms since his teens, picking up UX and product design by building them. He led the Mailgun redesign at Netguru and was Principal Designer at Ramp Network through its seed-to-Series-B run. At WAYF he leads design and organisational alignment, and watches how language carries through every product we ship.


Where to go next WAYF is an official Payload partner. We set up draft-only AI access, review steps and rollback checks on Payload projects.

We're booking content platform
engagements for 2026.

Twenty-five minutes to walk through the work and decide if we're the right team for it. Scoping and a fixed price come after.