The content API
Everything in SandraCMS goes through one API. The Studio uses it, uses it, an agent over MCP uses it, and so can you.
This is the content side — sites, pages, blocks, collections, entries. Writing a theme is a different job: see Writing a Sandra theme.
Before anything else
Authorization: Bearer sk_...
That header, exactly. X-API-Key and a bare token both return 401 — there is one accepted form and this is it.
Then ask what you can do:
curl -H "Authorization: Bearer $KEY" https://sandracms.com/v1/me
{
"user": { "email": "you@example.com", "admin": false },
"app": { "id": "direct", "name": "Direct API access",
"grant": ["content:read", "templates:install", "publish"] },
"scopes": ["content:read", "templates:install", "publish"],
"sites": [{ "id": "your-site", "capabilities": ["content:read", "publish"] }]
}
scopes is what will actually happen — your role, your key's scopes and the app's ceiling, already resolved. Check it before planning work rather than discovering a refusal halfway through an import.
Why a key does less than you expect
A key used directly can install and test themes, read a site and publish it. It cannot write content. That is deliberate: the API is not generally available yet, and content editing goes through the Studio or through an agent over MCP, both of which present an app credential of their own.
If you are writing content, use MCP. /v1/me will tell you which case you are in — app.id is direct when nothing else is presenting you.
The shape of everything
Four rules, and the rest follows:
1. Values go in values. Not fields, not at the top level. A block or entry carries a values object matching its type's fields. 2. Unknown keys are refused. Sending fields gets a 422 naming the key and suggesting the right one. Nothing is ever accepted and quietly ignored. 3. Every resource carries its own url. Do not construct paths — read them. 4. Updates send the version you read. If someone else changed it first you get stale_write with the current state attached, rather than overwriting their work.
Sites and pages
GET /v1/sites
GET /v1/sites/{site}?expand=pages,blocks,site_blocks
PATCH /v1/sites/{site} {"name": "...", "theme_key": "...", "settings": {...}}
GET /v1/sites/{site}/pages
POST /v1/sites/{site}/pages {"title": "...", "parent": "...", "show_in_menu": true}
PATCH /v1/sites/{site}/pages/{page}
DELETE /v1/sites/{site}/pages/{page}
A page may sit inside one other page — send parent with the parent's slug. Two levels only. A child publishes to {parent}/{child}.html, and slugs need only be unique among siblings, so services/design and work/design can both exist.
Deleting a page that has pages inside it is refused, and the error names them.
The theme on a site tells you when it is behind
"theme": {
"key": "plainly", "version": "1.0.0", "name": "Plainly",
"update": { "version": "1.1.0", "name": "Plainly", "installed_at": "2026-08-26" },
"stale": true
}
A site points at one specific version of a theme and does not move on its own. update is a newer version of that same theme sitting installed and unused — PATCH /v1/sites/{site} with its theme_key takes it, and null means the site is already on the newest. stale says the theme has changed since this site was last published, so the live pages are older than the theme they were built from.
Both are worth passing on to whoever owns the site. Neither is something you can resolve for them: taking an update is their decision, and publishing needs the publish capability, which a theme-builder key does not carry.
Images
An image field takes a media token, not a URL and not a path. Upload the file first and use what comes back:
POST /v1/sites/{site}/media {"filename": "team.jpg", "content_base64": "…"}
→ 201 {"id": "m_1a2b3c…", "filename": "team.jpg", "url": "/media/…"}
GET /v1/sites/{site}/media everything already uploaded, with its token
Then {"values": {"photo": "m_1a2b3c…"}}. Over MCP the same two are add_image and list_images.
Writes are refused, not quietly dropped
A value for a field the block type does not have is a 422, naming the fields it does have and guessing at a near miss. It used to be discarded with a 200, which is the same as losing it: content written during a theme upgrade, against fields the site had not adopted yet, vanished without a word.
{"field": "sidebar_html", "code": "unknown_field",
"message": "This block type has no field called 'sidebar_html'. It has: heading, subhead, …"}
The one deliberate exception is theme settings, where a site keeps its answers across a theme switch, so a submission carrying a setting the current theme does not declare is expected rather than mistaken.
Blocks — the content on a page
GET /v1/sites/{site}/pages/{page}/blocks
POST /v1/sites/{site}/pages/{page}/blocks {"type": "...", "region": "body", "values": {...}}
GET /v1/blocks/{id}
PATCH /v1/blocks/{id} {"version": 3, "values": {...}}
DELETE /v1/blocks/{id}
Note the per-block routes are /v1/blocks/{id}, not nested under the page. The url on every block says so; the nested forms 404.
PATCH merges — send one field and the rest are left alone.
Which types exist depends on the site's theme:
GET /v1/content-types?site={site}
Each is a JSON Schema with x-key, x-role, x-slot and per-field x-field-type, so a form can be generated rather than hand-written.
The header and footer
A slot block — menu, hero or footer — is chosen once for the whole site:
GET /v1/sites/{site}/blocks # what the slots are now
PUT /v1/sites/{site}/blocks/{slot} {"type": "...", "values": {...}}
DELETE /v1/sites/{site}/blocks/{slot}
This is the one people get wrong. Posting a block to a page with "region": "menu" is a per-page override — it appears on that page and nowhere else. For a header on every page, PUT to the slot. Over MCP the tools are set_slot and get_slots.
Collections and entries
A block belongs to a page. An entry stands alone with its own URL, SEO and publish state — a blog post, a product. Blocks list entries.
GET /v1/collection-profiles # blog, and its core fields
GET /v1/sites/{site}/collections
POST /v1/sites/{site}/collections {"id": "blog", "from_theme": "blog"}
DELETE /v1/sites/{site}/collections/{collection}
GET /v1/sites/{site}/collections/{collection}/entries
POST /v1/sites/{site}/collections/{collection}/entries {"title": "...", "values": {...}}
GET /v1/entries/{id}
PATCH /v1/entries/{id} {"version": 2, "values": {...}}
DELETE /v1/entries/{id}
Again: per-entry routes are /v1/entries/{id}, unnested.
from_theme accepts what the theme declared — fields, label, URL prefix and ordering — so you do not hand-list five fields and get one wrong.
Entries are drafts unless you send "status": "published", so writing one never publishes it by accident.
Titles
An entry's title can be sent as top-level title or as values.title where the collection declares that field. Whichever you send in a request wins, and the two are kept in step — an entry cannot list under one name and render under another.
Publishing
POST /v1/sites/{site}/publish {"target": "zip"}
GET /v1/sites/{site}/publishes
GET /v1/publishes/{id}/download
A publish snapshots the site, renders the snapshot, and packages a standalone folder. Links inside are relative, so it works at a domain root, in a subfolder, or straight off a disk.
Note the download is at /v1/publishes/{id}/download, outside the site namespace. The publish response gives you the path.
Putting a site live
publish builds the site. What happens to the build depends on the target.
POST /v1/sites/{site}/publish {"target": "zip"} a download, collected by a person
POST /v1/sites/{site}/publish {"target": "deploy"} sent to the server that serves it
deploy is only offered once the site has somewhere to go — a target you can pick and that then fails for want of configuration is worse than one that is not there. GET /v1/capabilities lists the targets and whether each is unattended.
GET /v1/sites/{site}/destinations
PUT /v1/sites/{site}/destinations/production
{"label": "The live site",
"endpoint_url": "https://example.com/deploy.php",
"secret": "…"}
DELETE /v1/sites/{site}/destinations/production
The secret is never returned. It is stored encrypted, it can be replaced, and it cannot be read back — send it blank on an update to keep the one already set. Setting up the receiving end is Putting a site live.
A publish that could not be delivered is a 502 carrying the receiver's own words, and does not become a ready publish: a record saying the site shipped when it did not is worse than no record.
When something is refused
Errors are field-addressable and say what to do:
{ "error": {
"code": "validation_failed",
"details": [{ "field": "fields", "code": "unknown_field",
"message": "This endpoint does not read 'fields'. Did you mean 'values'? Accepted: type, region, values, sort_order." }] } }
A 404 on a path that nearly exists lists what does:
{ "error": { "code": "no_such_endpoint",
"message": "No endpoint matches /v1/sites/x/collections/blog/entries/e_1. Did you mean one of these?",
"nearby": ["DELETE /v1/entries/{token}", "GET /v1/sites/{site}/collections/{collection}/entries"] } }
Codes worth handling: validation_failed (422), stale_write (409), missing_capability (403), unauthenticated (401), not_found (404).
One habit worth having
Check the rendered output, not just the response. A 200 means the write was accepted, not that the page looks how you meant. Preview it:
GET /v1/sites/{site}/pages/{page}/preview