Content Management API
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 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, 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, which allows only what their key or connection allows.
Sign in from a script
-
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) answers400withcode: "captcha_failed". -
The email contains a link like
https://app.spatibase.com/auth/verify?token=…. Copy thetokenvalue without opening the link: opening it in a browser uses the token up. -
Exchange the token for a session cookie:
bashcurl -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} -
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.sidcookie 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,editorordeveloper, returned asroleon every project) decides what you may do. A request your role does not allow answers403with a plainmessage. See Members and invitations. - Requests from scripts and servers (no
Originheader) work. Browser pages on other sites cannot call the Content Management API: CORS allows only the web app, and writes with anotherOrigin(or a cross-site browser request without one) answer403.
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"} |
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).
statusis derived:draft(never published),published(no edits since) orchanged(published, with newer draft edits).- An entry also has two facts beside
status:ready(truewhen someone marked this exact draft ready for the next publish; any real change to the draft clears it) andpendingAction(unpublishordeletewhile a live entry waits to leave the API at the next publish, otherwisenull). An entry never has both. Publishing does not touch entries that are not ready. - A draft save checks value shapes,
maxCountandmaxSelected, geometry and identifier uniqueness. Marking ready (and so publishing) also checksrequired,minCountandminSelected, a non-empty title, a main location when the type’slocationModeisrequired, 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
contentVersioninteger. 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. SwitchingdeliveryAccessincreases it. Changing thebasemapdoes not. The numbers of 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 |
| 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 |
{ "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" }
deliveryAccessis"public"(default: the Content Delivery API needs no key) or"key"(every Delivery request needs an API key). Any other value is a422. Only owners change it.templateis akeyfrom/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 a422 errors.template. Each template’spreviewis[{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. AI assistants can’t use these routes; inside their own project they can list datasets and import one as drafts (/api/projects/:pid/datasets…, MCPimport_dataset). A dataset release can be scheduled: until then it is not listed and can’t be copied or imported. counts.draftandcounts.changedare the entries that differ from the published copy;counts.readyof them are marked ready,counts.notReadyare not,counts.pendingRemovalsare live entries waiting to be unpublished or removed andcounts.groupChangesgroups with changes; together they say what a publish would ship and what it would leave out.counts.latestVersionis the newest version number ornull.counts.apiKeyscounts active read keys.bboxis[west, south, east, north]around the entries’ locations (nullwithout any), andlastActivityAtis the newest Activity row.mcpUrlanddeliveryUrlcome only with a single project (create, get, patch), not in the list.thumbnailViewis the map area the project card shows,[west, south, east, north], ornullfor automatic. Owners and editors can save it, like the basemap.localesis the list of the project’s languages as codes such asen,deorpt-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 bumpscontentVersion. Bad codes, duplicates, more than 20 and a changed default language answer422 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 returnsstoredLocales: 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. 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, orcustom. The older keys still work and are saved as the new ones:libertyandbrightbecomestreets,positronbecomeslight. Responses always use the new keys.labels:falsehides the map’s labels (absent means shown).blueprint:truedraws Light and Dark in their blueprint look (absent means off); it is kept with every style but only used withlightanddark.custom:{name, url}or{name, json}.nameis 1 to 60 characters.urlis anhttp(s)address of a MapLibre style JSON (up to 2000 characters, no user name or password).jsonis a pasted style object ("version": 8, asourcesobject, alayersarray), up to 1 MB. The browser loads the style; Spatibase never fetches it.- PATCH is partial.
{"basemap":{"style":"terrain"}}keeps a storedcustom. Sendingcustomreplaces it as a whole.{"basemap":{"custom":null}}removes it, and the map goes back toautoif the custom one was chosen. - The project list leaves out
custom.json(it can be 1 MB).GET /api/projects/:projectIdincludes it. - Every basemap error is a
422with one message undererrors.basemap.
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 |
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"}}'
{ "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:
{"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"}]}]}}
{"label":"Open season","kind":"date","config":{"mode":"date","range":true,"minDuration":1440}}
{"label":"Regions","kind":"reference","config":{"targetTypeIds":["c9f0346b-3962-40ab-a8c2-d24d6e1597c3"],"multiple":true,"maxCount":3}}
{"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}: theeditorsetting is gone. Always send the complete config. kindcannot change (409). Create a new element instead.- Renaming
apiKeyrewrites 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
apiKeyanswers409. A reserved one (title) answers422 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).
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.
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,geometryKindsorcolorcopies 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 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 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), 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.
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
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"]}}'
{ "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) 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):
{ "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, an entry also has translations, the texts in the non-default languages (the entry’s title and values hold the default language):
{ "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:
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":"..."}}}}'
nullor an empty text removes one translation;"de": nullremoves 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.translationsholds the first problem anderrors["translations.de.description"]names the field. - Translations are part of the draft: the entry becomes
changeduntil 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=dereturns only entries whose title, or a non-empty translatable default value, has no German text yet, and each summary hasmissingTranslations: {"de": 2}: how many texts are missing per non-default language (0= complete).include=valuesaddstranslationsto each summary. - Choice options carry their own labels per language in the element’s
config.options[].labels, for example{"de": "Leicht"};labelstays the default. - Imports and exports handle languages too (Import API). Export writes
title:deand<apiKey>:deproperties.
Update with PATCH
titleorgeometryleft out: unchanged.geometry: nullclears the location.valuesleft out ornull: 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": truechanges only the keys you send;nullclears 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.
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:
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:
{ "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:
{ "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. 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.
Geometry rules
The main location (geometry) and shapes in geo values follow the same rules:
- GeoJSON
Point,LineString,Polygonand theirMulti*forms. AFeatureis unwrapped. AGeometryCollectionis 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.”). ForlocationMode: nonethe 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; 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 |
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.
| 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 and its URL answers 404 |
curl -s -b jar.txt -X POST "https://api.spatibase.com/api/projects/$PROJECT_ID/assets" -F "files=@trailhead.jpg"
[ { "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 } ]
urlis relative tohttps://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 answers413. - Deleting an asset removes its id from the
imagesandfilesvalues 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. 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.
{ "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.
curl -s -b jar.txt "https://api.spatibase.com/api/projects/$PROJECT_ID/audit?entityType=entry&limit=1"
{ "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.identifiedisfalseonly 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. summaryis a ready-to-show sentence.changeslists changed fields:fromandtowhen the value can be shown, otherwisechange: "set" | "changed" | "removed". Ignore keys that start with_.actor.kindisuseroragent. An AI assistant on an edit key appears as"AI assistant (<key name>)"with itskeyIdandclient; one connected by a person appears as “via ”. - Values of
contact,phoneandlocationfields 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} |
[ { "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.
notRestoredlists 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
trashIdanswers404 {"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. 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 |
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"}'
{ "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" }
scopeisread(default, secretsb_live_…) oredit(secretsb_edit_…, for AI assistants through the 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) andcanDelete(delete entries and files). Read keys cannot have them (422). - Creating or rotating an edit key, and switching
canPublishorcanDeleteon, 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. nameis 1 to 100 characters.expiresAtis an ISO date-time with a time zone, in the future, within 10 years, ornullfor never. Up to 50 active keys per project (409beyond).statusisactive,expiredorrevoked.lastUsedAtis 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.
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.
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.
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.
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"
{ "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
apiKeys and treat a409on create as “already exists”. - Match entries on an identifier element with
POST /importandmatchBy, so a second run updates instead of duplicating. - Check
failedafter a publish: those entries stayed out and keep their ready mark.
Related
Updated