# 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](/docs/themes).

## 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:

```bash
curl -H "Authorization: Bearer $KEY" https://sandracms.com/v1/me
```

```json
{
  "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

```bash
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

```json
"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:

```bash
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.

```json
{"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

```bash
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:

```bash
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:

```bash
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.

```bash
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

```bash
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.**

```bash
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`.

```bash
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](/docs/deploy).

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:

```json
{ "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:

```json
{ "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:

```bash
GET /v1/sites/{site}/pages/{page}/preview
```
