API Usage
Access your content programmatically via REST or GraphQL APIs.
API Keys
Creating an API Key
- Go to Project Settings > API Keys
- Click Create API Key
- Enter a descriptive name
- Select permissions:
- Read: Can fetch content (delivery)
- Write: Can create/update content and media (management)
- Delete: Can delete content and media
- 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
https://your-domain.com/api/v1/{projectSlug}Authentication
Include your API key in the request header:
X-API-Key: your_api_key_hereOr use Bearer token:
Authorization: Bearer your_api_key_hereList Entries
GET /api/v1/{project}/{contentType}Example:
curl -H "X-API-Key: your_key" \
https://your-domain.com/api/v1/my-project/blogPostGet Single Entry
GET /api/v1/{project}/{contentType}/{id}Query Parameters
| Parameter | Description | Example |
|---|---|---|
limit | Results per page (default 25, max 100) | ?limit=10 |
page | Page number, 1-based (default 1) | ?page=2 |
sort | Order results; prefix a field with - for descending, comma-separate multiple | ?sort=-createdAt,title |
filters | Filter by field | ?filters[status][$eq]=published |
fields | Select fields | ?fields=title,slug |
populate | Include relations | ?populate=author |
Pagination defaults to page-based (
page+limit), not offset-based. For stable iteration over large or frequently-changing collections, pass acursorinstead (orafter/before) together withlimit; the response'smeta.paginationthen returnsstartCursor/endCursorto follow. Sorting uses a leading-for descending order (e.g.?sort=-createdAt) — thefield:descform 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):
GET /api/v1/{project}/{contentType}?populate=author
GET /api/v1/{project}/{contentType}?populate=author,category,tagsResponse without populate:
Without a populate parameter, no relationships object is emitted — a
relation field appears as a raw ID inside attributes:
{
"id": "entry-123",
"type": "post",
"attributes": {
"title": "My Post",
"author": "author-456"
}
}Response with ?populate=author:
{
"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:
GET /api/v1/{project}/{contentType}?populate=*Advanced Population Syntax
Select specific fields from populated relations:
GET /api/v1/{project}/{contentType}?populate[author][fields]=name,emailPopulate nested relations (e.g., author's company):
GET /api/v1/{project}/{contentType}?populate[author][populate]=companyCombine field selection with nested population:
GET /api/v1/{project}/{contentType}?populate[author][fields]=name&populate[author][populate]=companyDot Notation for Nested Relations
You can also use dot notation for nested population:
GET /api/v1/{project}/{contentType}?populate=author.companyThis 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.
| Operator | Description | Example |
|---|---|---|
$eq | Equals | filters[status][$eq]=published |
$ne | Not equals | filters[status][$ne]=draft |
$gt | Greater than | filters[price][$gt]=100 |
$gte | Greater than or equal | filters[price][$gte]=100 |
$lt | Less than | filters[price][$lt]=50 |
$lte | Less than or equal | filters[price][$lte]=50 |
$contains | Contains text (case-insensitive) | filters[title][$contains]=hello |
$startsWith | Starts with text | filters[slug][$startsWith]=intro |
$in | In a comma-separated list | filters[status][$in]=published,review |
$nin | Not in a comma-separated list | filters[status][$nin]=archived,draft |
$null | Is (true) / is not (false) null | filters[publishedAt][$null]=false |
$near | Within a radius of a coordinate (Location fields) | filters[location][$near]=40.7128,-74.006,5000 |
$fuzzy | Typo-tolerant text match | filters[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.
# 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.
# 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"
$fuzzyis 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.
Content Semantic Search
Add the semantic query parameter to search content by meaning:
GET /api/v1/{project}/{contentType}?semantic=customer+onboardingParameters:
| Parameter | Description | Default |
|---|---|---|
semantic | Natural language search query | - |
threshold | Minimum similarity score (0-1) | 0.3 |
Example:
# 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:
{
"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.4filters out valid results (and one above0.6often returns nothing). Start at the0.3default and lower it (e.g.0.2) to widen recall.
Similar Content
Find entries similar to a given entry:
GET /api/v1/{project}/{contentType}/{id}/similarParameters:
| Parameter | Description | Default |
|---|---|---|
limit | Maximum results (max 50) | 10 |
threshold | Minimum similarity score (0-1) | 0.7 |
sameContentType | Only find similar entries of same type | false |
Example:
# 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:
GET /api/v1/{project}/mediaQuery 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:
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:
GET /api/v1/{project}/media/{assetId}/downloadReturns 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:
| Field | Description | Required |
|---|---|---|
expiresIn | Signed URL lifetime in seconds for private assets (default 3600, min 60, max 86400) | No |
Example:
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:
POST /api/v1/{project}/media
Content-Type: multipart/form-dataForm fields:
| Field | Description | Required |
|---|---|---|
file | The file to upload | Yes |
folderId | Folder ID to place the file in | No |
visibility | PUBLIC (CDN URL) or PRIVATE (signed URL) | No |
alt | Alt text for accessibility | No |
title | Title/caption | No |
Example:
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):
{
"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:
PATCH /api/v1/{project}/media/{assetId}Request body (JSON):
| Field | Description | Required |
|---|---|---|
alt | New alt text (null to clear) | No |
title | New title (null to clear) | No |
filename | New display filename | No |
At least one field must be provided.
Example:
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:
DELETE /api/v1/{project}/media/{assetId}Example:
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.
Media Document Search
Search inside documents (PDFs, text files) by content:
GET /api/v1/{project}/media?semantic=quarterly+revenueParameters:
| Parameter | Description | Default |
|---|---|---|
semantic | Natural language search query | - |
threshold | Minimum similarity score (0-1) | 0.3 |
mimeType | Filter 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.15–0.35, so keep the threshold low.
Response includes matched text excerpts:
{
"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
POST https://your-domain.com/api/v1/{projectSlug}/graphqlSend 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):
| Query | Name | Returns |
|---|---|---|
| Single entry | <apiId> | the entry, or null |
| List entries | <apiId> + s | a Relay-style connection |
The list name is simply the API ID with an s appended — it is not English
pluralization. So post → posts, author → authors, and category →
categorys (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:
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:
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:
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):
query {
posts(sort: { field: publishedAt_DESC }) {
edges {
node {
id
title
publishedat
}
}
}
}Sorting is currently applied only for the built-in
createdAt,updatedAt, andpublishedAtkeys. The enum also lists your custom text/number/date fields, but sorting by them currently falls back tocreatedAt(descending). To sort by an arbitrary field, use the REST API'ssortparameter.
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":
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 theneargeo 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:
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:
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):
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:
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 withpopulate, or the MCPlist_entriestool withexpand.
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:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Validation failed",
"details": [
{
"field": "title",
"message": "Title is required"
}
]
}
}API Playground
Use the interactive API Playground to:
- Explore available endpoints
- Test requests with your API key
- View response schemas
Access via API Playground in the sidebar.