Skip to main content

Content

Corte's CMS has two APIs, and the split is the thing to understand first.

The management API is how you model and edit: it authenticates like every other endpoint in this reference — an API key or a browser session — and it is what the Content studio itself calls.

The delivery API is how an application reads published content. It authenticates with a separate, read-only delivery token, answers cross-origin requests from any site, and can never write anything.

Everything is scoped to a project. Content lives beside that project's media, and project membership is what grants access.

Content types

A content type is the shape of one kind of document. It has a machine name (camelCase, e.g. blogPost) that is its permanent identity — it becomes the GraphQL type name and the delivery type= value — and a title that editors see and can change freely.

The type object

FieldTypeDescription
idstringType id (UUID).
namestringMachine name. Immutable after creation.
titlestringDisplay name.
descriptionstring | nullFree text.
latestVersionnumberCurrent schema version; the CAS token for edits.
documentCountnumberDocuments of this type in the project.
schemaobject{ name, title, description?, fields, titleField? }.
createdAt · updatedAtstringISO 8601.

Field types

typeValue shapeOptions
stringstringmaxLength, enum
textstring (multi-line)maxLength
numbernumberinteger, min, max
booleanboolean
datetimeISO 8601 string
portableTextportable-text array
media{ storageId }accept: ('image'|'video'|'audio')[]
reference{ documentId }to: [typeName] (required)
objectnested objectfields
arrayarrayof (any non-array field)

Every field carries name, title, an optional description, and required. Objects nest up to three levels. titleField names the field that identifies documents in lists and in search. A string, text, portableText or media field may also set localized: true — its value is then kept per language; see Locales and translation.

:::note Reference targets must exist to is checked when the type is saved, so every reference resolves. Create the target type first. :::


POST /v1/projects/:projectId/content/types

Body is the schema itself.

curl -X POST https://api.corte.so/v1/projects/$PROJECT/content/types \
-H "Authorization: Bearer corte_sk_…" \
-H "Content-Type: application/json" \
-d '{
"name": "blogPost",
"title": "Blog post",
"titleField": "headline",
"fields": [
{ "name": "headline", "title": "Headline", "type": "string", "required": true },
{ "name": "slug", "title": "Slug", "type": "string" },
{ "name": "body", "title": "Body", "type": "portableText" },
{ "name": "hero", "title": "Hero image", "type": "media" },
{ "name": "author", "title": "Author", "type": "reference", "to": ["author"] }
]
}'

Returns { contentType } at version 1. A duplicate name in the project is conflict (409).

GET /v1/projects/:projectId/content/types

{ contentTypes, total, limit, offset }. ?limit= (≤200) and ?offset= page.

GET /v1/content/types/:id

?version= returns a historical schema instead of the latest.

PUT /v1/content/types/:id

{ "baseVersion": 1, "title": "Posts", "fields": [], "titleField": "headline" }

baseVersion must equal the type's current latestVersion; a mismatch is conflict (409) — refetch, reapply, retry. Changing fields writes a new immutable version and takes effect immediately: types have no draft state. Editing only title or description does not mint a version. name is not accepted.

Existing documents are not rewritten. Each one keeps the schema version it was last validated against and is re-validated on its next save or publish.

GET /v1/content/types/:id/versions

{ versions, total, limit, offset }, newest first — every historical schema.

DELETE /v1/content/types/:id

conflict (409) while documents of the type exist. ?force=true deletes them and their history with it.


Documents

One document carries two payloads: a draft you edit and a published one the delivery API serves. status reports the relationship.

statusMeaning
draftNever published, or unpublished since.
publishedLive, and the draft matches what is live.
changedLive, with newer unpublished edits.

The document object

FieldTypeDescription
idstringDocument id (UUID). Client-supplied ids are allowed.
typestringThe content type's machine name.
titlestring | nullThe titleField value, when it is a string.
statusenumdraft · published · changed.
draftVersionnumberMonotonic save counter; the CAS token for publish.
draftobjectField values, keyed by field name.
publishedobject | nullWhat delivery serves.
publishedAt · firstPublishedAtstring | nullISO 8601.

POST /v1/projects/:projectId/content/documents

{ "type": "blogPost", "draft": { "headline": "Hello", "slug": "hello" } }

Optional id (a UUID you generate) makes creation idempotent-ish: a second create with the same id is conflict (409).

GET /v1/projects/:projectId/content/documents

