curl 'https://cms.example.com/api/v1/content/article?pageSize=10&sort=-publishedAt' \
-H 'Authorization: Bearer apograph_…' const response = await fetch(
'https://cms.example.com/api/v1/content/article?pageSize=10&sort=-publishedAt',
{ headers: { Authorization: 'Bearer ' + process.env.APOGRAPH_TOKEN } }
);
const { items, total } = await response.json(); query Latest {
articles(pageSize: 10, sort: "-publishedAt") {
total
items {
id
title
slug
publishedAt
}
}
} Listing
GET /api/v1/content/:typeName
| Parameter | Default | Effect |
|---|---|---|
page | 1 | 1-based page number. |
pageSize | 25 | Rows per page, capped at 100. |
sort | -updatedAt | A whitelisted column, - prefixed for descending. |
search | — | Free-text across the type’s text-like columns. |
filter | — | A structured filter tree as JSON. See filtering. |
fields | all | Comma-separated field names to return in values. |
locale | default | Which locale to read, on a localized type. |
status | published | draft and any require a full token. |
The response is a page envelope:
{ "items": [ … ], "page": 1, "pageSize": 25, "total": 128 }Sorting
The whitelist is the envelope timestamps, publishedAt, locale, and the
type’s own scalar fields. An unknown key falls back to -updatedAt rather than
erroring, and id is always the final tiebreaker — so paging is stable even
when the sort column has ties.
?sort=title ascending
?sort=-publishedAt descendingFree-text search
?search= is a case-insensitive match across the type’s text-like columns —
text, richtext and select. LIKE metacharacters are matched literally, so a
search for 100% finds 100% rather than everything.
Search is ILIKE, not a search engine
There is no index behind ?search=, no relevance ranking, no stemming, no typo
tolerance and no highlighting. It is a substring match, and on a large table
with large rich-text bodies it is a sequential scan. If you need real search,
put one in front of Apograph and feed it from the API.
Sparse fieldsets
?fields=title,slug narrows what comes back in values. It narrows the SQL
projection too, so an unselected rich-text column is never read from the
database, let alone serialised — which is the difference between a listing page
that is fast and one that is not.
?fields=title,slug,publishedAtThe envelope is always returned and is not selectable. An unknown name is a 400, and so is naming a relation or media field — this parameter cannot return those, and silently ignoring the request would be worse.
Reading one entry
GET /api/v1/content/:typeName/:id
Takes the same expansion, fields and locale parameters as the list. A
missing entry, an entry in another workspace and an unpublished entry are all
404 to a read token.
Localized reads
On a localized type, ?locale= chooses the language, and the default locale is
used when it is absent. An unknown slug is a 400 — never a silent read of the
default.
?locale=deScoping is strict: a translation group with no row in the requested language is simply absent from the results.
Addressing by translation group
Every id-addressed route has a group-addressed twin:
GET /api/v1/content/article/group/:localeGroupId?locale=deThis is what a front end usually wants. A story has one stable group id across
every language, so a URL like /de/article/<group> resolves without keeping a
per-locale id map.
| Route | Returns |
|---|---|
GET …/group/:groupId | That group’s entry for the requested locale |
GET …/group/:groupId/relations/:field | One relation field’s links, by group |
GET …/group/:groupId/media | That entry’s media assets, by group |
GET …/group/:groupId/translations | The group’s other locale rows |
Sibling translations
?translations=preview attaches an entry’s sibling translations — the rest of
its localeGroupId group — as translations. It is a 400 on a type that is not
localized.
That is how you build a language switcher: one read gives you the entry and every language it exists in.
Discovering the schema
| Route | Returns |
|---|---|
GET /api/v1/content-types | Every type this token can read |
GET /api/v1/content-types/:name | One type’s field schema |
Both are pruned to the workspace’s granted types, so this is also the honest answer to “what can this token see”. A type the workspace was not granted is not listed and is not reachable by name.
Caching
Reads are ordinary GET requests and cache like any other. Apograph sends no
Cache-Control of its own, so a CDN in front of this API caches on whatever
max-age you give it. For an event rather than a timer — re-render a page the
moment it is published — subscribe a webhook: see reacting to
changes, and preview and
drafts for what the API does and does not cache.
What to read next
- Filtering — the structured
?filter=tree, every operator, and relation hops. - Expanding relations and media — turning a shallow read into one that carries its author and cover image.
- Preview and drafts — reading unpublished rows with
a
fulltoken, without shipping it to the browser.