Publishing is opt-in per type. Without publishable: true there is no publish
state — every row is live.
export const article = collection('article', {
publishable: true,
paranoid: true,
fields: {
title: field.text({ required: true }),
tags: field.relation({ to: () => tag, many: true, required: true })
}
});The states
| State | Row | Public API serves |
|---|---|---|
| Draft | status = 'draft', published_at null | Nothing |
| Published | status = 'published', published_at set | The row |
| Modified | status = 'draft', published_at set | The published version, with status: "published" |
Modified is not a stored value: it is a draft that has a published_at.
Saving a live entry moves it back to draft and keeps published_at.
To find Modified entries in the admin list, filter status eq draft and
publishedAt is not null — status eq draft alone also matches never-published
drafts.
What readers see while an entry is Modified
Since 0.7.0 the public API keeps serving the version that was last published —
its values, its links, status: "published" — until the next publish. The
unpublished edits never leak, and a ?filter= or ?search= cannot match them.
| Read | Sees |
|---|---|
REST /api/v1, GraphQL, MCP — default (published) | The published version |
Same, ?status=draft / ?status=any (a full token) | The working copy |
| Admin API | The working copy |
The published version comes from the entry’s version history;
updatedAt on the public read is when that version was written.
Required means required to publish
On a publishable type required: true does not block a save — the column stays
nullable and the rule runs at publish. On a non-publishable type it is
NOT NULL.
The publish gate
Publishing re-checks the stored row:
- Every value rule — required fields present, every present value valid.
- Every required link-managed relation (an owning many-to-many, or the
inverse of one) has at least one link. These links never travel in
values, so they are counted separately.
A required relation whose target type this workspace cannot reach is skipped — see relations.
curl -X POST https://cms.example.com/api/v1/content/article/0b9e…/publish \
-H 'Authorization: Bearer orthacms_…' mutation {
publishArticle(id: "0b9e…") {
id
status
publishedAt
}
} {
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "content_publish",
"arguments": { "typeName": "article", "id": "0b9e…" }
}
} Publishing over the public API needs a full token. A failed gate is a
422 and the entry stays where it was:
{
"message": "Entry validation failed",
"issues": [
{ "field": "title", "message": "is required" },
{ "field": "tags", "message": "is required" }
]
}A successful publish stamps published_at afresh — it records the latest
publish, not the first.
Bulk publishing
Up to 100 ids per call. Preview first, then publish:
curl -X POST https://cms.example.com/api/content/article/bulk/publish/preview \
-b 'orthacms_session=…' -H 'X-Workspace-Id: 7d0c…' \
-H 'Content-Type: application/json' \
-d '{ "ids": ["0b9e…", "4a11…"] }'{
"items": [
{
"id": "0b9e…", "title": "Hello world", "status": "draft", "verdict": "publishable",
"issues": [],
"checks": [
{ "field": "title", "label": "title", "ok": true },
{ "field": "tags", "label": "tags", "ok": true }
]
},
{
"id": "4a11…", "title": "Draft idea", "status": "draft", "verdict": "blocked",
"issues": [{ "field": "tags", "message": "is required" }],
"checks": [
{ "field": "title", "label": "title", "ok": true },
{ "field": "tags", "label": "tags", "ok": false, "message": "is required" }
]
}
]
}checks lists every required field (and any field with an issue); label is
the field’s admin.label, else its name.
POST …/bulk/publish with the same body publishes what passes and reports the
rest:
{ "published": ["0b9e…"], "skipped": [{ "id": "4a11…", "reason": "blocked" }] }Verdicts: publishable, already-published, blocked, not-found,
guard-refused. Since 0.7.0 bulk publish checks required many-relations
exactly like a single publish — before, an entry with no links could slip
through a bulk publish.
When a type is protected
With a protection rule on the type, publishing also needs approvals on the entry’s current revision. It is a separate check from the gate: approvals never make an incomplete entry publishable.
A blocked publish is a 409:
{
"statusCode": 409,
"code": "protection.insufficient_approvals",
"message": "Publishing \"article\" needs 2 approval(s) on the current version; it has 1.",
"required": 2,
"given": 1,
"stale": 0,
"bypassable": true
}- Someone with
protection:managemay send{ "bypass": true }as the admin publish body, where the rule allows it. Anyone else: 403protection.bypass_refused. - An API token cannot be reviewed: its publish on a protected type is a 409
protection.token_refusedunless the rule allows token publishing. - Bulk publish never bypasses; refused ids are skipped as
guard-refused.
Publishing endpoints
Every route needs content:publish.
| Route | Effect |
|---|---|
POST /api/content/:type/:id/publish | Runs the gate, goes live. Body { "bypass": true } optional. |
POST /api/content/:type/:id/unpublish | Back to draft, clearing published_at. |
POST /api/content/:type/bulk/publish/preview | Verdicts only; writes nothing. |
POST /api/content/:type/bulk/publish | Publishes what passes. |
POST /api/content/:type/bulk/unpublish | Unpublishes many. |
POST /api/v1/content/:type/:id/publish, /unpublish | Public API, full token. Also by group/:localeGroupId. |
POST /api/v1/content/:type/bulk/publish, /bulk/unpublish | Public API bulk. |
Publishing marks the relevant revision published and demotes the previous one
to superseded — at most one live version per entry. Unpublishing returns it
to draft; the entry leaves public reads immediately.
Deletion
With paranoid: true delete is a soft delete (deleted_at stamped). Without
it, the row is removed. Every route needs content:delete.
| Route | Effect |
|---|---|
DELETE /api/content/:type/:id | Soft delete; hard delete on a non-paranoid type. 204. |
POST /api/content/:type/:id/restore | Clears deleted_at. |
DELETE /api/content/:type/:id/permanent | Removes a trashed row for good. |
POST /api/content/:type/bulk/delete | Delete many, soft or hard as above. |
POST /api/content/:type/bulk/restore | Restore many. |
POST /api/content/:type/bulk/purge | Permanently delete many trashed rows. |
DELETE /api/v1/content/:type/:id | Public API delete, full token. 204. |
- Restore and purge exist only on a
paranoidtype — elsewhere a 400. - Restoring a translation whose
(locale_group_id, locale)slot was refilled meanwhile is a 409. onDeletefires when a row leaves the table — on a paranoid type, at purge. A'restrict'refusal is a 409, “still referenced by other entries”.- On a localized type deletion is per row: deleting the German translation leaves the English one.
Permanent deletion is permanent
/permanent and bulk/purge remove the row, and onDelete: 'cascade'
relations take their rows with it. The version history is not removed: its
snapshots stay in content_entry_revisions until the workspace is deleted.
A trashed row is excluded from list reads, the public API, relation previews and
pickers. The (locale_group_id, locale) and one-to-one unique indexes are
partial (WHERE deleted_at IS NULL), so a trashed row blocks neither a
replacement translation nor a relation target.
No scheduled publishing
There is no publish-at date. For timed publishing, call the publish endpoint
from a cron job with a full API token — on a type that is not protected, or
whose rule allows token publishing.