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:

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

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

bash
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: * and Access-Control-Expose-Headers: ETag, Cache-Control, Retry-After, WWW-Authenticate.
  • OPTIONS (CORS preflight) returns 204 without needing a key, with Access-Control-Allow-Methods: GET, HEAD, OPTIONS, Access-Control-Max-Age: 86400 and the requested headers allowed (Authorization, X-API-Key, If-None-Match when 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: noindex and 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:

json
{"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=bar is fine.
  • page, limit, q, bbox, near, radius, nearest, within, withinEntry, withinField, contains, containsField, subtypes and include must appear at most once; limit=2&limit=3 is a 400.
  • List parameters (type, group, sort and filter[<apiKey>][in]) take a comma-separated list, the parameter repeated, or both: sort=title&sort=-publishedAt equals sort=title,-publishedAt. The values merge into one list (an in filter 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 as filter[rating][gte]=3&filter[rating][lte]=4.
  • An empty type, group, q, sort, page, limit, bbox, near, radius or include counts as absent. An empty subtypes or filter value is a 400.

Caching

Successful responses (200, 204 and 304) of a public project carry an ETag and shared-cache headers:

http
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-Match to get 304 Not Modified with 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 a 401, never a 304.
  • 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-revalidate lets 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=60 instead (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.

bash
curl -s "$API"

This endpoint takes no parameters.

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

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

json
[
  {
    "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.

bash
curl -s "$API/feature-types"

This endpoint takes no parameters.

json
[
  {"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.

bash
curl -s "$API/groups"

This endpoint takes no parameters.

json
[
  {"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.

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

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

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

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

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

json
{
  "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.9 is a range, and filter[amenities]=camping&filter[amenities]=dog-friendly needs both options.
  • The short form cannot be mixed with operators on the same key: filter[amenities]=camping&filter[amenities][ne]=dog-friendly is a 400. Write filter[amenities][eq]=camping&filter[amenities][ne]=dog-friendly.
  • A field only counts for entries whose type has that element. filter[rating][ne]=4.9 also returns entries of types without a rating; add type=park to restrict it.
  • A parent option in a choice tree does not match its children: filter[activities]=hiking does not return entries tagged day-hike. Use filter[activities][in]=hiking,day-hike,backpacking.

An operator a kind does not support is a 400 that lists the supported ones:

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

json
{"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 a 400.

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.

bash
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
json
{
  "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.

bash
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
json
{
  "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}
}
  • bbox is [minLng, minLat, maxLng, maxLat], rounded to 6 decimals. For a point, min equals max.
  • bbox is null for an entry without a main location, and always for types without one.
  • With near or nearest, each item also has distance in meters.
  • include is 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.

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

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

bash
curl -s "$API/entries/822eecd4-c73c-4bc2-8289-6232512259eb?locale=de"
json
{
  "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=de returns 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) and missingTranslations.
  • 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. missingTranslations lists 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. Without locale, the header picks the best language of the project. Quality values count (de-AT, de;q=0.9, en;q=0.5), de-AT matches de, and no match or no header gives the default language. A browser can therefore fetch the right language with no code at all.
  • Content-Language names the language served, on every response that has locale set or chosen.
  • locale=* returns the default language as usual, and adds translations to 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_unknown that lists the valid ones:
json
{"error": {"code": "locale_unknown", "message": "Unknown locale \"xx\". This project's locales: en, de", "locales": ["en", "de"]}}
bash
curl -s "$API/entries?type=park&locale=*&limit=1"
json
{"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.

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

bash
npx openapi-typescript "$API/openapi.json" -o spatibase.d.ts
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:

yaml
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 projectId variable 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 an X-API-Key header.
  • 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 to null.
  • 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.

Updated

Docs support

How can we help?

A person on our team reads every message. We include the page you are on, so you don't have to explain where.

Or write to hello@spatibase.com. For outages, check System status.

Talk to us

Not sure it’s a fit? Let’s find out

Tell us about your use case and what you need, from data model to security and deployment. In 30 minutes we’ll look at it together and tell you honestly if we can help.

Or write to hello@spatibase.com.