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
| Field | Type | Description |
|---|---|---|
id | string | Type id (UUID). |
name | string | Machine name. Immutable after creation. |
title | string | Display name. |
description | string | null | Free text. |
latestVersion | number | Current schema version; the CAS token for edits. |
documentCount | number | Documents of this type in the project. |
schema | object | { name, title, description?, fields, titleField? }. |
createdAt · updatedAt | string | ISO 8601. |
Field types
type | Value shape | Options |
|---|---|---|
string | string | maxLength, enum |
text | string (multi-line) | maxLength |
number | number | integer, min, max |
boolean | boolean | — |
datetime | ISO 8601 string | — |
portableText | portable-text array | — |
media | { storageId } | accept: ('image'|'video'|'audio')[] |
reference | { documentId } | to: [typeName] (required) |
object | nested object | fields |
array | array | of (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.
status | Meaning |
|---|---|
draft | Never published, or unpublished since. |
published | Live, and the draft matches what is live. |
changed | Live, with newer unpublished edits. |
The document object
| Field | Type | Description |
|---|---|---|
id | string | Document id (UUID). Client-supplied ids are allowed. |
type | string | The content type's machine name. |
title | string | null | The titleField value, when it is a string. |
status | enum | draft · published · changed. |
draftVersion | number | Monotonic save counter; the CAS token for publish. |
draft | object | Field values, keyed by field name. |
published | object | null | What delivery serves. |
publishedAt · firstPublishedAt | string | null | ISO 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. OptionalbaseVersionrestores 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/tokensDELETE /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
| Parameter | Description |
|---|---|
type | Required. Content type machine name. |
where | URL-encoded JSON filter (below). |
sort | Comma-separated fields; -field for descending. |
select | Comma-separated fields to return. System fields always come back. |
expand | Comma-separated reference paths to inline. |
expandDepth | 1 (default) or 2. |
limit · offset | limit ≤ 100, default 20. |
perspective | drafts (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/datetime → String, number → Float (or
Int with integer: true), boolean → Boolean, portableText → a JSON
scalar, media → MediaAsset { 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/webhooksPATCH/DELETE /v1/content/webhooks/:idGET /v1/content/webhooks/:id/deliveries— the attempt logPOST /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.
Related
- Locales and translation — per-field locales, fallbacks,
?locale=, machine translation - Projects — membership and the owner role
- MCP tools — the agent-facing surface
- Errors — codes and status mapping