# Content Management API

> Script setup and data loading with the JSON API behind the web app, covering session sign-in, errors, and the endpoints for types, entries and groups.

The Content Management API is the JSON API the Spatibase web app uses to define the content model, edit and publish entries, curate groups, manage media and import or export data. Use it to automate setup and data loading from scripts. To read published content in websites and apps, use the [Content Delivery API](https://spatibase.com/docs/reference/delivery-api/) instead.

Placeholders on this page: `$PROJECT_ID` is the UUID in the project's address (`https://app.spatibase.com/projects/<id>/...`), and `jar.txt` is the curl cookie file that holds your session.

## Stability

The Content Management API is the interface of the Spatibase web app, not a versioned public API. Paths and payloads can change without notice, and there is no changelog for it. Build scripts against it if you accept that you may have to adjust them. For anything your visitors depend on, read content through the [Content Delivery API](https://spatibase.com/docs/reference/delivery-api/), which is versioned under `/delivery/v1`.

## Authentication for scripts

The Content Management API accepts only a signed-in session: a script signs in by emailed link, like a person, and keeps the session cookie. API keys do not work here.

| Credential | Content Delivery API | Content Management API | MCP server |
|---|---|---|---|
| Session cookie (signed in by email link) | no | yes, with the rights of your role | no |
| Read key `sb_live_…` | yes | no | yes, read only |
| Edit key `sb_edit_…` | yes, as a read key | no | yes, drafts and the switches on the key |
| OAuth token for an AI assistant | no | no | yes, up to the person's role |

AI assistants never call the Content Management API directly: they use the [MCP server](https://spatibase.com/docs/ai/mcp-server/), which allows only what their key or connection allows.

### Sign in from a script

1. Ask for a link on the sign-in page at `https://app.spatibase.com/login`. Sign-in emails are protected by a bot check (Cloudflare Turnstile) that only the sign-in page can pass, so asking from a script (`POST /api/auth/magic-link`) answers `400` with `code: "captcha_failed"`.
2. The email contains a link like `https://app.spatibase.com/auth/verify?token=…`. Copy the `token` value without opening the link: opening it in a browser uses the token up.
3. Exchange the token for a session cookie:

   ```bash
   curl -s -c jar.txt -X POST https://api.spatibase.com/api/auth/magic-link/verify \
     -H 'Content-Type: application/json' -d '{"token":"PASTE_TOKEN"}'
   # 200 {"user":{"id":42,"email":"you@example.com","name":"","is_master_user":false,"plan":"free","handle":"you",
   #      "onboarded":true,"onboarding":{},"avatarUrl":null},"created":false}
   ```

4. Send the cookie with every request: `curl -b jar.txt https://api.spatibase.com/api/projects`.

> **Note:** Instead of steps 2 and 3 you can sign in in a browser and copy the `spatibase.sid` cookie into your script.

| Method and path | Result |
|---|---|
| `POST /api/auth/magic-link` | `{email}` → `202` (optional `ref` and `landing` record where a new account came from; optional `next`, an app path such as `/import`, is added to the emailed link so it lands there on another device, and is dropped when it isn't a plain app path or points at a sign-in page). `400` for a bad address. `400` with `code: "captcha_failed"` without a passed bot check from the sign-in page. `429` with `Retry-After` after 5 links per address or 20 per IP address in an hour |
| `POST /api/auth/magic-link/verify` | `{token}` → `200 {user, created}` and the cookie. `400 {message, code}` with `code` `invalid`, `used` or `expired`. A link works once, for 15 minutes |
| `POST /api/auth/magic-link/verify-code` | `{email, code}` → `200 {user, created}` and the cookie, like `/verify`. The sign-in email also contains a 6-digit code (for installed phone apps, which keep their own cookies). The link and the code are one token: using either uses up both, and only the newest email's code counts. A wrong, expired, used or locked code (after 5 wrong tries) answers `400 {message}`, always the same message. `429` with `Retry-After` after 30 failed tries per IP address or 15 per address in an hour |
| `GET /api/auth/me` | `200 {user, authRequired, turnstileSiteKey, siteOrigins}`, or `401 {message, authRequired, turnstileSiteKey, siteOrigins}` without a session. `siteOrigins` lists the website origins the app accepts a free tool's file from |
| `PATCH /api/auth/me` | any of `{name, handle, onboarding, onboarded}` → `{user}`. A taken `handle` answers `409` |
| `POST /api/auth/logout` | ends the session → `200 {message}` |

The session cookie (`spatibase.sid`) lasts 30 days from the last request. Without it, `/api/projects…` answers `401 {"message":"Please sign in to continue."}`.

### Access rules

- You see only projects you are a member of. Another person's project answers `404 Project not found`, exactly like a project that does not exist.
- Your role in the project (`owner`, `editor` or `developer`, returned as `role` on every project) decides what you may do. A request your role does not allow answers `403` with a plain `message`. See [Members and invitations](https://spatibase.com/docs/reference/management-api/#members-and-invitations).
- Requests from scripts and servers (no `Origin` header) work. Browser pages on other sites cannot call the Content Management API: CORS allows only the web app, and writes with another `Origin` (or a cross-site browser request without one) answer `403`.

## Conventions

| Topic | Rule |
|---|---|
| Base URL | `https://api.spatibase.com/api`. Project paths start with `/api/projects/$PROJECT_ID`. |
| Format | JSON in and out (`Content-Type: application/json`), except file uploads (multipart). Request bodies up to 50 MB. |
| IDs | UUIDs. A malformed or unknown id in the path answers `404`. |
| `apiKey` | Elements, types, groups and map feature types have an `apiKey` matching `^[a-z][a-z0-9_-]{0,63}$`, unique per project and resource. Left out, it is a slug of the name, made unique (`path`, `path-2`). Element keys cannot be `id`, `title`, `type`, `geometry`, `status`, `createdAt`, `updatedAt` or `publishedAt`. |
| PATCH | Only the keys you send change. Two exceptions: element `config` and entry `values` are replaced as a whole. |
| Unknown keys | Ignored: unknown body fields, unknown config keys, and `values` keys that are not elements of the entry's type. |

### Errors

Every error is `{"message": string, "errors"?: {field: string}}`, with messages written for people. A few also carry a machine-readable `code` (`entry_changed`, or `invalid`, `used` and `expired` on sign-in links).

| Status | When |
|---|---|
| `400` | Malformed JSON, a bad query parameter (`sort`, `limit`, `status`, `type`), `values` not an object, a bad import `format` or `data`, upload problems, an invalid asset PATCH, changing a map feature type's `apiKey` or `geometryKind` |
| `401` | No session |
| `403` | Your role does not allow it, or a browser write from another site |
| `404` | Unknown project, resource or route (`{"message":"Not found"}`) |
| `409` | Duplicate `apiKey`, changing an element's `kind`, deleting something still in use, a stale group order, an entry changed since you loaded it |
| `413` | Uploaded file over 25 MB |
| `422` | Validation. `errors` is keyed by body field (`name`, `parentId`, `elementIds`), by element config key (`options`, `mode`, `targetTypeIds`), or for entries by element `apiKey`, `title`, `geometry`, `typeId` or `groupIds` |
| `429` | Too many requests: sign-in links or codes, invitations, or public dataset copies and imports. `Retry-After` says how many seconds to wait |
| `500` | `{"message":"Internal server error"}` |

```bash
curl -s -b jar.txt -X POST https://api.spatibase.com/api/projects \
  -H 'Content-Type: application/json' -d '{"name":"  "}'
# 422 {"message":"Please check the highlighted fields.","errors":{"name":"Give the project a name."}}
```

### Draft, publish and content version

- Entry writes (create, update, import) change the **draft** only. The Content Delivery API shows only the **published** copy, and the published copy changes only in a **project publish** (below).
- `status` is derived: `draft` (never published), `published` (no edits since) or `changed` (published, with newer draft edits).
- An entry also has two facts beside `status`: `ready` (`true` when someone marked this exact draft ready for the next publish; any real change to the draft clears it) and `pendingAction` (`unpublish` or `delete` while a live entry waits to leave the API at the next publish, otherwise `null`). An entry never has both. Publishing does not touch entries that are not ready.
- A draft save checks value shapes, `maxCount` and `maxSelected`, geometry and identifier uniqueness. Marking ready (and so publishing) also checks `required`, `minCount` and `minSelected`, a non-empty title, a main location when the type's `locationMode` is `required`, and a map feature type on every shape when the element offers feature types.
- Live at once, without a publish step: model changes (types, elements, map feature types), project settings (name, description, languages, `deliveryAccess`) and asset alt text, filename and deletion. Group changes (membership, order, name, key, description, creating and deleting) are **drafts** and ship with the next publish.
- Each project has a `contentVersion` integer. Every change to Delivery output increases it, and Delivery ETags are built from it. A draft save does not, nor does marking an entry ready, staging a removal or editing a group. A publish that ships something increases it once, a publish with nothing to ship does not, and going back to a version increases it once. Switching `deliveryAccess` increases it. Changing the `basemap` does not. The numbers of [versions](https://spatibase.com/docs/reference/management-api/#versions) are separate: they count publishes.

## Projects

| Method | Path | Body → response |
|---|---|---|
| GET | `/api/projects` | → `[project]`, most recently updated first |
| GET | `/api/projects/templates` | → `[{key, name, summary, counts, preview}]` |
| POST | `/api/projects` | `{name, description?, deliveryAccess?, basemap?, template?}` → `201` project. The caller becomes its owner |
| GET | `/api/projects/:projectId` | → project |
| PATCH | `/api/projects/:projectId` | `{name?, description?, deliveryAccess?, basemap?, thumbnailView?, locales?}` → project |
| DELETE | `/api/projects/:projectId` | → `204`. Deletes everything, including uploaded files. Owners only |
| POST | `/api/projects/:projectId/duplicate` | `{name?, entries?, groups?, media?, keepPublished?}` → `201` project plus `copied`, `notCopied`. Owners and editors |
| POST | `/api/projects/:projectId/publish` | `{token?}` → `{version, published, removed, failed:[{id, title, errors}]}`; see [Publish and versions](https://spatibase.com/docs/reference/management-api/#publish-and-versions) |
| GET | `/api/datasets` | → `[{slug, name, description, licence, filters:[{field, label, kind}], snapshotAt, counts:{entries}, updatedAt}]`, the released public datasets |
| GET | `/api/datasets/:slug` | → `{slug, name, description, licence, filters, snapshotAt, counts:{types, elements, entries, published}, updatedAt}`; unknown or not yet released slug `404` |
| GET | `/api/datasets/:slug/count` | `?field=&value=&bbox=w,s,e,n` → `{count, limit, filter}`: how many entries a copy with this filter takes. A field that isn't one of the dataset's `filters` → `422` `errors.filter` |
| GET | `/api/datasets/:slug/values/:field` | → `{field, values:[{value, count}]}` (≤ 500, most common first) of a filter field |
| POST | `/api/datasets/:slug/copy` | `{name?, description?, filter?: {field, value}, bbox?: [w, s, e, n]}` → `201` project: a copy of the dataset (or of the matching entries) in your account, entries published; ≤ 10,000 matching entries (`422` says to filter). Max 10 a day per person (`429`, `Retry-After`) |
| GET | `/api/projects/:pid/datasets` | → the datasets plus `sources` (what this project copied or imported from each: kind, snapshotAt, filter, entries) and `updateAvailable` |
| POST | `/api/projects/:pid/datasets/:slug/import` | `{filter?, bbox?, dryRun?}` → `{matched, imported, skipped, withoutIdentifier, created:{types, elements, featureTypes}, dataset, filter, attribution}`: the dataset's published entries as **drafts** in this project; missing types and fields are created, existing ones never changed (`409 dataset_model_conflict`); entries matched by the identifier field, so a re-run adds only what's new. Owners and editors; AI assistants with Edit |

```json
{ "id": "105ae2d1-3a58-4f8b-a1e8-d3e6359f0a4b", "name": "Trails", "description": "",
  "createdAt": "2026-10-13T09:12:51.391Z", "updatedAt": "2026-10-13T09:12:51.391Z", "contentVersion": 0,
  "deliveryAccess": "public", "basemap": { "style": "auto" }, "role": "owner",
  "counts": { "types": 0, "elements": 0, "entries": 0, "published": 0, "draft": 0, "changed": 0, "ready": 0, "notReady": 0,
              "pendingRemovals": 0, "groupChanges": 0, "latestVersion": null, "groups": 0,
              "assets": 0, "assetBytes": 0, "apiKeys": 0 },
  "thumbnailView": null, "lastActivityAt": "2026-10-13T09:12:51.400Z", "bbox": null,
  "mcpUrl": "https://mcp.spatibase.com/mcp/v1/projects/105ae2d1-3a58-4f8b-a1e8-d3e6359f0a4b",
  "deliveryUrl": "https://api.spatibase.com/delivery/v1/projects/105ae2d1-3a58-4f8b-a1e8-d3e6359f0a4b" }
```

- `deliveryAccess` is `"public"` (default: the Content Delivery API needs no key) or `"key"` (every Delivery request needs an [API key](https://spatibase.com/docs/reference/management-api/#api-keys)). Any other value is a `422`. Only owners change it.
- `template` is a `key` from `/api/projects/templates` (`store-locator`, `outdoor-guide`, `events-map`). The project then starts with that template's model, sample entries and group. An unknown key is a `422 errors.template`. Each template's `preview` is `[{lat, lng, color, title}]`, one point per sample entry (at most 50; the middle vertex of a line or an area's outline), in its type's color, for showing the template on a map before creating it.
- **Public datasets** need any signed-in account, not membership; the copy (`{name?, description?}`) is an ordinary project you own, with the dataset's content model, entries (published as in the dataset), groups and files. See [Create a project](https://spatibase.com/docs/get-started/create-a-project/#can-i-start-from-a-public-dataset). AI assistants can't use these routes; inside their own project they can list datasets and import one as drafts (`/api/projects/:pid/datasets…`, MCP `import_dataset`). A dataset release can be scheduled: until then it is not listed and can't be copied or imported.
- `counts.draft` and `counts.changed` are the entries that differ from the published copy; `counts.ready` of them are marked ready, `counts.notReady` are not, `counts.pendingRemovals` are live entries waiting to be unpublished or removed and `counts.groupChanges` groups with changes; together they say what a publish would ship and what it would leave out. `counts.latestVersion` is the newest [version](https://spatibase.com/docs/reference/management-api/#versions) number or `null`. `counts.apiKeys` counts active read keys. `bbox` is `[west, south, east, north]` around the entries' locations (`null` without any), and `lastActivityAt` is the newest Activity row. `mcpUrl` and `deliveryUrl` come only with a single project (create, get, patch), not in the list.
- `thumbnailView` is the map area the project card shows, `[west, south, east, north]`, or `null` for automatic. Owners and editors can save it, like the basemap.
- `locales` is the list of the project's [languages](https://spatibase.com/docs/content/translations/) as codes such as `en`, `de` or `pt-BR`; the first one is the **default language**. `[]` (the value in a new project) means a single-language project. At most 20 languages. Only owners change it, and a change bumps `contentVersion`. Bad codes, duplicates, more than 20 and a changed default language answer `422 errors.locales` (the default can't change while languages are on). Adding or removing the other languages is free; removing one keeps its stored translations, hidden, and adding it again brings them back. Turning languages back on with a default that already has stored translations is refused. `GET /api/projects/:projectId` (not the list) also returns `storedLocales`: the codes that still have stored translations while languages are off, so a client can suggest the previous setup.
- A PATCH that sends what is already stored changes nothing (no new `updatedAt`, no activity row).

**Duplicate** copies the content model and, as chosen, entries, groups and media files into a new project the caller owns. `entries`, `groups` and `media` are on by default, `keepPublished` (entries stay published instead of becoming drafts) is off; `groups` and `keepPublished` need `entries`. The copy keeps the source's name plus " (copy)", description, `deliveryAccess`, basemap and thumbnail; members, API keys, the webhook, Activity and the trash are never copied. A project with more than 10,000 entries, or more than 1 GB of media, answers `422` until you send `entries: false` or `media: false`. The answer lists what was copied (`copied: {types, elements, featureTypes, entries, published, groups, files}`) and `notCopied: {fileLinks}`, the file links dropped because a file was not copied (`copied.published` says whether entries kept their published state). AI assistants can't duplicate projects.

The route `POST /api/projects/:projectId/publish` is the **project publish**: see [Publish and versions](https://spatibase.com/docs/reference/management-api/#publish-and-versions). There is no publish for a single entry.

### Basemap

`basemap` is the background map everyone editing the project sees in the web app. It never appears in the Content Delivery API. Owners and editors can change it. Shape: `{style, custom?, labels?, blueprint?}`.

- `style`: `auto` (default for new projects: the web app's maps follow the app theme), `streets`, `terrain`, `light`, `dark`, or `custom`. The older keys still work and are saved as the new ones: `liberty` and `bright` become `streets`, `positron` becomes `light`. Responses always use the new keys.
- `labels`: `false` hides the map's labels (absent means shown). `blueprint`: `true` draws Light and Dark in their blueprint look (absent means off); it is kept with every style but only used with `light` and `dark`.
- `custom`: `{name, url}` or `{name, json}`. `name` is 1 to 60 characters. `url` is an `http(s)` address of a MapLibre style JSON (up to 2000 characters, no user name or password). `json` is a pasted style object (`"version": 8`, a `sources` object, a `layers` array), up to 1 MB. The browser loads the style; Spatibase never fetches it.
- PATCH is partial. `{"basemap":{"style":"terrain"}}` keeps a stored `custom`. Sending `custom` replaces it as a whole. `{"basemap":{"custom":null}}` removes it, and the map goes back to `auto` if the custom one was chosen.
- The project list leaves out `custom.json` (it can be 1 MB). `GET /api/projects/:projectId` includes it.
- Every basemap error is a `422` with one message under `errors.basemap`.

```bash
curl -s -b jar.txt -X PATCH "https://api.spatibase.com/api/projects/$PROJECT_ID" -H 'Content-Type: application/json' \
  -d '{"basemap":{"style":"custom","custom":{"name":"Winter","url":"https://api.maptiler.com/maps/winter-v2/style.json?key=YOUR_KEY"}}}'
```

The activity log keeps the basemap's name and the URL's host, never the full URL or the JSON.

## Elements

Elements (fields) are reusable field definitions: define one once per project, then assign it to types. Paths from here on are relative to `/api/projects/$PROJECT_ID`. Model changes (elements, types, map feature types) are for owners.

| Method | Path | Body → response |
|---|---|---|
| GET | `/elements?includeArchived=true` | → `[element]` (archived ones only with the flag) |
| POST | `/elements` | `{label, kind, apiKey?, required?, helpText?, config?}` → `201` element |
| PATCH | `/elements/:elementId` | any of `label, apiKey, required, helpText, config` → element |
| DELETE | `/elements/:elementId` | archives it and removes it from every type → element with `archived: true` |
| DELETE | `/elements/:elementId?permanent=true` | → `204`, or `409` while any entry (draft or published) has a value for it |
| POST | `/elements/:elementId/restore` | un-archives it, without assigning it to types again → element |
| GET | `/elements/:elementId/option-usage` | `choice` elements only (`400` otherwise) → `{[optionValue]: {entries, draft, published}}`: how many entries use each stored value |

Element shape: `{id, label, apiKey, kind, required, helpText, config, archived, usedIn:[{id, name}]}`.

| `kind` | `config` (all optional unless noted) |
|---|---|
| `text` | `editor`: `single` (default), `multi` or `json`; `minLength`, `maxLength` (0 = no limit) |
| `markdown`, `boolean`, `link`, `contact`, `color`, `location`, `phone` | none |
| `number` | `min`, `max`, `integer`, `unit` |
| `date` | `mode`: `date` (default), `datetime` or `time`; `range` (start and end); `minDuration`, `maxDuration` in minutes (ranges only) |
| `choice` | `options` (**required**: a tree up to 4 levels, up to 1000 options, values unique across the tree), `multiple`, `minSelected`, `maxSelected` (multiple only) |
| `images` | `minCount`, `maxCount` |
| `files` | `minCount`, `maxCount`, `allowedFormats` (a list, or text like `"pdf, gpx"` → `[".pdf",".gpx"]`) |
| `reference` | `targetTypeIds` (empty = any type; subtypes of a target are allowed too), `multiple`, `minCount`, `maxCount` (multiple only) |
| `geo` | `geometryKinds`, `maxFeatures` (1 to 100, default 1), `featureTypeIds` |
| `identifier` | `minLength`, `maxLength` (up to 255; values are checked against 64 when unset), `fromTitle` |
| `embed` | `providers`: any of `youtube`, `vimeo`, `soundcloud`, `spotify`, `other` |

```bash
curl -s -b jar.txt -X POST "https://api.spatibase.com/api/projects/$PROJECT_ID/elements" -H 'Content-Type: application/json' \
  -d '{"label":"Length","kind":"number","required":true,"helpText":"Total length in km","config":{"min":0,"unit":"km"}}'
```
```json
{ "id": "d93f51e3-64fa-48ec-af84-8af4561c1556", "label": "Length", "apiKey": "length", "kind": "number", "required": true,
  "helpText": "Total length in km", "config": { "min": 0, "unit": "km" }, "archived": false, "usedIn": [] }
```

More create bodies:

```json
{"label":"Surface","kind":"choice","config":{"multiple":true,"maxSelected":3,"options":[
  {"value":"natural","label":"Natural","children":[{"value":"dirt","label":"Dirt"},{"value":"gravel","label":"Gravel"}]},
  {"value":"paved","label":"Paved","children":[{"value":"asphalt","label":"Asphalt"}]}]}}
```
```json
{"label":"Open season","kind":"date","config":{"mode":"date","range":true,"minDuration":1440}}
```
```json
{"label":"Regions","kind":"reference","config":{"targetTypeIds":["c9f0346b-3962-40ab-a8c2-d24d6e1597c3"],"multiple":true,"maxCount":3}}
```
```json
{"label":"Route features","kind":"geo","config":{"geometryKinds":["LineString","Point"],"maxFeatures":10,
  "featureTypeIds":["e17eeb05-e4b1-40ea-b489-1429f87ae572"]}}
```

Rules to know:

- **PATCH replaces `config`.** Sending `{"config":{"maxLength":200}}` to an element with `{"editor":"multi","maxLength":300}` leaves `{"maxLength":200}`: the `editor` setting is gone. Always send the complete config.
- **`kind` cannot change** (`409`). Create a new element instead.
- **Renaming `apiKey`** rewrites that key in every entry's draft and published values in one step. Apps reading the old key stop finding it.
- **Archiving** keeps every stored value. Restoring does not assign the element to types again: use `PUT /types/:typeId/elements`.
- A duplicate `apiKey` answers `409`. A reserved one (`title`) answers `422 errors.apiKey`.

## Map feature types

Map feature types are named, styled kinds of shape ("Path", "Parking") that `geo` elements offer through `featureTypeIds`. Each stored shape refers to one by `properties.featureType`, its `apiKey`.

| Method | Path | Body → response |
|---|---|---|
| GET | `/feature-types` | → `[{id, name, apiKey, geometryKind, icon, color, sortOrder, usage:{elements, shapes}}]` |
| POST | `/feature-types` | `{name, geometryKind, apiKey?, icon?, color?, sortOrder?}` → `201` |
| PATCH | `/feature-types/:id` | `{name?, icon?, color?, sortOrder?}`. A changed `apiKey` or `geometryKind` → `400` |
| DELETE | `/feature-types/:id` | → `204`, or `409` while a `geo` element offers it or a stored shape uses it |

`geometryKind` is `Point`, `LineString` or `Polygon`. `icon` is one of `pin`, `flag`, `tree`, `mountain`, `tent`, `water`, `building`, `route`, `star`, `parking`, `info`, or `null`. `color` is `#rrggbb` (default `#1aa356`).

```bash
curl -s -b jar.txt -X POST "https://api.spatibase.com/api/projects/$PROJECT_ID/feature-types" -H 'Content-Type: application/json' \
  -d '{"name":"Path","geometryKind":"LineString","color":"#16a34a","icon":"route"}'
# 201 {"id":"e17eeb05-…","name":"Path","apiKey":"path","geometryKind":"LineString","icon":"route","color":"#16a34a","sortOrder":0,"usage":{"elements":0,"shapes":0}}
```

## Types

A type is an ordered list of assigned elements, plus main-location settings, a color and an `enabled` flag. Types form a tree up to 5 levels deep. A subtype inherits its ancestors' elements (root first), followed by its own.

| Method | Path | Body → response |
|---|---|---|
| GET | `/types` | → `[type]` in tree order (parents before children) |
| POST | `/types` | `{name, apiKey?, description?, parentId?, locationMode?, geometryKinds?, color?, enabled?, sortOrder?, elementIds?}` → `201` |
| GET | `/types/:typeId` | → type |
| PATCH | `/types/:typeId` | any POST field except `elementIds`. `parentId: null` makes it a root type |
| PUT | `/types/:typeId/elements` | `{elementIds:[…]}` replaces the type's **own** assignments, in array order → type |
| DELETE | `/types/:typeId` | → `204`, or `409` while it has entries or subtypes, or a reference element (archived ones too) targets it |

| Field | Values |
|---|---|
| `locationMode` | `required` (marking ready needs a main location), `optional` (default), `none` (no map; any geometry sent is dropped) |
| `geometryKinds` | a non-empty subset of `Point`, `LineString`, `Polygon` (`Multi*` is accepted and mapped to its base kind). Default: all |
| `color` | `#rrggbb`, default `#1aa356` |
| `enabled` | `false` hides the type and its subtypes from the Content Delivery API and blocks new entries and imports. `effectiveEnabled` shows the inherited result |

Response fields: `id, name, apiKey, description, parentId, locationMode, geometryKinds, color, enabled, effectiveEnabled, sortOrder, entryCount, childCount, ownElementIds, elements`. `elements` is the effective list: full element objects, each with `inheritedFrom: {id, name}` or `null`.

```bash
curl -s -b jar.txt -X POST "https://api.spatibase.com/api/projects/$PROJECT_ID/types" -H 'Content-Type: application/json' \
  -d '{"name":"Trail","locationMode":"required","geometryKinds":["LineString"],"color":"#16a34a","elementIds":["83bc7593-…","d93f51e3-…"]}'
# 201 {"id":"74497dfa-…","apiKey":"trail","parentId":null,"locationMode":"required","geometryKinds":["LineString"],
#      "enabled":true,"effectiveEnabled":true,"entryCount":0,"childCount":0,"ownElementIds":["83bc7593-…","d93f51e3-…"],"elements":[…]}
```

- A subtype created without its own `locationMode`, `geometryKinds` or `color` copies them from its parent.
- Assigning an element the type already inherits is a `422 errors.elementIds`. When a type moves in the tree, own assignments that are now inherited are removed.
- Placing a type under one of its own subtypes, or a sixth level, is a `422 errors.parentId`.

## Entries

| Method | Path | Body → response |
|---|---|---|
| GET | `/entries` | query below → `{data:[summary], meta:{page, limit, total}}` |
| POST | `/entries` | `{typeId, title?, values?, translations?, geometry?, groupIds?}` → `201` entry (draft) |
| GET | `/entries/:entryId` | → entry |
| PATCH | `/entries/:entryId` | `{title?, values?, translations?, geometry?, mergeValues?, expectedUpdatedAt?}` → entry |
| DELETE | `/entries/:entryId` | A live entry: **stages** the delete → `200` entry with `pendingAction: "delete"`; it stays live until the next publish, then goes to the [trash](https://spatibase.com/docs/reference/management-api/#trash) for 30 days. An entry that was never published goes to the trash at once → `204` |
| POST | `/entries/:entryId/ready` | marks the draft ready for the next publish → entry, or `422` with `errors` when it would fail the publish checks. `DELETE` on the same path takes the mark away (back to draft) |
| POST | `/entries/:entryId/unpublish` | **stages** the unpublish → entry with `pendingAction: "unpublish"`; it comes off the API at the next publish (no effect on an entry that is not live) |
| DELETE | `/entries/:entryId/pending` | keeps the entry: clears a staged unpublish or delete → entry |
| POST | `/entries/:entryId/restore-version` | `{number}` copies the entry's state from that [version](https://spatibase.com/docs/reference/management-api/#versions) into its draft, not ready → entry plus `missingFiles` |
| PUT | `/entries/:entryId/groups` | `{groupIds:[…]}` sets its group memberships in the groups' drafts (new ones go to the end of each group; they ship with the next publish) → entry |
| POST | `/entries/bulk` | `{action, ids, groupId?, values?}` → `{results:[{id, ok, message?}], ok, failed}` |

**List query:** `type` (type UUIDs, comma-separated; not apiKeys), `includeSubtypes=true`, `group` (UUIDs), `status` (`draft`, `published`, `changed`, `ready`, `pending`), `q` (title contains, case-insensitive), `sort` (`title`, `updatedAt`, `createdAt`; a `-` prefix sorts descending; default `-updatedAt`), `page` (default 1), `limit` (default 100; values above 1000 are cut to 1000), `changedBy=agent` (drafts an AI assistant changed last that nobody has reviewed), `missingLocale=<code>` ([translations](https://spatibase.com/docs/reference/management-api/#translations)), `include=values`. Summary: `{id, typeId, title, geometry, status, updatedAt, publishedAt, groupIds, changedByAgent, ready, pendingAction}`. Bad values answer `400` with a message that names the allowed ones.

**`include=values`** (the only `include` value; another answers `400` `Unknown include "x". Use include=values.`): each summary also carries `values` (the stored draft values, the same object as the single entry's) and the response gets page-level `assets` and `references` maps, `{[id]: asset}` and `{[id]: {id, title, typeId}}`, covering every image, file and reference id in that page's values (one lookup per page, not per row). Without `include` the response is unchanged. The app's entries Table reads entries this way.

`changedByAgent` is `true` while the newest draft change came from an AI assistant and the draft is not published. A person's save that changes something sets it back to `false`; publishing clears it. `status=ready` lists the entries marked ready, `status=pending` those waiting to be unpublished or removed.

**Bulk:** `action` is `markReady`, `markDraft`, `unpublish`, `delete`, `keep`, `addToGroup`, `removeFromGroup` or `setValues` (`publish` no longer exists), `ids` holds 1 to 200 entry ids, and `groupId` is needed for the group actions. Each id succeeds or fails on its own, and unknown ids fail with "Entry not found". Nothing here changes the live API: `unpublish` and `delete` stage the removal for the next publish (a delete of an entry that was never live goes to the trash at once), `keep` clears a staged removal, and group actions change the groups' drafts. Owners and editors only. **`setValues`** sets the same fields on every listed entry: `values` is an object of 1 to 20 element apiKeys mapped to a value (`null` clears), merged into each entry's **draft** like `PATCH` with `mergeValues: true`, so only those keys change and the whole draft is validated. The request answers `422` (`errors.values`) when `values` is empty or not an object, has more than 20 keys, names a key that is no element of the project, or names an `identifier` element (identifiers must be unique, so they cannot be set on several entries). Per entry, a key that is not a field of the entry's type fails that entry ("“Price” isn’t a field of this entry’s type."), a validation error fails it with `<Field label>: <message>`, and an unchanged draft is `ok` and writes nothing. It changes drafts only: no `contentVersion` bump and no webhook; each changed entry gets an `updated` audit row (`meta.via: "bulk"`). Each `setValues` result also has `updatedAt` (the entry's after the write) and `before`, the old value of every key it set (`null` when there was none), so you can put them back. To avoid overwriting someone else's change, send `expectedUpdatedAt`: an object of entry ids mapped to the `updatedAt` you loaded. An entry saved since then fails with `code: "entry_changed"` and its current `updatedAt`, and is left as it is; ids missing from the object are not checked. The other actions bump `contentVersion` once, and only when something live changed.

### Values per kind

Values are keyed by element `apiKey`. Empty values (`null`, `""`, `[]`, `{}`) count as no value. What the Content Delivery API returns for each kind is on [Field values](https://spatibase.com/docs/reference/field-values/).

| `kind` | Send | Stored as, notes |
|---|---|---|
| `text`, `markdown` | `"…"` (a number becomes text) | The `single` editor trims. The `json` editor must parse as JSON |
| `number` | `1.6` or `"1.6"` | number; `min`, `max`, `integer` checked |
| `boolean` | `true` or `"true"` | boolean |
| `date` | `"2026-05-01"`, `"2026-05-01T14:30"`, time `"7:30"` | time padded to `"07:30"`; a range is `{start, end}` |
| `choice` | `"easy"` or `["dirt","boardwalk"]` | option **values** at any level of the tree |
| `link`, `embed` | `{"url":"https://…","label":"…"}` or `"https://…"` | `{url, label?}` or `{url}`; embed `providers` checked |
| `images`, `files` | `["$ASSET_ID", …]` | assets must exist in the project; `images` must be web images |
| `contact` | `{name?, email?, phone?, website?, address?:{street?, city?, region?, postalCode?, country?}}` | email and website checked |
| `color` | `"#ABCDEF"` | lowercased |
| `reference` | `"$ENTRY_ID"` or `["$ENTRY_ID", …]` | a single id is wrapped in an array when `multiple` |
| `geo` | FeatureCollection, Feature or bare geometry | a FeatureCollection; `properties` keep only `label` and `featureType` |
| `identifier` | `"fern-canyon-loop"` | `^[A-Za-z0-9][A-Za-z0-9._-]*$`, unique per element across all entries' drafts |
| `location` | `{"lng":-124.05,"lat":41.4,"address":"…"}` (numbers or numeric strings) | longitude wrapped into ±180, 7 decimals |
| `phone` | `" +1  707-465-7335 "` | `"+1 707-465-7335"`: 5 to 25 characters, at least 5 digits |

### Create an entry

```bash
curl -s -b jar.txt -X POST "https://api.spatibase.com/api/projects/$PROJECT_ID/entries" -H 'Content-Type: application/json' -d '{
  "typeId": "74497dfa-e594-4897-8bc5-d46b3334755b", "title": "Fern Canyon Loop",
  "geometry": {"type":"LineString","coordinates":[[-124.0461,41.3990],[-124.0502,41.4013]]},
  "values": {"slug":"fern-canyon-loop","length":"1.6","surface":["dirt","boardwalk"],"opens-at":"7:30",
             "regions":"e52152c3-9c8c-44fa-b33b-e026ca9b42d5","photos":["8e0d13e0-8181-4058-a27a-464f709efba4"]}}'
```
```json
{ "id": "4d6809f7-5cc6-4033-a1bb-8197f7754d8c", "typeId": "74497dfa-e594-4897-8bc5-d46b3334755b", "title": "Fern Canyon Loop",
  "values": { "slug": "fern-canyon-loop", "length": 1.6, "surface": ["dirt", "boardwalk"], "opens-at": "07:30",
              "regions": ["e52152c3-9c8c-44fa-b33b-e026ca9b42d5"], "photos": ["8e0d13e0-8181-4058-a27a-464f709efba4"] },
  "geometry": { "type": "LineString", "coordinates": [[-124.0461, 41.399], [-124.0502, 41.4013]] },
  "status": "draft", "createdAt": "2026-10-13T09:15:02.311Z", "updatedAt": "2026-10-13T09:15:02.311Z", "publishedAt": null,
  "groupIds": [], "changedByAgent": false,
  "assets": { "8e0d13e0-…": { "id": "8e0d13e0-…", "filename": "trailhead.jpg", "mimeType": "image/jpeg", "size": 2069,
                              "width": 640, "height": 480, "alt": "", "url": "/assets/8e0d13e0-…/trailhead.jpg", "isImage": true } },
  "references": { "e52152c3-…": { "title": "Redwood Coast", "typeId": "c9f0346b-…" } } }
```

`assets` (the same objects as in [Assets](https://spatibase.com/docs/reference/management-api/#assets)) and `references` carry metadata for every asset and entry id used in `values`. A type that is switched off rejects new entries with `422 errors.typeId`.

### Validation errors

All problems come back together, keyed by element `apiKey` (plus `title` and `geometry`):

```json
{ "message": "Some fields need attention. Check the highlighted fields and try again.",
  "errors": { "geometry": "Trail entries can only be a line on the map.", "length": "Enter a number.",
              "surface": "Choose up to 3 options (4 are selected).", "slug": "Already used by “Fern Canyon Loop”" } }
```

Sending `"values": ["x"]` answers `400 {"message":"values must be an object keyed by element apiKey"}`.

### Translations

In a project with [languages](https://spatibase.com/docs/content/translations/), an entry also has `translations`, the texts in the non-default languages (the entry's `title` and `values` hold the default language):

```json
{ "title": "Redwood National Park", "values": { "description": "..." },
  "translations": { "de": { "title": "Redwood-Nationalpark", "values": { "description": "..." } } } }
```

Only the title, `text` fields (not JSON text) and `markdown` fields can be translated. `POST` and `PATCH` accept `translations` and **merge** them per language and per key, so only what you send changes:

```bash
curl -s -b jar.txt -X PATCH "https://api.spatibase.com/api/projects/$PROJECT_ID/entries/$ENTRY_ID" -H 'Content-Type: application/json' \
  -d '{"translations":{"de":{"title":"Redwood-Nationalpark","values":{"description":"..."}}}}'
```

- `null` or an empty text removes one translation; `"de": null` removes the whole language. Removed texts fall back to the default language in the Delivery API.
- A language that isn't one of the project's, the default language, a field that can't be translated, or a text that breaks the field's own limits answers `422`: `errors.translations` holds the first problem and `errors["translations.de.description"]` names the field.
- Translations are part of the draft: the entry becomes `changed` until it is published, and one publish publishes every language. The publish checks (required fields and so on) look at the default language only.
- In the list, `missingLocale=de` returns only entries whose title, or a non-empty translatable default value, has no German text yet, and each summary has `missingTranslations: {"de": 2}`: how many texts are missing per non-default language (`0` = complete). `include=values` adds `translations` to each summary.
- Choice options carry their own labels per language in the element's `config.options[].labels`, for example `{"de": "Leicht"}`; `label` stays the default.
- Imports and exports handle languages too ([Import API](https://spatibase.com/docs/reference/import-api/#languages)). Export writes `title:de` and `<apiKey>:de` properties.

### Update with PATCH

- `title` or `geometry` left out: unchanged. `geometry: null` clears the location.
- `values` left out or `null`: unchanged. Otherwise it **replaces** the values of the type's elements, so a key you leave out is cleared. Values of archived or unassigned elements are kept.
- `"mergeValues": true` changes only the keys you send; `null` clears one.
- The whole new draft is validated, so a problem in a field you did not send (for example a deleted asset) still blocks the save.

```bash
curl -s -b jar.txt -X PATCH "https://api.spatibase.com/api/projects/$PROJECT_ID/entries/$ENTRY_ID" -H 'Content-Type: application/json' \
  -d '{"values":{"length":3.5}}'
# before: "values":{"slug":"lost-man-creek","length":3}   after: "values":{"length":3.5}   (slug cleared)
```

### Avoid overwriting someone's change (409)

Send `expectedUpdatedAt`, the entry's `updatedAt` as you loaded it. When someone saved in between, the save fails instead of overwriting their change:

```bash
curl -s -b jar.txt -X PATCH "https://api.spatibase.com/api/projects/$PROJECT_ID/entries/$ENTRY_ID" -H 'Content-Type: application/json' \
  -d '{"values":{"length":3.5},"mergeValues":true,"expectedUpdatedAt":"2026-10-13T09:16:24.730Z"}'
# 409 {"message":"This entry changed since you loaded it. Load it again and retry.","code":"entry_changed","updatedAt":"2026-10-13T09:20:01.112Z"}
```

A value that is not a date-time is a `422 errors.expectedUpdatedAt`.

### Publish and versions

**Ready.** `POST /entries/:entryId/ready` marks a draft as approved for the next publish and answers the entry with `ready: true`, or a `422` that lists what blocks it:

```json
{ "message": "This entry can’t be published yet. Fix the highlighted fields and try again.",
  "errors": { "geometry": "Place this entry on the map before publishing." } }
```

`DELETE /entries/:entryId/ready` takes the mark away. A save that changes the draft clears it, so what was marked is what ships. `POST /projects/:projectId/ready` with `{"all": true}` or `{"typeId": "…"}` marks every draft and changed entry that passes the checks ready in one call (no limit of 200, meant for after a big import) and answers `{marked, failed: [{id, title, errors}], failedTotal}`.

**Preview and publish.** `GET /projects/:projectId/publish/preview` lists what a publish would do: `{token, ready: {added, changed}, removals, groups, notReady: {count, changedByAgent}, failing, totals}`. Lists stop at 200 rows; `totals` do not. `POST /projects/:projectId/publish` ships everything that is ready, carries out staged unpublishes and deletes, and ships group changes, as one transaction and one new version:

```json
{ "version": 7, "published": 12, "removed": 2,
  "failed": [ { "id": "f451704c-6ae1-4dfb-a549-044f6c49671f", "title": "Unknown keys",
                "errors": { "geometry": "Place this entry on the map before publishing." } } ] }
```

It answers `200` even when entries fail: a ready entry that fails the checks keeps its draft and its mark and is listed in `failed`, and the rest ships. Send the preview's `token` as `{"token": "…"}` to publish exactly what you reviewed: when the pending work changed in the meantime the answer is `409` with `code: "publish_changed"` and nothing happens. `version` is `null` when there was nothing to ship (no version, no `contentVersion` change). Drafts that are not ready are never touched, and the Content Delivery API keeps the live copy of an entry you are still editing.

Editing a published entry makes it `changed`, and the Content Delivery API keeps the old copy until the next publish. A staged `unpublish` removes the published copy at the publish, and the Content Delivery API then answers `404` for it. A shape in a `geo` value without `featureType` saves as a draft, but marking ready fails with "Choose a map feature type for this shape."

### Versions

Every publish that ships something, and every going back, creates a numbered version of everything live. A version is never rewritten. The first version of a project that already had live entries is a `baseline`.

| Method | Path | Body → response |
|---|---|---|
| GET | `/versions?page&limit` | newest first (`limit` up to 200, default 50) → `{data:[{number, kind, restoredFrom, actor, added, changed, removed, groupsChanged, createdAt}], meta:{page, limit, total, oldestKept}}`. `kind` is `baseline`, `publish` or `rollback` |
| GET | `/versions/:number` | the version plus `changes: {added, changed, removed, groups}`; a changed entry lists its `fields` (`key`, `label`, `from`, `to`), with the same privacy rules as [Activity](https://spatibase.com/docs/reference/management-api/#audit-log). Lists stop at 200. `404` for a version that is not kept |
| POST | `/versions/:number/restore?dryRun=true` | goes back to that version, live at once, as a new `rollback` version → `{version, restoredFrom, added, changed, removed, groups, lists, keptDrafts, purged, skipped, missingFiles}`. With `dryRun=true` the same work runs and is rolled back (`version: null`) |
| POST | `/entries/:entryId/restore-version` | `{number}` copies the entry's state at that version into its **draft**, not ready; `422` when the entry was not live then or another draft holds its identifier |

Going back changes every entry whose live copy differs from its state at that version. A draft with no edits beyond the live copy moves with it, so the next publish cannot ship the mistake again; a draft with newer edits is kept (`keptDrafts`). Entries deleted for good from the trash cannot come back (`purged`), an entry the trash refuses to restore is `skipped`, and a file that no longer exists is removed from restored values (`missingFiles`). Pending work on other entries is not shipped by going back. Every version of the last 90 days is kept, and always at least the last 20; `oldestKept` is the oldest number the routes still return. Webhook messages for a publish or going back carry the version number, see [Webhook events](https://spatibase.com/docs/reference/webhook-events/).

### Geometry rules

The main location (`geometry`) and shapes in `geo` values follow the same rules:

- GeoJSON `Point`, `LineString`, `Polygon` and their `Multi*` forms. A `Feature` is unwrapped. A `GeometryCollection` is refused.
- Coordinates are WGS84 `[lng, lat]`, rounded to 7 decimals. Z values are dropped and unclosed rings are closed. A shape drawn on a wrapped copy of the world is shifted back (`[235.95, 41.40]` becomes `[-124.05, 41.4]`).
- Latitude must be between -90 and 90. A line needs 2 points, an area 3 corners.
- The kind must be in the type's `geometryKinds` ("Region entries can only be an area on the map."). For `locationMode: none` the geometry is dropped.

## Groups

Groups are named, hand-ordered lists of entries. Owners and editors can change them. Every change to a group is a draft and ships with the next [publish](https://spatibase.com/docs/reference/management-api/#publish-and-versions); the Content Delivery API keeps listing the group as it was at the last publish, and only its published entries. A group in the list carries `live` (it has a live form), `deletedPending` (a staged delete), `pendingChanges` (a publish would ship something for it) and `publishedCount`.

| Method | Path | Body → response |
|---|---|---|
| GET | `/groups` | → `[{id, name, apiKey, description, sortOrder, entryCount, publishedCount, createdAt, updatedAt}]` |
| POST | `/groups` | `{name, apiKey?, description?, sortOrder?}` → `201` group |
| GET, PATCH, DELETE | `/groups/:groupId` | → group, group, `204` (the entries stay). Deleting a live group **stages** the delete; a group that was never live goes at once |
| DELETE | `/groups/:groupId/pending` | keeps a group staged for deletion → group |
| GET | `/groups/:groupId/entries` | → `[summary]` in group order, all statuses |
| PUT | `/groups/:groupId/entries` | `{entryIds}` replaces the membership, in array order → `[summary]` |
| POST | `/groups/:groupId/entries` | `{entryIds}` appends; entries already in the group keep their place → `[summary]` |
| PUT | `/groups/:groupId/order` | `{entryIds}` reorders. Must be **exactly** the current members, else `409` |
| DELETE | `/groups/:groupId/entries/:entryId` | → `204`, or `404` when the entry is not in the group |

```bash
curl -s -b jar.txt -X PUT "https://api.spatibase.com/api/projects/$PROJECT_ID/groups/$GROUP_ID/order" -H 'Content-Type: application/json' \
  -d '{"entryIds":["'$A'","'$B'"]}'
# 409 {"message":"This group changed since you opened it. Reload to see the latest list."}
```

Use `/order` to reorder, so a stale list can never drop an entry someone else added. Duplicate `apiKey` answers `409`; unknown entry ids answer `422 errors.entryIds`.

## Assets

Assets are uploaded images and files, each with a permanent URL. Resizing and Delivery output are on [Assets and images](https://spatibase.com/docs/reference/assets-and-images/).

| Method | Path | Body → response |
|---|---|---|
| POST | `/assets` | `multipart/form-data`, field **`files`** (1 to 20 files, 25 MB each) → `201 [asset]` |
| GET | `/assets?kind=image\|file&q=` | → `[asset]` newest first; `q` matches the filename |
| PATCH | `/assets/:assetId` | `{alt?, filename?}` → asset |
| DELETE | `/assets/:assetId` | → `204`. The asset goes to the [trash](https://spatibase.com/docs/reference/management-api/#trash) and its URL answers `404` |

```bash
curl -s -b jar.txt -X POST "https://api.spatibase.com/api/projects/$PROJECT_ID/assets" -F "files=@trailhead.jpg"
```
```json
[ { "id": "8e0d13e0-8181-4058-a27a-464f709efba4", "filename": "trailhead.jpg", "mimeType": "image/jpeg", "size": 2069,
    "width": 640, "height": 480, "alt": "", "url": "/assets/8e0d13e0-8181-4058-a27a-464f709efba4/trailhead.jpg",
    "createdAt": "2026-10-13T09:14:45.528Z", "isImage": true } ]
```

- `url` is relative to `https://api.spatibase.com`.
- Images are recognised from the file content: JPEG, PNG, GIF, WebP, AVIF and SVG get `isImage: true`. Other files keep the type the upload declared, or one guessed from the extension.
- The wrong field name, no files, or an empty filename in a PATCH answer `400`. A file over 25 MB answers `413`.
- Deleting an asset removes its id from the `images` and `files` values of every entry's draft and published copy. Entry statuses do not change.

## Dashboard

`GET /dashboard` returns counts per type and per status, plus the 20 newest changes from the [audit log](https://spatibase.com/docs/reference/management-api/#audit-log). The web app's **Analytics** page reads it. Rows that only echo another row (an import's per-entry rows, a group's side of a membership change) are left out of `recentActivity`.

```json
{ "counts": { "entries": 4, "published": 2, "draft": 2, "changed": 0, "agentChanged": 0, "lastPublishedAt": "2026-10-13T09:18:42.256Z",
              "types": 4, "elements": 20, "groups": 1, "files": 2, "apiKeys": 1 },
  "entriesByType": [ { "typeId": "c9f0346b-…", "name": "Region", "color": "#9333ea", "count": 1 } ],
  "entriesByStatus": [ { "status": "draft", "count": 2 }, { "status": "published", "count": 2 }, { "status": "changed", "count": 0 } ],
  "recentActivity": [ { "at": "2026-10-13T09:16:24.730Z", "action": "updated", "entityType": "entry", "entityId": "4d6809f7-…",
                        "title": "Fern Canyon Loop", "summary": "Updated entry “Fern Canyon Loop”: Length 1.6 → 3.5",
                        "actor": { "id": 42, "name": "Ann Example", "kind": "user", "keyId": null, "client": null } } ] }
```

## Audit log

The audit log records every change made through the Content Management API: who, what and when. It is what the web app shows as **Activity**. A change and its row are saved together or not at all; reads, failed requests and saves that change nothing write no row.

```bash
curl -s -b jar.txt "https://api.spatibase.com/api/projects/$PROJECT_ID/audit?entityType=entry&limit=1"
```
```json
{ "data": [ { "id": 1038984, "at": "2026-10-13T11:20:06.885Z",
    "actor": { "id": 42, "name": "Ann Example", "kind": "user", "keyId": null, "client": null },
    "action": "updated", "entityType": "entry", "entityId": "4b1e1956-4a99-46bb-8297-de9e09c18a04", "title": "Sign Hill Park",
    "summary": "Updated entry “Sign Hill Park”: Rating 4.5 → 4.8, Phone added",
    "changes": { "_order": ["values.rating", "values.phone"],
                 "values.rating": { "label": "Rating", "from": 4.5, "to": 4.8 },
                 "values.phone": { "label": "Phone", "change": "set" } },
    "meta": null } ],
  "nextCursor": 1038983, "identified": true }
```

| Query parameter | Meaning |
|---|---|
| `entityType` | `project`, `type`, `element`, `entry`, `group`, `asset`, `feature_type` or `api_key` |
| `entityId` | one item's history, for example an entry's (with `entityType`) |
| `action` | `created`, `updated`, `deleted`, `published`, `unpublished`, `archived`, `restored` (also a going back to a version, on the project), `reordered`, `imported`, `uploaded`, `members_changed`, `revoked`, `rotated` |
| `actor` | a user id |
| `actorKind` | `user` (people) or `agent` (AI assistants) |
| `from`, `to` | ISO dates (whole UTC days) or ISO date-times with a time zone, inclusive |
| `q` | text in the title or summary, case-insensitive, up to 200 characters |
| `limit` | 1 to 200, default 50 |
| `before` | cursor: the previous response's `nextCursor` |

- Rows come newest first. Bad parameter values answer `400`. `identified` is `false` only when the server runs without sign-in, so every action shows the same built-in user.
- Saving the same entry again and again (autosave in the entry panel) does not flood the log: edits to one entry by the same person or assistant fold into one row while the previous edit is less than 10 minutes old, and the row moves to the top with the combined `changes`. A row for an edit that undoes the previous one disappears.
- `summary` is a ready-to-show sentence. `changes` lists changed fields: `from` and `to` when the value can be shown, otherwise `change: "set" | "changed" | "removed"`. Ignore keys that start with `_`.
- `actor.kind` is `user` or `agent`. An AI assistant on an edit key appears as `"AI assistant (<key name>)"` with its `keyId` and `client`; one connected by a person appears as "<person> via <app>".
- Values of `contact`, `phone` and `location` fields are never stored, only that they changed. When a deleted entry leaves the trash for good, the values in its earlier rows are removed.
- Rows cannot be edited or deleted through the API.

## Trash

Deleted entries and assets stay in the project's trash for 30 days, then they are removed for good. Owners and editors restore and purge; every member can list it.

| Method | Path | Body → response |
|---|---|---|
| GET | `/trash?kind=entry\|asset` | → `[item]` newest first |
| POST | `/trash/:trashId/restore` | → `{kind, item, restoredLinks, notRestored}` |
| DELETE | `/trash/:trashId` | delete forever → `204` |
| DELETE | `/trash` | empty the trash → `{purged: n}` |

```json
[ { "id": "5d0c…", "kind": "entry", "itemId": "4b1e1956-…", "title": "Old mill", "deletedAt": "2026-10-13T12:00:00.000Z",
    "deletedBy": { "id": 42, "name": "Ann Example", "kind": "user" }, "purgeAfter": "2026-11-12T12:00:00.000Z",
    "typeId": "74497dfa-…", "wasPublished": true } ]
```

An asset item has `mimeType` instead of `typeId` and `wasPublished`. `?kind=entry|asset` filters; another value answers `400`.

- Restore brings the item back with the **same id**. An entry comes back as an unpublished draft, back into groups that still exist (at the end), and its links go back into the drafts of entries that lost them. An asset comes back into the library and into the drafts it was removed from.
- `notRestored` lists links that could not go back, as `{entryId, field, reason}`.
- Restoring an entry whose type was deleted, or whose identifier another entry now uses, answers `409`.
- An unknown `trashId` answers `404 {"message":"Item not found in the trash"}`.

## API keys

API keys are read-only keys for a **private** project's Content Delivery API (`deliveryAccess: "key"`), and edit keys connect AI assistants. How clients send them is on [Authentication](https://spatibase.com/docs/reference/authentication/). Owners and developers manage keys; editors get `403`.

| Method | Path | Body → response |
|---|---|---|
| GET | `/api-keys` | → `[key]` newest first, revoked and expired ones included |
| POST | `/api-keys` | `{name, expiresAt?, scope?, canPublish?, canDelete?}` → `201` key **plus `key`, the secret, shown only here** |
| PATCH | `/api-keys/:keyId` | `{name?, canPublish?, canDelete?}` → key. A revoked key → `409` |
| DELETE | `/api-keys/:keyId` | → `200` key with `status: "revoked"`. It stops working at once |
| POST | `/api-keys/:keyId/rotate` | `{expiresAt?}` → `201` a replacement with the same name **plus `key`**. The old key is revoked and gets `replacedBy` |

```bash
curl -s -b jar.txt -X POST "https://api.spatibase.com/api/projects/$PROJECT_ID/api-keys" -H 'Content-Type: application/json' -d '{"name":"Website"}'
```
```json
{ "id": "7eb9d42d-e2c1-4612-a832-f4bfdac13ab6", "name": "Website", "prefix": "sb_live_lZSX", "scope": "read", "status": "active",
  "canPublish": false, "canDelete": false, "createdAt": "2026-10-13T14:40:05.825Z", "createdBy": { "id": 42, "name": "Ann Example" },
  "expiresAt": null, "lastUsedAt": null, "revokedAt": null, "replacedBy": null,
  "key": "sb_live_lZSXPDKtJuiaKsh0rovGzHuqww4W1ZfJ" }
```

- `scope` is `read` (default, secret `sb_live_…`) or `edit` (secret `sb_edit_…`, for AI assistants through the [MCP server](https://spatibase.com/docs/ai/mcp-server/)). The scope never changes.
- Edit keys have two switches, off by default: `canPublish` (mark ready, publish, unpublish, go back to a version, group membership) and `canDelete` (delete entries and files). Read keys cannot have them (`422`).
- Creating or rotating an edit key, and switching `canPublish` or `canDelete` on, is for owners only. Developers can rename, switch off and revoke.
- Spatibase stores only a hash of the secret and its `prefix` (the first 12 characters). A lost key cannot be shown again: rotate it.
- `name` is 1 to 100 characters. `expiresAt` is an ISO date-time with a time zone, in the future, within 10 years, or `null` for never. Up to 50 active keys per project (`409` beyond).
- `status` is `active`, `expired` or `revoked`. `lastUsedAt` is updated at most once a minute.
- No API key, of either scope, works on the Content Management API itself.

To make the Content Delivery API private, an owner sends `PATCH /api/projects/$PROJECT_ID` with `{"deliveryAccess":"key"}`; `"public"` switches it back.

## Webhook

Each project has one webhook, managed at `/webhook` by owners and developers: `GET`, `PUT {url, enabled?}`, `POST /test`, `POST /rotate` and `DELETE`. The full contract, payload and signature check are on [Webhook events](https://spatibase.com/docs/reference/webhook-events/#manage-the-webhook-with-the-content-management-api).

## Members and invitations

Members are the people in a project, each with one role.

| Role | May |
|---|---|
| `owner` | everything, including the content model, project settings, deleting the project, and members |
| `editor` | entries (also bulk), publishing and going back to versions, groups, files, import and export, the trash, the basemap and map thumbnail, duplicating the project |
| `developer` | API keys, the webhook, disconnecting AI assistants; reads everything else |

| Method | Path | Who → result |
|---|---|---|
| GET | `/members` | any member → `{people, invitations, assistants}`. `people` items are `{userId, name, handle, email, avatarUrl, role, isYou, joinedAt}`; `assistants` are edit keys and OAuth connections, never secrets |
| POST | `/members/invitations` | owner: `{email, role}` → `201`. Emails a link valid for 7 days. `409` if already a member; inviting again refreshes the link. `429` after many invitations in an hour |
| PATCH | `/members/invitations/:invitationId` | owner: `{role}` → the invitation. Changes the role of an open invitation; the link and expiry stay, no new email. `404` if used, cancelled or expired |
| DELETE | `/members/invitations/:invitationId` | owner: cancel → `204` |
| PATCH | `/members/:userId` | owner: `{role}` → the person |
| GET | `/members/:userId/avatar` | any member → the member's profile picture (`404` without one) |
| DELETE | `/members/:userId` | owner removes anyone; anyone may remove themselves → `204` |
| DELETE | `/members/assistants/oauth/:grantId` | owner, developer: disconnect someone's AI assistant → `204` |
| GET | `/api/invitations/:token` | public → `{projectName, role, email, invitedByName}`; `404` when used, cancelled or expired |
| POST | `/api/invitations/:token/accept` | signed in with the invited address → `{projectId}` (`403` for another address) |

A project always keeps one owner: demoting or removing the last one answers `409`.

```bash
curl -s -b jar.txt -X POST "https://api.spatibase.com/api/projects/$PROJECT_ID/types" -H 'Content-Type: application/json' -d '{"name":"X"}'   # as an editor
# 403 {"message":"Editors can’t change the content model (types, fields and feature types). Ask a project owner."}
```

## Import and export

`POST /import` loads GeoJSON features or CSV/TSV rows into one type as drafts. `GET /export?type=$TYPE_ID` downloads drafts as GeoJSON. `"dryRun": true` on an import checks the data and returns the same result without writing anything. The request, value coercion, matching and limits are on [Import API](https://spatibase.com/docs/reference/import-api/).

## Other endpoints the web app uses

These serve the app's own screens. They are listed so you know they exist, and they can change like the rest of this API.

| Method and path | Result |
|---|---|
| `GET /api/auth/handle-available?handle=` | `{available, problem}` for the profile's @handle |
| `POST /api/auth/me/avatar` | `multipart/form-data`, field `avatar` (one JPG, PNG or WebP, up to 5 MB), cropped to a square → `{user}`. `DELETE /api/auth/me/avatar` removes it; `GET /api/auth/me/avatar` reads it |
| `GET /api/account/usage` | what the person's projects use now: `{projects:[{id, name, entries, published, files, storageBytes, aiCalls, aiWrites}], totals, aiWindowDays}` (the Plan page) |
| `GET /api/account/onboarding` | the Getting started steps done in any of the person's projects |
| `GET /api/preview/:projectId` | public: whether this visitor may open the data preview at `/view/<id>` (private projects reveal nothing but whether the person is signed in) |
| `GET /api/projects/:projectId/preview…` | a member's way to read a private project's published content for the preview, with the same output as the Content Delivery API |

## A typical automation flow

This flow creates a project, defines the model, loads and publishes an entry, then reads it through the Content Delivery API. Every call uses the cookie from [Sign in from a script](https://spatibase.com/docs/reference/management-api/#sign-in-from-a-script).

```bash
API=https://api.spatibase.com/api
# 1. Project
curl -s -b jar.txt -X POST "$API/projects" -H 'Content-Type: application/json' -d '{"name":"Viewpoints"}'
# → {"id":"5ac0662f-caaa-477b-997e-abc6a45b6715","contentVersion":0, …}
PROJECT_ID=5ac0662f-caaa-477b-997e-abc6a45b6715

# 2. Elements
curl -s -b jar.txt -X POST "$API/projects/$PROJECT_ID/elements" -H 'Content-Type: application/json' \
  -d '{"label":"Elevation","kind":"number","required":true,"config":{"unit":"m","integer":true}}'   # → {"id":"76339ee6-…","apiKey":"elevation"}
curl -s -b jar.txt -X POST "$API/projects/$PROJECT_ID/elements" -H 'Content-Type: application/json' \
  -d '{"label":"Slug","kind":"identifier","config":{"fromTitle":true}}'                            # → {"id":"bcbe5907-…","apiKey":"slug"}

# 3. Type with those elements, in this order
curl -s -b jar.txt -X POST "$API/projects/$PROJECT_ID/types" -H 'Content-Type: application/json' \
  -d '{"name":"Viewpoint","locationMode":"required","geometryKinds":["Point"],"elementIds":["bcbe5907-…","76339ee6-…"]}'
# → {"id":"8649d07d-…","apiKey":"viewpoint", …}

# 4. Entry (a draft). For many rows, use POST /import instead
curl -s -b jar.txt -X POST "$API/projects/$PROJECT_ID/entries" -H 'Content-Type: application/json' \
  -d '{"typeId":"8649d07d-…","title":"Crescent Beach Overlook","geometry":{"type":"Point","coordinates":[-124.1406,41.7086]},
       "values":{"slug":"crescent-beach-overlook","elevation":75}}'                                # → {"id":"aa214081-…","status":"draft"}

# 5. Mark the entry ready, then publish the project (a new version with everything that is ready)
curl -s -b jar.txt -X POST "$API/projects/$PROJECT_ID/entries/aa214081-…/ready"             # → {"ready":true, …}
curl -s -b jar.txt -X POST "$API/projects/$PROJECT_ID/publish"                                # → {"version":1,"published":1,"removed":0,"failed":[]}

# 6. Read it through the Content Delivery API (types by apiKey there, no cookie needed for a public project)
curl -s "https://api.spatibase.com/delivery/v1/projects/$PROJECT_ID/entries?type=viewpoint"
```
```json
{ "data": [ { "id": "aa214081-867d-4326-93aa-5fb9583da849", "type": "viewpoint", "typePath": ["viewpoint"],
              "title": "Crescent Beach Overlook", "geometry": { "type": "Point", "coordinates": [-124.1406, 41.7086] },
              "fields": { "slug": "crescent-beach-overlook", "elevation": 75 }, "groups": [],
              "publishedAt": "2026-10-13T09:18:42.256Z" } ],
  "meta": { "page": 1, "limit": 50, "total": 1, "totalPages": 1 } }
```

Tips for scripts:

- Make runs repeatable: give elements and types explicit `apiKey`s and treat a `409` on create as "already exists".
- Match entries on an identifier element with `POST /import` and `matchBy`, so a second run updates instead of duplicating.
- Check `failed` after a publish: those entries stayed out and keep their ready mark.

## Related

- [Import API](https://spatibase.com/docs/reference/import-api/)
- [Authentication](https://spatibase.com/docs/reference/authentication/)
- [Content Delivery API](https://spatibase.com/docs/reference/delivery-api/)
- [Webhook events](https://spatibase.com/docs/reference/webhook-events/)
- [Limits](https://spatibase.com/docs/reference/limits/)