Filters: ?type= (machine name), ?status=, ?q= (searches the type's title field — requires type). Returns { documents, total, limit, offset }.

GET /v1/content/documents/:id

Returns the document, the schema it validates against, valid/issues, and — for editors — draftState and stateVector, base64 Yjs binaries described below. Ignore those two if you are not building an editor.

PUT /v1/content/documents/:id

{ "draft": { "headline": "Hello again", "slug": "hello" } }

A full replace: fields you omit are cleared. There is no baseVersion, because the draft is a CRDT — your write merges with anyone editing the same document live, field by field, last writer wins per field.

Returns { version, status, valid, issues, … }.

Draft validation is soft; publish is the gate

A draft save always lands, even when the result does not validate, and reports { valid, issues }. This is deliberate. Two individually valid saves can merge into an invalid document — one editor empties a required field while another edits elsewhere — and there is no principled loser to reject. Drafts are working copies; publish is where the rules are enforced.

POST /v1/content/documents/:id/draft-update

The collaborative-editing path. Skip it unless you hold a local Y.Doc.

{ "epoch": 1, "update": "<base64 Yjs update>", "stateVector": "<base64>" }

The server applies your update, rematerializes the document, and replies with { version, valid, issues, update, stateVector } where update is the ops you are missing — other people's merged saves. One round trip, both sides converge. Applying an update twice is a no-op, so retrying a save is safe.

epoch guards a rare server-side compaction that rebuilds the document's history. A stale epoch is conflict (409): reload and reapply.

POST /v1/content/documents/:id/publish

{ "baseVersion": 4 }

baseVersion must equal the document's current draftVersion — publishing is a judgement about a state someone reviewed, so a draft that moved underneath is a real conflict (409). The draft must validate against the current schema or the call fails with invalid_params (400).

POST /v1/content/documents/:id/unpublish

Takes the document off the delivery API; the draft is untouched. Refused with conflict while published documents reference this one — pass ?force=true to proceed anyway.

DELETE /v1/content/documents/:id

Same reference guard, same ?force=true. The document, its history and its reference edges go together.

Revisions

  • GET /v1/content/documents/:id/revisions{ revisions, total, limit, offset }, newest first. Kinds: draft, publish, unpublish, restore, delete.
  • GET /v1/content/documents/:id/revisions/:revisionId — one revision with its payload.
  • POST /v1/content/documents/:id/revisions/:revisionId/restore — replays that snapshot onto the live draft. Optional baseVersion restores only onto the version you looked at.

Consecutive draft saves by one person within five minutes coalesce into a single revision. Draft snapshots are pruned to the newest 50 per document; publish and unpublish steps are kept far longer.


Delivery tokens

Read-only keys for the delivery API, minted by the project owner.

  • POST /v1/projects/:projectId/content/tokens{ name, mode? }{ token, secret }
  • GET /v1/projects/:projectId/content/tokens
  • DELETE /v1/content/tokens/:id

mode is published (live content only) or preview (also reads unpublished drafts). Max 20 per project. Corte stores only a SHA-256 hash, so the secret appears exactly once.

:::caution Preview tokens are not public A published token is safe in a browser bundle — it can only read what you published. A preview token exposes unpublished drafts and belongs on a server. :::


Delivery API

Authorization: Bearer corte_ct_…

Read-only, and the only part of the Corte API that answers cross-origin requests from any site. That is safe because these routes never accept a cookie: the token is the whole credential, so a third-party page cannot ride a visitor's Corte session.

Documents serialize flat:

{
"_id": "…", "_type": "blogPost", "_status": "published",
"_createdAt": "…", "_updatedAt": "…", "_publishedAt": "…",
"headline": "Hello",
"hero": { "storageId": "…", "url": "https://…", "contentType": "image/png" },
"author": { "documentId": "…" }
}

Media resolves to a signed, expiring url. A media field whose file was never granted to the project keeps its storageId and gets no URL.

GET /v1/content/:projectId/documents

ParameterDescription
typeRequired. Content type machine name.
whereURL-encoded JSON filter (below).
sortComma-separated fields; -field for descending.
selectComma-separated fields to return. System fields always come back.
expandComma-separated reference paths to inline.
expandDepth1 (default) or 2.
limit · offsetlimit ≤ 100, default 20.
perspectivedrafts (preview default) or published.

Filter operators: eq, ne, in (≤50 values), lt, lte, gt, gte, contains (case-insensitive substring), exists. Top-level fields only. Anything but exists needs a scalar field.

curl -G https://api.corte.so/v1/content/$PROJECT/documents \
-H "Authorization: Bearer corte_ct_…" \
--data-urlencode 'type=blogPost' \
--data-urlencode 'where={"views":{"gte":100},"slug":{"eq":"hello"}}' \
--data-urlencode 'sort=-_publishedAt' \
--data-urlencode 'expand=author' \
--data-urlencode 'limit=10'

expand replaces { documentId } with the document itself. A reference to something unpublished or deleted expands to null rather than leaking a draft.

Returns { documents, total, limit, offset }total counts the whole result set, not the page.

GET /v1/content/:projectId/documents/:id

Same select / expand / perspective parameters. 404 when the document is not published and the token cannot see drafts.

GET /v1/content/:projectId/types

The project's schemas — what a codegen step or a typed SDK reads.

Preview

A preview token reads the working copy of every document, including ones never published, and may pass ?perspective=published to see what is live instead. A published token asking for drafts is refused with forbidden (403).


POST /v1/content/:projectId/graphql

A GraphQL schema is generated per project from your types. blogPost becomes BlogPost; nested objects are parent-prefixed (BlogPostSeo); a reference with several targets becomes a union.

Each type gets two root fields:

{
blogPost(id: "…") { headline author { name } }
allBlogPost(where: { views: { gte: 100 } }, sort: [_publishedAt_DESC], limit: 10) {
items { _id headline hero { url contentType } }
total
}
}

Field mapping: string/text/datetimeString, numberFloat (or Int with integer: true), booleanBoolean, portableText → a JSON scalar, mediaMediaAsset { storageId, url, contentType }, array → a list. Every document type implements a Document interface carrying the _ system fields.

References resolve through a batching loader, so selecting the author of a hundred posts costs one extra query rather than a hundred.

No mutations are generated — the delivery API is read-only by construction. A malformed or unknown query answers 400 with a GraphQL errors array; a query that runs and fails answers 200 with partial data plus errors. Queries are capped at 50 KB and 10 levels of nesting.


Webhooks

Tell a site to rebuild when content changes. Project owner only.

Events: document.publish, document.unpublish, document.delete.

  • POST /v1/projects/:projectId/content/webhooks{ name, url, events, enabled? }{ webhook, secret }
  • GET /v1/projects/:projectId/content/webhooks
  • PATCH / DELETE /v1/content/webhooks/:id
  • GET /v1/content/webhooks/:id/deliveries — the attempt log
  • POST /v1/content/webhooks/:id/deliveries/:deliveryId/retry

URLs must be https and resolve to a publicly routable address — loopback and private ranges are refused, at save time and again before every send.

The payload

{
"event": "document.publish",
"projectId": "…",
"documentId": "…",
"type": "blogPost",
"publishedAt": "2026-08-07T10:00:00.000Z",
"document": { "_id": "…", "_type": "blogPost", "headline": "Hello" }
}

document is the delivery-shaped published payload. It is present on document.publish only — after an unpublish or a delete there is nothing live to describe — and is omitted when it would exceed 256 KB, in which case refetch by id.

Verifying the signature

Every delivery carries X-Corte-Event, X-Corte-Delivery, X-Corte-Timestamp, and:

X-Corte-Signature: sha256=<hex>

The MAC covers the timestamp and the body, so a captured delivery cannot be replayed later:

import { createHmac, timingSafeEqual } from 'node:crypto'

function verify(req, rawBody, secret) {
const timestamp = req.headers['x-corte-timestamp']
// Reject anything too old to be a live delivery.
if (Math.abs(Date.now() - Number(timestamp)) > 5 * 60_000) return false

const expected = 'sha256=' + createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex')
const received = req.headers['x-corte-signature'] ?? ''
return expected.length === received.length
&& timingSafeEqual(Buffer.from(expected), Buffer.from(received))
}

Sign over the raw body, before any JSON parsing — re-serializing changes the bytes and the MAC will not match.

Retries

A 2xx marks the delivery delivered. Anything else is retried up to five times with a widening backoff (30s, 2m, 8m, 32m), after which it is failed and visible in the deliveries log. Fix the receiver and use the retry endpoint to requeue it with a fresh ladder.


Agents

The MCP server exposes the management API as tools, so an agent can scaffold a whole CMS — define types, seed documents, publish them, and mint a delivery token for the app it is building. The tools call the same code these routes do, so the rules on this page apply unchanged.