Skip to main content
{headless}
Dashboard
API ReferenceAPI Usage9 min read

API Usage

Access your content programmatically via REST or GraphQL APIs.

API Keys

Creating an API Key

  1. Go to Project Settings > API Keys
  2. Click Create API Key
  3. Enter a descriptive name
  4. Select permissions:
    • Read: Can fetch content (delivery)
    • Write: Can create/update content and media (management)
    • Delete: Can delete content and media
  5. Copy and securely store the key

API Key Security

  • Store API keys in environment variables
  • Never expose keys in client-side code
  • Rotate keys periodically
  • Revoke unused keys

REST API

Base URL

Text
https://your-domain.com/api/v1/{projectSlug}

Authentication

Include your API key in the request header:

Text
X-API-Key: your_api_key_here

Or use Bearer token:

Text
Authorization: Bearer your_api_key_here

List Entries

HTTP
GET /api/v1/{project}/{contentType}

Example:

Bash
curl -H "X-API-Key: your_key" \
  https://your-domain.com/api/v1/my-project/blogPost

Get Single Entry

HTTP
GET /api/v1/{project}/{contentType}/{id}

Query Parameters

ParameterDescriptionExample
limitResults per page (default 25, max 100)?limit=10
pagePage number, 1-based (default 1)?page=2
sortOrder results; prefix a field with - for descending, comma-separate multiple?sort=-createdAt,title
filtersFilter by field?filters[status][$eq]=published
fieldsSelect fields?fields=title,slug
populateInclude relations?populate=author

Pagination defaults to page-based (page + limit), not offset-based. For stable iteration over large or frequently-changing collections, pass a cursor instead (or after / before) together with limit; the response's meta.pagination then returns startCursor / endCursor to follow. Sorting uses a leading - for descending order (e.g. ?sort=-createdAt) — the field:desc form is not supported.

Populating Relations

Use the populate parameter to expand relationship fields with full entry data instead of just IDs.

Simple Population

Include one or more relations (comma-separated):

HTTP
GET /api/v1/{project}/{contentType}?populate=author
GET /api/v1/{project}/{contentType}?populate=author,category,tags

Response without populate:

Without a populate parameter, no relationships object is emitted — a relation field appears as a raw ID inside attributes:

JSON
{
  "id": "entry-123",
  "type": "post",
  "attributes": {
    "title": "My Post",
    "author": "author-456"
  }
}

Response with ?populate=author:

JSON
{
  "id": "entry-123",
  "attributes": {
    "title": "My Post"
  },
  "relationships": {
    "author": {
      "data": {
        "id": "author-456",
        "type": "author",
        "attributes": {
          "name": "Jane Doe",
          "email": "jane@example.com",
          "bio": "Writer and developer"
        }
      }
    }
  }
}

Populate All Relations

Use * to populate all relationship fields:

HTTP
GET /api/v1/{project}/{contentType}?populate=*

Advanced Population Syntax

Select specific fields from populated relations:

HTTP
GET /api/v1/{project}/{contentType}?populate[author][fields]=name,email

