Content Delivery API reference
The Content Delivery API is the read-only HTTP API that delivers a Spatibase project’s published content to your apps, websites and other media. Places and their fields are delivered as JSON (GeoJSON or vector tiles for maps), and images, documents and other media as files. It returns only published entries of enabled types, answers GET, allows any origin, and is cacheable with ETag and 304. This page lists every endpoint, parameter, response shape and error.
Base URL and versioning
Every Content Delivery API URL starts with the project’s base URL:
https://api.spatibase.com/delivery/v1/projects/$PROJECT_ID
$PROJECT_ID is the UUID in the project’s address in the web app; the project’s API page shows the full base URL with a Copy API address button. v1 is the only version, and there is no version header. Examples on this page use $API for the base URL:
export API=https://api.spatibase.com/delivery/v1/projects/$PROJECT_ID
Use curl -g when a URL contains [ and ] (filters), so curl does not treat them as patterns.
Endpoints
Every Content Delivery API endpoint sits under the project’s base URL and answers GET:
Path after /delivery/v1/projects/$PROJECT_ID |
Returns | Paged |
|---|---|---|
| (none) | the project, its types, groups and map feature types | no |
/types |
types with their effective elements | no |
/feature-types |
map feature types | no |
/groups |
groups with published entry counts | no |
/groups/<groupKey> |
one group and its entries in manual order | yes |
/entries |
entries, filterable | yes |
/entries.geojson |
the same entries as a GeoJSON FeatureCollection | yes |
/entries.index |
the same entries as a compact index: id, type, title, bounding box | yes |
/entries/<entryId> |
one entry | no |
/tiles.json, /tiles/{z}/{x}/{y}.pbf |
TileJSON and vector tiles | no |
/openapi.json |
this project’s typed OpenAPI file | no |
Asset files live outside this prefix, at https://api.spatibase.com/assets/<assetId>/<filename> (Assets and images). Tiles and TileJSON are described in GeoJSON and vector tiles.
One more endpoint sits outside the project prefix: https://api.spatibase.com/delivery/v1/openapi.json, the generic machine-readable spec of this API for any project.
Authentication
A public project, the default, needs no credentials: the project id is the only identifier. A private project answers every endpoint only when the request carries a valid API key of that project, sent in one of three ways (the first one present counts):
curl -H "Authorization: Bearer $API_KEY" "$API/entries?limit=1"
curl -H "X-API-Key: $API_KEY" "$API/types"
curl "$API/tiles.json?api_key=$API_KEY"
$API_KEY is a key created on the project’s API page. Prefer a header; ?api_key= exists for map libraries that cannot add headers to tile requests. Public projects accept and ignore keys. Key types, private-project caching and tiles with keys are covered in Authentication.
Methods and CORS
The Content Delivery API is read-only: GET and HEAD work on every endpoint, and any other method except OPTIONS returns 405 with Allow: GET, HEAD, OPTIONS.
- Every response has
Access-Control-Allow-Origin: *andAccess-Control-Expose-Headers: ETag, Cache-Control, Retry-After, WWW-Authenticate. OPTIONS(CORS preflight) returns204without needing a key, withAccess-Control-Allow-Methods: GET, HEAD, OPTIONS,Access-Control-Max-Age: 86400and the requested headers allowed (Authorization, X-API-Key, If-None-Matchwhen none are requested).- A temporary project (made by a coding agent and not yet kept) is public too, but every response carries
X-Robots-Tag: noindexand the project is listed nowhere. After it is kept, the header is gone. - No cookies are used. A signed-in web app session has no effect on Content Delivery API requests.
Errors
Errors are JSON with a stable code and a human-readable message, sent with Cache-Control: no-store:
{"error":{"code":"bad_request","message":"\"limit\" must be a whole number between 1 and 500 (got \"501\")"}}
| Status | code |
When |
|---|---|---|
| 400 | bad_request |
invalid or unknown parameter value: unknown type, group, filter key, operator or sort key, out-of-range number, malformed coordinates |
| 400 | locale_unknown |
locale is not one of the project’s languages (a project without languages has none); the error also lists the valid locales |
| 401 | api_key_required |
private project, no key sent |
| 401 | api_key_invalid |
private project, and the key is wrong, revoked, expired, belongs to another project, or is an edit key sent as ?api_key= |
| 404 | not_found |
unknown or malformed project id, entry not published (or its type switched off), unknown group, unknown path |
| 405 | method_not_allowed |
a method other than GET, HEAD or OPTIONS |
| 410 | project_expired |
a temporary project that nobody kept expired (“This temporary project expired because nobody kept it”). It is sent from the moment of expiry and for 30 days after the project was deleted |
| 429 | rate_limited |
too many failed key checks from one IP address; see the Retry-After header |
| 500 | internal_error |
unexpected server error |
A 401 also carries a WWW-Authenticate: Bearer realm="Spatibase Delivery API" header (with error="invalid_token" for api_key_invalid). Messages name the allowed values, for example Unknown type "nope". Available types: region, park, national-park, state-park, store-location, trip-idea, event. Match on status and code in your code, not on message text.
How query parameters are read
Query parameters follow the same rules on every endpoint:
- Parameters an endpoint does not use are ignored, so
?foo=baris fine. page,limit,q,bbox,near,radius,nearest,within,withinEntry,withinField,contains,containsField,subtypesandincludemust appear at most once;limit=2&limit=3is a400.- List parameters (
type,group,sortandfilter[<apiKey>][in]) take a comma-separated list, the parameter repeated, or both:sort=title&sort=-publishedAtequalssort=title,-publishedAt. The values merge into one list (aninfilter is still one “any of” condition: at most 100 values, each at most 500 characters, whichever form you use). Any other filter value is at most 500 characters. Other repeated filters must all match, such asfilter[rating][gte]=3&filter[rating][lte]=4. - An empty
type,group,q,sort,page,limit,bbox,near,radiusorincludecounts as absent. An emptysubtypesor filter value is a400.
Caching
Successful responses (200, 204 and 304) of a public project carry an ETag and shared-cache headers:
Cache-Control: public, max-age=60, s-maxage=300, stale-while-revalidate=86400
ETag: W/"6806f635-636f-4814-ae20-988d4c84369e-1-DtjfXG8Bf0zHoY4-KR1F"
- The ETag is
W/"<projectId>-<contentVersion>-<hash>". The hash covers the request path and query string exactly as sent, and the content model. Reordering query parameters gives a different ETag for the same content. - Send the ETag back in
If-None-Matchto get304 Not Modifiedwith no body. Weak and strong forms, comma-separated lists and*are accepted. A private project checks the key first, so a conditional request without a valid key is a401, never a304. contentVersion(in the project response) increases on every project publish that ships something (new and changed entries, unpublishes, deletions of published entries and group changes all ship together), when going back to an earlier version, and on every asset change, content model change and switch between public and private access. Saving a draft, marking an entry ready or editing a group changes neither the version nor any output.- With these headers a change reaches browsers within 60 seconds and shared caches such as a CDN within 300 seconds;
stale-while-revalidatelets a cache serve one more stale copy while it refetches. - In a project with languages the language that was served is part of the ETag, and responses carry
Vary: Accept-Language, so a cache keeps one copy per language. - Private projects send
Cache-Control: private, max-age=60instead (Authentication).
To poll for changes cheaply, repeat a request with If-None-Match, or read contentVersion from the project endpoint. A client example is in Recipes.
Get the project
The project endpoint returns the project’s name, description and contentVersion, plus its types, groups and map feature types in one response. Use it to bootstrap an app or to poll for changes.
curl -s "$API"
This endpoint takes no parameters.
{
"id": "6806f635-636f-4814-ae20-988d4c84369e",
"name": "Pacific Coast Outdoors",
"description": "Parks, regions, stores and trip ideas along the Pacific coast of California, Oregon and Washington. Demo content.",
"contentVersion": 12,
"types": [ ... ],
"groups": [ ... ],
"featureTypes": [ ... ]
}
types, groups and featureTypes are exactly the bodies of /types, /groups and /feature-types.
A project with languages also has locales (every language code, the default first) and defaultLocale. Both are left out when the project has no languages. locale works here too: it picks the language of the choice labels in types.
List types
/types returns the enabled types and the elements (fields) each one has, so you can build forms, filters or legends from the content model.
curl -s "$API/types"
This endpoint takes no parameters. Types come in tree order, each parent followed by its subtypes; a type switched off, or under a parent that is switched off, is left out.
[
{
"apiKey": "store-location", "name": "Store Location", "description": "Pacific Coast Outdoors stores.",
"parent": null, "path": ["store-location"], "children": [],
"locationMode": "required", "geometryKinds": ["Point"], "color": "#c95b20", "entryCount": 4,
"elements": [
{"apiKey": "rental-phone", "label": "Rental desk phone", "kind": "phone", "required": false,
"helpText": "Direct line for bike and kayak rentals.", "config": {}, "inheritedFrom": null},
{"apiKey": "region", "label": "Region", "kind": "reference", "required": false,
"helpText": "The stretch of coast this belongs to.", "config": {"targetTypes": ["region"]}, "inheritedFrom": null},
{"apiKey": "opens-at", "label": "Opens at", "kind": "date", "required": false,
"helpText": "Usual opening time.", "config": {"mode": "time"}, "inheritedFrom": null},
...
]
}
]
| Key | Meaning |
|---|---|
apiKey, name, description |
the type’s stable key, display name and description |
parent, children, path |
apiKeys in the type tree; path runs from the root to this type |
locationMode |
required, optional or none; entries of none types never have a geometry |
geometryKinds |
allowed main-location shapes: Point, LineString, Polygon |
color |
the type’s color (Type color in the app), #rrggbb |
entryCount |
how many published entries this type has; entries of its subtypes count for their own type, drafts never count |
elements |
the effective list: inherited elements first (inheritedFrom is the ancestor’s apiKey), then the type’s own. Archived and unassigned elements are not listed |
elements[].config |
the element’s settings. reference shows targetTypes (apiKeys of enabled targets; [] means any type, or that every target is switched off), geo shows featureTypes, date always has a mode |
List map feature types
/feature-types lists the named shape categories used in geo fields, in the order the editor shows them. Use it to style the shapes in a geo field by properties.featureType.
curl -s "$API/feature-types"
This endpoint takes no parameters.
[
{"apiKey": "path", "name": "Path", "geometryKind": "LineString", "icon": "route", "color": "#b77c05"},
{"apiKey": "trailhead", "name": "Trailhead", "geometryKind": "Point", "icon": "flag", "color": "#1aa356"},
{"apiKey": "park-area", "name": "Park area", "geometryKind": "Polygon", "icon": "tree", "color": "#778b1d"},
{"apiKey": "parking", "name": "Parking", "geometryKind": "Point", "icon": "parking", "color": "#3979d4"}
]
icon is one of pin, flag, tree, mountain, tent, water, building, route, star, parking, info, or null.
List groups
/groups lists every group of the project with the number of published entries in it. A group is part of the next publish like an entry: a group is listed once it has been published, and as it was at the last publish, so changes editors made since are not visible yet.
curl -s "$API/groups"
This endpoint takes no parameters.
[
{"apiKey": "featured", "name": "Featured", "description": "Highlighted on the homepage.", "entryCount": 3},
{"apiKey": "coastal-road-trip", "name": "Coastal road trip", "description": "A south-to-north drive up the Pacific coast, in driving order.", "entryCount": 5}
]
entryCount counts published entries of enabled types only.
Get one group
/groups/<groupKey> returns one group and its published entries in the order editors arranged them. Use it for hand-curated lists such as a homepage selection or a route.
curl -s "$API/groups/coastal-road-trip?limit=2"
| Parameter | Values | Default | Meaning |
|---|---|---|---|
page |
1 to 1000000 | 1 | page number |
limit |
1 to 500 | 50 | entries per page |
include |
references |
none | expand reference fields (Expand references) |
Other entry parameters (type, filter, sort and so on) are ignored here. To combine group order with filters, use /entries?group=<groupKey>&sort=group.
{
"data": {"apiKey": "coastal-road-trip", "name": "Coastal road trip", "description": "A south-to-north drive up the Pacific coast, in driving order."},
"entries": [
{"id": "e2a46e0b-d526-48a2-a04a-dd2b61cd2df4", "type": "national-park", "title": "Point Reyes National Seashore", ...},
{"id": "822eecd4-c73c-4bc2-8289-6232512259eb", "type": "national-park", "title": "Redwood National Park", ...}
],
"meta": {"page": 1, "limit": 2, "total": 5, "totalPages": 3},
"links": {
"self": "https://api.spatibase.com/delivery/v1/projects/6806f635-636f-4814-ae20-988d4c84369e/groups/coastal-road-trip?limit=2",
"next": "https://api.spatibase.com/delivery/v1/projects/6806f635-636f-4814-ae20-988d4c84369e/groups/coastal-road-trip?limit=2&page=2",
"prev": null
}
}
The group itself is in data and its entries in entries, each an entry object. An unknown key is a 404: Group "nope" not found. Available groups: featured, coastal-road-trip.
List entries
/entries returns published entries, filtered, sorted and paged. It is the main endpoint for lists, search and store locators.
curl -sg "$API/entries?near=-124.0,41.2&radius=50000&sort=distance&limit=2"
All parameters combine with AND.
| Parameter | Example | Meaning |
|---|---|---|
type |
type=park, type=park,region, type=park&type=region |
entries of these types and their subtypes. An unknown apiKey is a 400 |
subtypes |
type=park&subtypes=false |
false: only the listed types themselves. true (default) or false |
group |
group=featured,coastal-road-trip |
entries in any of these groups. An unknown key is a 400 |
q |
q=state |
title contains this text, case-insensitive; % and _ are literal; at most 500 characters |
filter[<apiKey>] |
filter[amenities]=camping |
field equals the value (Filters) |
filter[<apiKey>][<op>] |
filter[rating][gte]=4.8 |
field compared with an operator |
bbox |
bbox=-125,40,-120,42 |
minLng,minLat,maxLng,maxLat in degrees; the main location intersects the box. Each minimum must be at most its maximum (no wrapping across the 180° meridian). The free Bounding box finder draws one and copies it in this form |
near |
near=-124.0,41.2 |
lng,lat; the main location is within radius of the point. Adds distance to each entry. The free Radius map shows a radius on the map and copies its near and radius |
radius |
radius=50000 |
meters, more than 0 and at most 20037509, default 10000. Only with near |
nearest |
nearest=-124.0,41.2 |
lng,lat; every entry with a main location, closest first, however far away. Adds distance to each entry; page through with page and limit. Not together with near or radius (those keep only what is inside the radius), and sort can only be distance |
contains |
contains=-123.9,41.5 |
lng,lat; the main location is an area (a Polygon or MultiPolygon) that covers the point, its edge included. Points and lines never match. Answers “which zone is this point in?” (example) |
containsField |
contains=-123.9,41.5&containsField=service-area |
only with contains: match the areas drawn in this geo field instead of the main location, e.g. the stores whose delivery area covers an address. A key that isn’t a geo field of any type is a 400 |
withinEntry |
withinEntry=<entry id> |
the main location lies inside or touches the area of that published entry’s main location, e.g. all stores in a region. The entry itself is left out. An entry that isn’t published, or has no area (a point or a line), is a 400 |
withinField |
withinEntry=<id>&withinField=service-area |
only with withinEntry: use the areas drawn in that entry’s geo field instead, e.g. stores inside a delivery zone. A field that isn’t a geo field of the entry’s type, or has no area drawn, is a 400 |
within |
within={"type":"Polygon","coordinates":[…]} |
a URL-encoded GeoJSON Polygon or MultiPolygon in lng,lat degrees, at most 8000 characters; the main location lies inside or touches it. Not together with withinEntry |
sort |
sort=title, sort=-publishedAt, sort=createdAt,title, sort=createdAt&sort=title |
list of title, createdAt, updatedAt, publishedAt, distance (needs near or nearest), group (needs exactly one group); a leading - sorts descending. Default -publishedAt (with nearest: distance); ties are broken by entry id |
page |
page=2 |
1 to 1000000, default 1 |
limit |
limit=100 |
1 to 500, default 50 |
include |
include=references |
expand reference fields into full entries, one level (Expand references) |
locale |
locale=de, locale=* |
the language to return, only in a project with languages (Languages). Without it the Accept-Language header decides |
bbox, near, nearest, contains and the within… filters only consider entries that have a main location and whose type’s locationMode is not none, and they match that main location. geo and location fields are never matched, though withinField can use a geo field as the area to search in and containsField as the area to look for the point in. Only areas count as the area (points and lines in it are ignored). For long boundaries prefer withinEntry: the polygon stays on the server and the URL stays short and cacheable. distance is in meters with one decimal, measured on the spheroid to the nearest point of the geometry, so it is 0 inside a polygon. nearest and sort=distance order by this same value, so the distances only ever grow down the list and from page to page.
Which zone is this point in?
Draw your zones as areas (delivery zones, school districts, sales territories), then ask with the point your app already has, for example from the browser’s location or your own geocoder:
curl -sg "$API/entries?contains=-123.9,41.5&type=region"
Every zone that covers the point comes back, so overlapping zones all match; an empty data means the point is in none of them. When the areas belong to other entries, say each store has a service-area field, contains=-123.9,41.5&containsField=service-area returns the stores that deliver there. To find the closest entries without a distance cap, use nearest=-123.9,41.5&limit=5.
{
"data": [
{"id": "66f36784-bb65-4de3-a745-bfd294cdf017", "type": "region", "title": "Northern California Coast", ..., "distance": 0},
{"id": "822eecd4-c73c-4bc2-8289-6232512259eb", "type": "national-park", "title": "Redwood National Park", ..., "distance": 1515.9}
],
"meta": {"page": 1, "limit": 2, "total": 4, "totalPages": 2},
"links": {
"self": "https://api.spatibase.com/delivery/v1/projects/6806f635-636f-4814-ae20-988d4c84369e/entries?near=-124.0,41.2&radius=50000&sort=distance&limit=2",
"next": "https://api.spatibase.com/delivery/v1/projects/6806f635-636f-4814-ae20-988d4c84369e/entries?near=-124.0,41.2&radius=50000&sort=distance&limit=2&page=2",
"prev": null
}
}
sort=group uses the group’s manual order: group=coastal-road-trip&sort=group returns Point Reyes, Redwood, Cape Perpetua, Ecola and Cape Disappointment in that order, and sort=-group reverses it.
Entry object
Every endpoint that returns entries uses the same entry object:
{
"id": "822eecd4-c73c-4bc2-8289-6232512259eb",
"type": "national-park",
"typePath": ["park", "national-park"],
"title": "Redwood National Park",
"geometry": {"type": "Point", "coordinates": [-124.0046, 41.2132]},
"fields": {"description": "## Home of the tallest trees on Earth\n\n...", "slug": "redwood-national-park", "rating": 4.9, "entrance-fee": 0},
"groups": ["featured", "coastal-road-trip"],
"createdAt": "2026-09-29T09:03:34.409Z",
"updatedAt": "2026-10-10T09:03:34.409Z",
"publishedAt": "2026-10-10T09:03:34.409Z"
}
| Key | Meaning |
|---|---|
id |
the entry’s UUID |
type, typePath |
the entry’s own type apiKey; the type apiKeys from the root down to it |
title |
the published title |
geometry |
the main location as a GeoJSON geometry (Point, LineString, Polygon or a Multi* form, [lng, lat]), or null. Always null for types with locationMode: none |
fields |
one key per effective element of the type, in model order; empty values are null. Shapes per kind are in Field values |
groups |
apiKeys of every group containing the entry, in group order |
createdAt |
when the entry was first created |
updatedAt, publishedAt |
both the time of the last publish; draft edits never change them |
distance |
meters from the near or nearest point; only present with one of them |
locale, missingTranslations |
only in a project with languages, when one language is served: the language of this entry and the fields that fell back to the default language (Languages) |
translations |
only with locale=*: every stored translation (Languages) |
Filters
filter[<apiKey>]=<value> is short for filter[<apiKey>][eq]=<value>. The apiKey must belong to an element of at least one enabled type; otherwise the 400 lists the filterable keys. Values are trimmed and at most 500 characters (for in, each value of the list).
| Operator | Value | Matches |
|---|---|---|
eq |
one value | equal. Multiple choice: has this option. Reference: points to this entry id. Text is case-sensitive |
ne |
one value | not equal; includes entries where the field is empty or whose type lacks the field |
gt, gte, lt, lte |
one value | number, date, date-time or time comparison |
in |
comma list or the parameter repeated, at most 100 items | equal to any item (multiple choice or reference: has any) |
contains |
one value | case-insensitive substring |
exists |
true or false |
the field has a value, or is empty. false also matches entries whose type lacks the field |
Which operators an element accepts depends on its kind:
| Element kind | Operators |
|---|---|
text, markdown, identifier, phone, link |
eq, ne, in, contains, exists |
number, date (single date, date-time or time) |
eq, ne, gt, gte, lt, lte, in, exists |
color, choice |
eq, ne, in, exists |
reference |
eq, ne, in, exists; with multiple references: eq, in, exists |
boolean |
eq, ne, exists |
contact |
contains, exists |
images, files, embed, geo, location, text with the JSON editor, date ranges |
exists |
Each value must fit its kind, or the request is a 400 naming the field: numbers must be numeric; booleans true or false; colors #rrggbb (compared case-insensitively, # encoded as %23); choice values must be options of the element; references must be entry UUIDs; dates YYYY-MM-DD or YYYY-MM-DDTHH:MM[:SS][Z|±HH:MM] (encode + as %2B); times HH:MM or HH:MM:SS (9:30 also works). Per-kind matching details are in Field values.
How filters combine:
- Different filters, and several filters on one key, are ANDed:
filter[rating][gte]=4.7&filter[rating][lt]=4.9is a range, andfilter[amenities]=camping&filter[amenities]=dog-friendlyneeds both options. - The short form cannot be mixed with operators on the same key:
filter[amenities]=camping&filter[amenities][ne]=dog-friendlyis a400. Writefilter[amenities][eq]=camping&filter[amenities][ne]=dog-friendly. - A field only counts for entries whose type has that element.
filter[rating][ne]=4.9also returns entries of types without a rating; addtype=parkto restrict it. - A parent option in a choice tree does not match its children:
filter[activities]=hikingdoes not return entries taggedday-hike. Usefilter[activities][in]=hiking,day-hike,backpacking.
An operator a kind does not support is a 400 that lists the supported ones:
{"error":{"code":"bad_request","message":"Operator \"contains\" is not supported for number element \"rating\". Supported: eq, ne, gt, gte, lt, lte, in, exists"}}
Pagination
Paged responses carry meta and links. meta is {page, limit, total, totalPages}, where total counts all matches. links.self is the request URL; links.next and links.prev are the same URL with page moved to the end and changed, or null on the last or first page. Links are valid URIs: characters a URI can’t hold literally, such as the brackets of filter[…] or the JSON of within=, are percent-encoded (filter%5Brating%5D%5Bgte%5D=4), so a link can be requested as it is.
Past the end, data is empty and prev points to the last page:
{"data":[],"meta":{"page":99,"limit":5,"total":17,"totalPages":4},"links":{"self":"https://api.spatibase.com/delivery/v1/projects/6806f635-636f-4814-ae20-988d4c84369e/entries?limit=5&page=99","next":null,"prev":"https://api.spatibase.com/delivery/v1/projects/6806f635-636f-4814-ae20-988d4c84369e/entries?limit=5&page=4"}}
Pages are counted by offset, so a publish between two page requests can shift entries across pages. If that matters, compare contentVersion (or the version part of the ETag) between requests. links keep the request’s whole query string, including api_key if you sent one.
Expand references
Without include, a reference field is a stub, {id, type, title}. With include=references, each stub becomes the complete target entry object. It works on /entries, /entries.geojson, /entries/<entryId> and /groups/<groupKey>.
- Only one level is expanded: references inside the expanded entry stay stubs.
- Targets that are unpublished, deleted or of a switched-off type stay hidden either way.
- The only accepted value is
references; anything else is a400.
Entries as GeoJSON
/entries.geojson returns the same entries as /entries, as a GeoJSON FeatureCollection that map libraries and GIS tools read directly. Only entries with a main location are included.
curl -sg "$API/entries.geojson?type=store-location&limit=1"
It takes every /entries parameter, with a different paging limit:
| Parameter | Values | Default |
|---|---|---|
limit |
1 to 5000 | 1000 |
all other /entries parameters |
as for /entries |
as for /entries |
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"id": "192a713e-995a-4cb6-a84a-a5da45e9646a",
"geometry": {"type": "Point", "coordinates": [-124.0828, 40.8665]},
"properties": {"id": "192a713e-995a-4cb6-a84a-a5da45e9646a", "type": "store-location", "title": "Pacific Coast Outdoors – Arcata", "rental-phone": "+1 707-555-0135", "opens-at": "09:00", ...}
}
],
"meta": {"page": 1, "limit": 1, "total": 4, "totalPages": 4},
"links": {"self": "...", "next": "...", "prev": null}
}
The response has Content-Type: application/geo+json. Feature shape, what is left out and how to use it in map tools are in GeoJSON and vector tiles.
Entries as a compact index
/entries.index returns the same entries as /entries with only what a list, a search box or a “zoom to” link needs: id, type, title and the bounding box of the main location. Without fields and geometries, all entries of a large project arrive in a few small requests.
curl -s "$API/entries.index?sort=title&limit=5000"
It takes every /entries parameter, with the same paging limit as GeoJSON:
| Parameter | Values | Default |
|---|---|---|
limit |
1 to 5000 | 1000 |
all other /entries parameters |
as for /entries |
as for /entries |
{
"data": [
{"id": "192a713e-995a-4cb6-a84a-a5da45e9646a", "type": "store-location", "title": "Pacific Coast Outdoors – Arcata", "bbox": [-124.0828, 40.8665, -124.0828, 40.8665]}
],
"meta": {"page": 1, "limit": 1000, "total": 4, "totalPages": 1},
"links": {"self": "...", "next": null, "prev": null}
}
bboxis[minLng, minLat, maxLng, maxLat], rounded to 6 decimals. For a point, min equals max.bboxisnullfor an entry without a main location, and always for types without one.- With
nearornearest, each item also hasdistancein meters. includeis accepted but has no effect here.- For the fields, get one entry.
Get one entry
/entries/<entryId> returns one published entry by id, for detail pages and for loading full content when someone clicks a map feature.
curl -s "$API/entries/b8c20554-a09d-41e4-8da9-0d19b6cb8434"
| Parameter | Values | Default | Meaning |
|---|---|---|---|
include |
references |
none | expand reference fields (Expand references) |
Other parameters are ignored. The entry is wrapped in data:
{
"data": {
"id": "b8c20554-a09d-41e4-8da9-0d19b6cb8434",
"type": "trip-idea",
"typePath": ["trip-idea"],
"title": "A weekend among the redwoods",
"geometry": null,
"fields": {
"description": "## Two days on the Redwood Coast\n\n...",
"images": [{"id": "6bc1720d-d1ce-45db-8fd5-20c36a60e1fb", "url": "https://api.spatibase.com/assets/6bc1720d-d1ce-45db-8fd5-20c36a60e1fb/a-weekend-among-the-redwoods-19.jpg", ...}],
"slug": "a-weekend-among-the-redwoods",
"featured-park": {"id": "822eecd4-c73c-4bc2-8289-6232512259eb", "type": "national-park", "title": "Redwood National Park"},
"parks": [{"id": "822eecd4-c73c-4bc2-8289-6232512259eb", "type": "national-park", "title": "Redwood National Park"}],
"season": {"start": "2027-05-01", "end": "2027-10-15"},
"starting-point": {"lng": -124.0912, "lat": 41.2843, "address": "Kuchel Visitor Center, Orick, CA 95555"}
},
"groups": ["featured"],
"createdAt": "2026-09-29T09:03:34.409Z",
"updatedAt": "2026-10-10T09:03:34.409Z",
"publishedAt": "2026-10-10T09:03:34.409Z"
}
}
An entry with unpublished edits returns its last published version. A draft-only entry, an unpublished entry, an entry of a switched-off type and a malformed id all give the same 404: Entry "<id>" not found (or not published) in this project.
Languages
A project can offer its entries in more than one language (Content model → Languages in the app). Such a project has a default language, and the entry’s title and text fields hold it. A project without languages never shows any of what follows: no extra keys, no extra headers, and locale is an error.
Only the entry title, text fields (except JSON text), markdown fields and the labels of choice options have a translation per language. Everything else, such as numbers, dates, links and locations, is the same in all languages.
curl -s "$API/entries/822eecd4-c73c-4bc2-8289-6232512259eb?locale=de"
{
"id": "822eecd4-c73c-4bc2-8289-6232512259eb",
"type": "national-park",
"title": "Redwood-Nationalpark",
"locale": "de",
"missingTranslations": ["description"],
"fields": {"description": "## Home of the tallest trees on Earth\n\n...", "rating": 4.9},
...
}
Choosing the language
locale=dereturns the title, text fields and choice labels in German. The shape is exactly the same as without languages, plus two keys on every entry:locale(the language you got) andmissingTranslations.- Fallback, field by field. A field that has no German text comes back in the default language, so a half-translated entry never shows holes.
missingTranslationslists them by name:"title"or the field’s apiKey. It is[]when everything is translated, and always[]for the default language. Show a “not translated yet” hint, or hide the entry, when it is not empty. Accept-Language. Withoutlocale, the header picks the best language of the project. Quality values count (de-AT, de;q=0.9, en;q=0.5),de-ATmatchesde, and no match or no header gives the default language. A browser can therefore fetch the right language with no code at all.Content-Languagenames the language served, on every response that haslocaleset or chosen.locale=*returns the default language as usual, and addstranslationsto every entry with all stored translations, without any fallback. Use it to load every language once and switch on the client.- An unknown language (codes are matched without regard to case) is a
400 locale_unknownthat lists the valid ones:
{"error": {"code": "locale_unknown", "message": "Unknown locale \"xx\". This project's locales: en, de", "locales": ["en", "de"]}}
curl -s "$API/entries?type=park&locale=*&limit=1"
{"data": [{"title": "Redwood National Park", "fields": {...},
"translations": {"de": {"title": "Redwood-Nationalpark", "values": {"description": "## Heimat der höchsten Bäume..."}}}}], ...}
Where locale works
locale works on /entries, /entries.geojson, /entries.index, /entries/<entryId>, /groups/<groupKey>, /types and the project endpoint. In GeoJSON, locale and missingTranslations sit in each feature’s properties. In /types an option’s label is the translated label, and locale=* adds labels with every language.
q finds a word in the default title and in the title of the language you asked for (with locale=*, in every language). sort=title sorts by the title of the language you asked for.
Caching and tiles
The language is part of the ETag, and projects with languages answer with Vary: Accept-Language, so one URL can have a cached copy per language and 304 works for each. If you serve a language by URL, always send locale explicitly; it keeps CDN caches simple.
Vector tiles and tiles.json stay in the default language. To show translated titles on a map, load GeoJSON or the compact index with locale.
A recipe shows a language switcher end to end. To fill the languages in, see Translate your content.
Vector tiles and TileJSON
/tiles.json describes the project’s vector tiles as a TileJSON 3.0 document, and /tiles/{z}/{x}/{y}.pbf serves the tiles themselves (Mapbox Vector Tile, zoom 0 to 22, one layer named entries). Use them for large projects and GIS tools.
curl -s "$API/tiles.json"
Tiles accept only type, subtypes and group, and always carry the default language. Layer properties, TileJSON fields, empty tiles (204) and client setup are in GeoJSON and vector tiles.
Machine-readable spec
The Content Delivery API is described by two OpenAPI 3.1 files. Both list every endpoint on this page with its parameters, response shapes and errors, allow any origin and answer ETag and 304.
| File | URL | API key |
|---|---|---|
| Typed, for your project | $API/openapi.json |
like every project endpoint: needed for a private project |
| Generic, for any project | https://api.spatibase.com/delivery/v1/openapi.json |
never, also for private projects (cached for 5 minutes) |
The typed file describes your own content model: one Entry<Type> schema per enabled type (national-park becomes EntryNationalPark), with every field as a key whose value is the field’s shape or null when empty, choices as an enum of their current options, and Entry as a oneOf with a discriminator on type. type, group and sort list your own apiKeys, and every filter is a named parameter such as filter[difficulty] or filter[rating][gte]. Both files have one server, https://api.spatibase.com, and full paths such as /delivery/v1/projects/{projectId}/entries and /assets/{assetId}/{filename}. In the typed file, projectId can only be your project’s id and is filled in by default, so importers prefill it and a generated client rejects any other id. info.version is 1.0.0+<contentVersion>. JSON text fields are any JSON value (unknown in TypeScript).
A schema’s name is Entry followed by the type’s apiKey in PascalCase, split at - and _. If that name is already a built-in schema (a type list would give EntryList) or two types give the same name (a-b and a_b), each of them is named Entry_<apiKey> instead (Entry_list, Entry_a-b, Entry_a_b). Names never depend on the order of your types, and with a single type Entry points to its schema, so names stay the same when you add a type. The project’s API page in the web app shows it under Endpoints → Typed OpenAPI file, with a ready-made command under Generate a typed client.
The generic file serves every project, so it can’t know your fields: entry fields are a map from element apiKey to a value (clients see unknown), and filter is a single deepObject parameter. It is under Endpoints → OpenAPI file (any project).
Files and webhooks. Both files also describe the asset file URL /assets/{assetId}/{filename} (no key, with w, h, fit, fmt and q), and, under the OpenAPI webhooks section, the five webhook messages: one schema per event, such as WebhookEntriesPublished, with the signature headers. Generated types include them, so a webhook receiver can be typed too.
Generate a typed client
Generate TypeScript types with openapi-typescript and call the API with openapi-fetch:
npx openapi-typescript "$API/openapi.json" -o spatibase.d.ts
import createClient from 'openapi-fetch'
import type { paths } from './spatibase.d.ts'
const api = createClient<paths>({ baseUrl: 'https://api.spatibase.com' })
const { data } = await api.GET('/delivery/v1/projects/{projectId}/entries', {
params: {
path: { projectId: '<project id>' },
query: { type: ['national-park'], 'filter[difficulty]': 'easy', sort: ['title'] },
},
})
for (const entry of data?.data ?? []) {
if (entry.type === 'national-park') console.log(entry.fields.difficulty) // 'easy' | 'hard' | null
}
List parameters are arrays sent as repeated parameters (sort=title&sort=-publishedAt), which is what openapi-fetch sends by default, so it needs no querySerializer setting.
Private projects. openapi-typescript can’t send headers, so add a read key to the URL: npx openapi-typescript "$API/openapi.json?api_key=$API_KEY" -o spatibase.d.ts. Edit keys don’t work in URLs. To keep the key out of the URL, put it in a redocly.yaml in the folder where you run the command and set SPATIBASE_KEY in the environment:
resolve:
http:
headers:
- matches: https://api.spatibase.com/**
name: X-API-Key
envVariable: SPATIBASE_KEY
At run time, pass the key to the client: createClient<paths>({ baseUrl: 'https://api.spatibase.com', headers: { 'X-API-Key': key } }).
Regenerate after every model change. The typed file follows the current content model, so adding or removing a type or field, changing a field’s settings, editing choice options or changing groups changes it. Responses always follow the current model, so an old client can meet a field or option it doesn’t know. Re-run the command, for example before each build.
Other tools
- Postman, Insomnia, Bruno: import either URL (in Postman: Import, then paste the link). The typed file fills in your project’s id; with the generic file, set the
projectIdvariable to your project’s UUID. Bruno brings in the key as a collection-level Bearer Token: for a private project set your read key there once, for a public one leave it empty. In other tools add the key as Bearer auth or as anX-API-Keyheader. - Other generators: openapi-generator, orval, Kiota and similar tools read the same URLs. AI coding assistants can read them to learn the API.
The response schemas, the typed ones included, are checked against the server’s real responses and webhook messages by Spatibase’s test suite. The query parameter descriptions are written by hand, and neither file types the fields inside vector tiles. There is no SDK.
What the Content Delivery API never returns
The Content Delivery API leaves out everything that is not published content:
- Drafts, and the draft side of published entries.
- Entries of switched-off types, including types switched off through a parent. Those types disappear from
/types, their elements stop being filterable, and references to their entries resolve tonull. - Archived or unassigned elements. Their stored values are kept and come back if the element is assigned again.
- Internal ids of types, elements, groups and map feature types. Use apiKeys.
Related
Updated