# Content Delivery API reference

> Look up the read-only Content Delivery API endpoints, query parameters, filters, errors, caching and responses for types, entries, groups and map feature types.

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](https://spatibase.com/docs/reference/delivery-api/#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](https://spatibase.com/docs/reference/delivery-api/#machine-readable-spec) | no |

Asset files live outside this prefix, at `https://api.spatibase.com/assets/<assetId>/<filename>` ([Assets and images](https://spatibase.com/docs/reference/assets-and-images/)). Tiles and TileJSON are described in [GeoJSON and vector tiles](https://spatibase.com/docs/reference/geojson-and-tiles/).

One more endpoint sits outside the project prefix: `https://api.spatibase.com/delivery/v1/openapi.json`, the generic [machine-readable spec](https://spatibase.com/docs/reference/delivery-api/#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](https://spatibase.com/docs/reference/authentication/#content-delivery-api-keys).

## 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](https://spatibase.com/docs/build/start-from-your-code/#temporary-projects) (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](https://spatibase.com/docs/reference/delivery-api/#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](https://spatibase.com/docs/build/start-from-your-code/#temporary-projects) 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](https://spatibase.com/docs/reference/delivery-api/#get-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](https://spatibase.com/docs/publish/versions/), 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](https://spatibase.com/docs/reference/delivery-api/#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](https://spatibase.com/docs/reference/authentication/#how-are-private-responses-cached)).

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](https://spatibase.com/docs/build/recipes/#cache-with-etag-and-304).

## 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`](https://spatibase.com/docs/reference/delivery-api/#list-types), [`/groups`](https://spatibase.com/docs/reference/delivery-api/#list-groups) and [`/feature-types`](https://spatibase.com/docs/reference/delivery-api/#list-map-feature-types).

A project with [languages](https://spatibase.com/docs/reference/delivery-api/#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](https://spatibase.com/docs/reference/delivery-api/#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](https://spatibase.com/docs/reference/delivery-api/#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](https://spatibase.com/docs/reference/delivery-api/#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](https://spatibase.com/tools/bounding-box/) 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](https://spatibase.com/tools/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](https://spatibase.com/docs/reference/delivery-api/#which-zone-is-this-point-in)) |
| `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](https://spatibase.com/docs/reference/delivery-api/#expand-references)) |
| `locale` | `locale=de`, `locale=*` | the language to return, only in a project with languages ([Languages](https://spatibase.com/docs/reference/delivery-api/#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](https://spatibase.com/docs/reference/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](https://spatibase.com/docs/reference/delivery-api/#languages)) |
| `translations` | only with `locale=*`: every stored translation ([Languages](https://spatibase.com/docs/reference/delivery-api/#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](https://spatibase.com/docs/reference/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](https://spatibase.com/docs/reference/delivery-api/#list-entries), 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](https://spatibase.com/docs/reference/geojson-and-tiles/#geojson).

## 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](https://spatibase.com/docs/reference/delivery-api/#list-entries), 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](https://spatibase.com/docs/reference/delivery-api/#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](https://spatibase.com/docs/reference/delivery-api/#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](https://spatibase.com/docs/reference/delivery-api/#vector-tiles-and-tilejson) and `tiles.json` stay in the default language. To show translated titles on a map, load [GeoJSON](https://spatibase.com/docs/reference/delivery-api/#entries-as-geojson) or the [compact index](https://spatibase.com/docs/reference/delivery-api/#entries-as-a-compact-index) with `locale`.

A [recipe](https://spatibase.com/docs/build/recipes/#serve-your-content-in-several-languages) shows a language switcher end to end. To fill the languages in, see [Translate your content](https://spatibase.com/docs/content/translations/).

## 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](https://spatibase.com/docs/reference/geojson-and-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](https://spatibase.com/docs/reference/assets-and-images/) `/assets/{assetId}/{filename}` (no key, with `w`, `h`, `fit`, `fmt` and `q`), and, under the OpenAPI `webhooks` section, the five [webhook messages](https://spatibase.com/docs/reference/webhook-events/): 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](https://openapi-ts.dev) and call the API with [openapi-fetch](https://openapi-ts.dev/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.

## Related

- [Field values](https://spatibase.com/docs/reference/field-values/)
- [GeoJSON and vector tiles](https://spatibase.com/docs/reference/geojson-and-tiles/)
- [Authentication](https://spatibase.com/docs/reference/authentication/)
- [Translate your content](https://spatibase.com/docs/content/translations/)
- [Recipes](https://spatibase.com/docs/build/recipes/)
- [Limits](https://spatibase.com/docs/reference/limits/)