Populate nested relations (e.g., author's company):

HTTP
GET /api/v1/{project}/{contentType}?populate[author][populate]=company

Combine field selection with nested population:

HTTP
GET /api/v1/{project}/{contentType}?populate[author][fields]=name&populate[author][populate]=company

Dot Notation for Nested Relations

You can also use dot notation for nested population:

HTTP
GET /api/v1/{project}/{contentType}?populate=author.company

This populates the author relation and its nested company relation.

Filter Operators

Filters use the filters[field][$operator]=value syntax (note the plural filters). Combine multiple filters by repeating the parameter — they are AND-ed together. Dotted paths (e.g. filters[author.name][$eq]=Ada) reach into nested JSON values.

OperatorDescriptionExample
$eqEqualsfilters[status][$eq]=published
$neNot equalsfilters[status][$ne]=draft
$gtGreater thanfilters[price][$gt]=100
$gteGreater than or equalfilters[price][$gte]=100
$ltLess thanfilters[price][$lt]=50
$lteLess than or equalfilters[price][$lte]=50
$containsContains text (case-insensitive)filters[title][$contains]=hello
$startsWithStarts with textfilters[slug][$startsWith]=intro
$inIn a comma-separated listfilters[status][$in]=published,review
$ninNot in a comma-separated listfilters[status][$nin]=archived,draft
$nullIs (true) / is not (false) nullfilters[publishedAt][$null]=false
$nearWithin a radius of a coordinate (Location fields)filters[location][$near]=40.7128,-74.006,5000
$fuzzyTypo-tolerant text matchfilters[title][$fuzzy]=onbaording

Geospatial & fuzzy filters

Two operators go beyond exact matching. Both return results ordered by relevance (nearest first / most-similar first) and compose with the other filters above.

$near — radius search on Location fields. Pass filters[<field>][$near]=<lat>,<lng>,<radiusMeters>. Only entries whose Location lies within radiusMeters of the point are returned, closest first.

Bash
# Stores within 5km of downtown, nearest first
curl -H "X-API-Key: your_key" \
  "https://your-domain.com/api/v1/my-project/store?filters[location][\$near]=40.7128,-74.006,5000"

$fuzzy — typo-tolerant, accent-insensitive text search. Pass filters[<field>][$fuzzy]=<term>. Uses trigram similarity, so misspellings and partial words still match; results are ordered by similarity.

Bash
# Matches "Central Park Cafe" despite the typo
curl -H "X-API-Key: your_key" \
  "https://your-domain.com/api/v1/my-project/place?filters[title][\$fuzzy]=Central%20Prak%20Cafe"

$fuzzy is lexical (trigram) matching — great for typos and partial terms. For meaning-based search that finds related concepts, use Semantic Search below. They're complementary.

Semantic Search (AI-Powered)

Search your content by meaning using AI embeddings. Unlike keyword search, semantic search understands concepts and finds related content even when exact terms don't match.

Add the semantic query parameter to search content by meaning:

HTTP
GET /api/v1/{project}/{contentType}?semantic=customer+onboarding

Parameters:

ParameterDescriptionDefault
semanticNatural language search query-
thresholdMinimum similarity score (0-1)0.3

Example:

Bash
# Find blog posts about customer onboarding (even if they don't use those exact words)
curl -H "X-API-Key: your_key" \
  "https://your-domain.com/api/v1/my-project/blogPost?semantic=customer+onboarding&threshold=0.2"

Response includes similarity scores:

JSON
{
  "data": [
    {
      "id": "entry-123",
      "attributes": {
        "title": "Getting Users Started with Your Product"
      },
      "_similarity": 0.34
    }
  ]
}

Choosing a threshold. Embedding similarity scores are modest — with the default model, genuinely relevant matches typically score in the 0.3–0.5 range, so a threshold much above 0.4 filters out valid results (and one above 0.6 often returns nothing). Start at the 0.3 default and lower it (e.g. 0.2) to widen recall.

Similar Content

Find entries similar to a given entry:

HTTP
GET /api/v1/{project}/{contentType}/{id}/similar

Parameters:

ParameterDescriptionDefault
limitMaximum results (max 50)10
thresholdMinimum similarity score (0-1)0.7
sameContentTypeOnly find similar entries of same typefalse

Example:

Bash
# Find blog posts similar to a specific post
curl -H "X-API-Key: your_key" \
  "https://your-domain.com/api/v1/my-project/blogPost/entry-123/similar?limit=5"

List Media

List media assets in a project:

HTTP
GET /api/v1/{project}/media

Query parameters: folderId, mimeType (prefix, e.g. image/), search, page, limit, cursor, plus semantic + threshold for AI document search.

Each asset's url is resolved for direct use — a CDN URL for public assets, or a short-lived signed URL for private assets.

Get Media

Get a single media asset by ID:

HTTP
GET /api/v1/{project}/media/{assetId}

Add ?transform=true to include pre-built image transformation URLs. As with the list endpoint, url is a CDN URL (public) or a short-lived signed URL (private).

Download Media

Redirect to a directly downloadable URL for an asset:

HTTP
GET /api/v1/{project}/media/{assetId}/download

Returns a 302 redirect — to the CDN URL for public assets, or to a short-lived signed URL with a Content-Disposition: attachment header for private assets (so it saves with the original filename). Follow redirects to fetch the bytes.

Query parameters:

FieldDescriptionRequired
expiresInSigned URL lifetime in seconds for private assets (default 3600, min 60, max 86400)No

Example:

Bash
curl -L \
  -H "X-API-Key: your_key" \
  -o report.pdf \
  "https://your-domain.com/api/v1/my-project/media/media-789/download"

Upload Media

Upload a media file to a project:

HTTP
POST /api/v1/{project}/media
Content-Type: multipart/form-data

Form fields:

FieldDescriptionRequired
fileThe file to uploadYes
folderIdFolder ID to place the file inNo
visibilityPUBLIC (CDN URL) or PRIVATE (signed URL)No
altAlt text for accessibilityNo
titleTitle/captionNo

Example:

Bash
curl -X POST \
  -H "X-API-Key: your_key" \
  -F "file=@photo.jpg" \
  -F "alt=A sunset over the ocean" \
  -F "visibility=PUBLIC" \
  "https://your-domain.com/api/v1/my-project/media"

Response (201):

JSON
{
  "data": {
    "id": "media-789",
    "filename": "photo.jpg",
    "url": "https://cdn.example.com/photo.jpg",
    "mimeType": "image/jpeg",
    "size": 245760,
    "width": 1920,
    "height": 1080,
    "alt": "A sunset over the ocean",
    "visibility": "PUBLIC",
    "createdAt": "2026-03-21T12:00:00.000Z"
  }
}

Files are automatically deduplicated by content hash. If an identical file already exists, the existing asset is returned.

Update Media

Update a media asset's metadata:

HTTP
PATCH /api/v1/{project}/media/{assetId}

Request body (JSON):

FieldDescriptionRequired
altNew alt text (null to clear)No
titleNew title (null to clear)No
filenameNew display filenameNo

At least one field must be provided.

Example:

Bash
curl -X PATCH \
  -H "X-API-Key: your_key" \
  -H "Content-Type: application/json" \
  -d '{"alt": "Updated alt text", "title": "New title"}' \
  "https://your-domain.com/api/v1/my-project/media/media-789"

Delete Media

Permanently delete a media asset from storage and database:

HTTP
DELETE /api/v1/{project}/media/{assetId}

Example:

Bash
curl -X DELETE \
  -H "X-API-Key: your_key" \
  "https://your-domain.com/api/v1/my-project/media/media-789"

Returns 204 No Content on success.

Search inside documents (PDFs, text files) by content:

HTTP
GET /api/v1/{project}/media?semantic=quarterly+revenue

Parameters:

ParameterDescriptionDefault
semanticNatural language search query-
thresholdMinimum similarity score (0-1)0.3
mimeTypeFilter by MIME type (e.g., application/pdf)-

Document-search scores follow the same modest range as content search — see Choosing a threshold above. Good matches commonly score around 0.150.35, so keep the threshold low.

Response includes matched text excerpts:

JSON
{
  "data": [
    {
      "id": "media-456",
      "filename": "Q3-Report.pdf",
      "mimeType": "application/pdf",
      "_similarity": 0.31,
      "_matchedChunk": {
        "index": 3,
        "text": "Quarterly revenue increased by 15%..."
      }
    }
  ]
}

GraphQL API

The GraphQL API is a read-only content-delivery API. Its schema is generated from your project's content types, so the type names, field names, and queries below mirror whatever you have modelled. The examples in this section use the demo-magazine project (content types post, author, and category).

Endpoint

Text
POST https://your-domain.com/api/v1/{projectSlug}/graphql

Send queries as an HTTP POST with the standard GraphQL JSON body ({ "query": "..." }). Authenticate with the same API key used for REST (X-API-Key or Authorization: Bearer); a read key is sufficient. In non-production deployments the endpoint also serves the built-in GraphiQL explorer in a browser; introspection is disabled in production.

Query names

Each content type generates two queries, named from its API ID (not its display name):

QueryNameReturns
Single entry<apiId>the entry, or null
List entries<apiId> + sa Relay-style connection

The list name is simply the API ID with an s appended — it is not English pluralization. So postposts, authorauthors, and categorycategorys (not "categories"). A type whose API ID already ends in s becomes ...ss. Always use the exact generated name; the MCP get_schema tool or the GraphiQL explorer will show it.

The single-entry query accepts id or slug:

GraphQL
query {
  post(slug: "the-quiet-revolution") {
    id
    title
    slug
    status
  }
}

Listing entries (connections)

List queries return a connection — not a bare array, and not a { data, meta } wrapper. Select entries through edges { node { ... } }, and read pagination metadata from pageInfo / totalCount:

GraphQL
query {
  posts {
    edges {
      node {
        id
        title
        slug
        dek
        featured
        publishedat
      }
      cursor
    }
    pageInfo {
      hasNextPage
      hasPreviousPage
      startCursor
      endCursor
      totalCount
    }
    totalCount
  }
}

The fields on node are your fields' API IDs exactly as stored (e.g. publishedat, heroimageurl), plus the built-in entry fields id, slug, status, locale, createdAt, updatedAt, publishedAt, and firstPublishedAt.

Pagination

Pagination is offset/page based via the pagination argument ({ page, limit }). limit defaults to 25 and is capped at 100; page is 1-based. Cursors are returned on every edge and in pageInfo, but there is no cursor input (no first / after arguments) — paginate by incrementing page:

GraphQL
query {
  posts(pagination: { page: 2, limit: 5 }) {
    edges {
      node {
        id
        title
      }
    }
    pageInfo {
      hasNextPage
      hasPreviousPage
      totalCount
    }
    totalCount
  }
}

Sorting

Sort with sort: { field: <ENUM> }. Each type has a <TypeName>SortField enum; every sortable field is available ascending by its name and descending with a _DESC suffix (e.g. createdAt, createdAt_DESC, publishedAt_DESC):

GraphQL
query {
  posts(sort: { field: publishedAt_DESC }) {
    edges {
      node {
        id
        title
        publishedat
      }
    }
  }
}

Sorting is currently applied only for the built-in createdAt, updatedAt, and publishedAt keys. The enum also lists your custom text/number/date fields, but sorting by them currently falls back to createdAt (descending). To sort by an arbitrary field, use the REST API's sort parameter.

Filtering

Filter with the filter argument. status is the ContentStatus enum, so match it with an unquoted enum value (PUBLISHED, DRAFT, REVIEW, SCHEDULED, ARCHIVED) — not the string "published":

GraphQL
query {
  posts(filter: { status: { eq: PUBLISHED } }) {
    edges {
      node {
        id
        title
      }
    }
    totalCount
  }
}

Operators depend on the field: status supports eq / ne / in; string fields (slug, locale) support eq / ne / contains / startsWith / endsWith / in / notIn; date fields (createdAt, updatedAt, publishedAt) support eq / ne / lt / lte / gt / gte.

GraphQL filtering currently applies only to the built-in entry fields (status, locale, slug, createdAt, updatedAt, publishedAt) plus the near geo filter below. Your custom fields appear in the generated <TypeName>FilterInput, but a filter on them is not yet applied by the resolver — it is silently ignored. For field-level filtering on custom fields (and for $contains, $fuzzy, dotted paths, etc.), use the REST filter operators.

Content types with a Location (GEO_POINT) field expose a near filter for radius search, returning matches nearest-first:

GraphQL
query {
  stores(
    filter: {
      near: {
        field: "location"
        lat: 40.7128
        lng: -74.006
        radiusMeters: 5000
      }
    }
  ) {
    edges {
      node {
        id
        title
        location {
          lat
          lng
        }
      }
    }
  }
}

Reference fields

Reference (relation) fields do not resolve into the full nested entry with its own typed fields. They resolve to an EntryRef:

GraphQL
type EntryRef {
  id: ID!
  contentType: String!
  slug: String
  data: JSON
}

Because EntryRef exposes no typed content fields, a selection like author { name } fails (name is not a field on EntryRef), and you must select at least one EntryRef subfield. The referenced entry's own field values are available untyped through data (a JSON blob):

GraphQL
query {
  posts {
    edges {
      node {
        id
        title
        author {
          id
          slug
          data # -> { "name": "...", "role": "...", "bio": "..." }
        }
        category {
          id
          data
        }
      }
    }
  }
}

To get typed fields for a referenced entry, take its id (or slug) and fetch it with that type's query — in a second request, or the same document via aliases:

GraphQL
query {
  post(slug: "the-quiet-revolution") {
    title
    author {
      id # use this id ...
    }
  }
  # ... to fetch typed author fields
  author(id: "paste-the-author-id-here") {
    name
    role
    bio
  }
}

A reference field always resolves to a single EntryRef (the first related entry), even when the field is configured to allow multiple references. To read every target of a multi-reference field, use the REST API with populate, or the MCP list_entries tool with expand.

Creating and updating content

The GraphQL API is read-only — the generated schema exposes a Query type only, with no mutations. To create, update, publish, or delete content, use the REST API, the dashboard, or the MCP write tools.

Rate Limits

  • Default: 1000 requests per minute
  • Rate limit headers included in responses
  • Contact support for higher limits

Error Responses

Errors follow a consistent format:

JSON
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Validation failed",
    "details": [
      {
        "field": "title",
        "message": "Title is required"
      }
    ]
  }
}

API Playground

Use the interactive API Playground to:

  1. Explore available endpoints
  2. Test requests with your API key
  3. View response schemas

Access via API Playground in the sidebar.