Content
The content API: the reads a site makes and the writes a build or a script makes. These routes take an API token — the query language, a page of a list, one document, the history of one, and the batch of mutations that creates and changes documents.
Run a GROQ query
The whole point of the API: one query language over a dataset, with parameters, at one of three perspectives. The query is a query parameter rather than a body, so it can be pasted into a browser or a curl and read back.
| projectId* | string | What a project is addressed by — proj_ and twelve characters. |
| dataset* | string | A dataset in that project. Lowercase letters, digits, _ and -, up to 64 characters — production is the usual first one. |
| query* | string | The GROQ query. See the subset this service implements, below. |
| params | JSON object | What $name in the query resolves to, as JSON. Parameters rather than interpolation, so that a value can never be read as syntax. |
| perspective | "published" | "previewDrafts" | "raw" | Which of a document’s two rows to read. Defaults to published, which is what a site serves; previewDrafts is the draft when there is one, and raw is “whatever exists”. |
| shape | "schema" | "stored" | Defaults to schema: a document as the type that declares it — an asset field is its CDN address, a reference is the document it names, resolved one level. stored answers the row: an asset reference and a {_ref}. The studio asks for stored, because it edits references rather than the documents they name. |
| language | string | One of the dataset’s languages, as it spells it — fr, pt-BR. Defaults to the dataset’s default language. A language the dataset does not have is a 400 listing the ones it does. Localized fields are answered in it, falling back per field to the default language and naming what fell back in _untranslated. |
curl -X GET 'https://s76dm6ggpc.execute-api.us-east-1.amazonaws.com/v1/data/query/{projectId}/{dataset}?query=*%5B_type+%3D%3D+%22post%22%5D+%7C+order%28publishedAt+desc%29%5B0...10%5D¶ms=%7B%22type%22%3A%22post%22%7D&perspective=published&shape=schema&language=fr' \
-H 'Authorization: Bearer $ACHAR_TOKEN'{
"result": [
{
"_id": "hello-world",
"_type": "post",
"title": "Hello world",
"_rev": "01JQ8Z…",
"_createdAt": "2026-02-14T11:02:00.000Z",
"_updatedAt": "2026-03-01T09:00:00.000Z",
"title": "Hello world",
"coverImage": "https://cdn.example/assets/…/cover.png",
"author": { "_id": "maya", "_type": "author", "name": "Maya" },
"body": [
{
"_type": "block",
"style": "normal",
"markDefs": [],
"_key": "fk4eroz868mq",
"children": [
{ "_type": "span", "marks": [], "text": "this is a body", "_key": "wq7qoisnecmv" }
]
}
]
}
],
"ms": 12,
"perspective": "published",
"documentsRead": 1
}| result | any | What the query asked for. A projection, a count, a single document — whatever the query says. |
| ms | integer | How long the read took, for the one screen that draws it. |
| perspective | "published" | "previewDrafts" | "raw" | The perspective the answer was resolved at — the one asked for, or the default. |
| documentsRead | integer | How many documents the query read, not how many it returned. |
- A projection is answered exactly as it was written:
{title, "author": author->name}is those two fields, and shaping does not touch a shape you chose. Whole documents — anything carrying a_type— are the ones shaped. - The GROQ subset is stated in
services/api/src/lib/groq:*, filters,&&,||,!, comparisons,in,match,defined(),count(),order(), slices, projections,->,^,$paramand the pipe operator. Anything outside it is a 400 naming the position, never a silently empty result. - A query that reads a
previewDraftsperspective sees drafts; the default does not. - A whole document is answered as the type that declares it. An
image,videoorfilefield is the full CDN address as a string, and a reference field —author— is the document it names, resolved one level: the author’s own asset fields are addresses and its references stay references.nullmeans the thing pointed at is gone. Ask forshape=storedto get the rows instead, which is what an editor works with. - A field that is portable text is answered in its canonical form: a block’s text is one span per run of marks, not one span per keystroke.
"this is a body"is one span, and astrongphrase inside it is a second — which is the shape the schema describes and the one a person can read.
Try it
5 query parameters
A page of documents of one type
What a studio’s list draws: the documents of one _type, newest first, each with the name and image the schema’s preview resolves for it. It exists beside the query route because a list needs things a query would have to re-derive — preview titles, whether a draft exists beside the published row, and a page token rather than an offset.
| projectId* | string | What a project is addressed by — proj_ and twelve characters. |
| dataset* | string | A dataset in that project. Lowercase letters, digits, _ and -, up to 64 characters — production is the usual first one. |
| type* | string | The _type to list. |
| limit | integer | How many to answer with. The API has its own ceiling and will not go past it. |
| nextToken | string | The page to read next, taken from the previous response. Opaque — pass it back unchanged, and stop when it is absent. |
| search | string | Filters the page by the text the preview draws. |
| order | string | One of the type’s declared orderings, by name. |
| perspective | "published" | "previewDrafts" | "raw" | Which of a document’s two rows to read. Defaults to published, which is what a site serves; previewDrafts is the draft when there is one, and raw is “whatever exists”. |
| language | string | One of the dataset’s languages, as it spells it — fr, pt-BR. Defaults to the dataset’s default language. A language the dataset does not have is a 400 listing the ones it does. Localized fields are answered in it, falling back per field to the default language and naming what fell back in _untranslated. |
curl -X GET 'https://s76dm6ggpc.execute-api.us-east-1.amazonaws.com/v1/data/list/{projectId}/{dataset}?type=post&limit=20&perspective=published&language=fr' \
-H 'Authorization: Bearer $ACHAR_TOKEN'{
"items": [
{
"_id": "hello-world",
"_type": "post",
"_rev": "01JQ8Z…",
"_updatedAt": "2026-03-01T09:00:00.000Z",
"hasDraft": false,
"published": true,
"title": "Hello world"
}
],
"nextToken": null
}searchandorderare page-scoped. Sorting a whole dataset by a field would need an index the table does not have, and the honest answer past one page is aquery.
Try it
7 query parameters
One document
A document by id, at one perspective. published is the default because that is what a site serves; an editor asks for previewDrafts by name.
| projectId* | string | What a project is addressed by — proj_ and twelve characters. |
| dataset* | string | A dataset in that project. Lowercase letters, digits, _ and -, up to 64 characters — production is the usual first one. |
| documentId* | string | A document, by its own id. The drafts.-prefixed id is accepted and means the same document. |
| perspective | "published" | "previewDrafts" | "raw" | Which of a document’s two rows to read. Defaults to published, which is what a site serves; previewDrafts is the draft when there is one, and raw is “whatever exists”. |
| shape | "schema" | "stored" | Defaults to schema: a document as the type that declares it — an asset field is its CDN address, a reference is the document it names, resolved one level. stored answers the row: an asset reference and a {_ref}. The studio asks for stored, because it edits references rather than the documents they name. |
| language | string | One of the dataset’s languages, as it spells it — fr, pt-BR. Defaults to the dataset’s default language. A language the dataset does not have is a 400 listing the ones it does. Localized fields are answered in it, falling back per field to the default language and naming what fell back in _untranslated. |
curl -X GET 'https://s76dm6ggpc.execute-api.us-east-1.amazonaws.com/v1/data/doc/{projectId}/{dataset}/{documentId}?perspective=published&shape=schema&language=fr' \
-H 'Authorization: Bearer $ACHAR_TOKEN'{
"_id": "hello-world",
"_type": "post",
"_rev": "01JQ8Z…",
"_createdAt": "2026-02-14T11:02:00.000Z",
"_updatedAt": "2026-03-01T09:00:00.000Z",
"_draft": false,
"_published": true,
"_editable": false,
"title": "Hello world",
"coverImage": "https://cdn.example/assets/…/cover.png",
"author": { "_id": "maya", "_type": "author", "name": "Maya" }
}| _id | string | The document’s id, unprefixed. |
| _type | string | The content type it is, which is what the schema calls it. |
| _rev | string | The revision this write produced. Two clients compare it to notice a change. |
| _createdAt | string | ISO 8601. When the document first existed. |
| _updatedAt | string | ISO 8601. When this row was last written. |
| _draft | boolean | True when this is the drafts.<id> row. |
| _published | boolean | Whether a published row exists beside it. |
| _editable | boolean | Whether the caller may change it, resolved on read like Project.role. |
| _language | string | The language this answer is in — the one asked for, or the dataset’s default. |
| _untranslated | string[] | The localized fields this document has no value for in _language, by path. They are answered from the default language, and this is how a page knows to mark one or a build step knows to translate it. Absent when there is nothing missing. |
| _translations | Record<string, TranslationRecord> | Where each language’s values came from and who approved them — { "fr": { "source": "ai", "model": "…", "at": "…", "approvedBy": null } }. source is ai when a model wrote those values and human when anything else did, and an approval applies to a particular text: editing a language clears it. Absent on a document no model has translated. |
- A document that exists only as a draft answers 404 at
publishedrather than an empty body, because that is what it is: not published, and not a thing this caller can be told about. - Shaped as the type that declares it, by default: an asset field is its CDN address as a string, and a reference field is the document it names.
shape=storedanswers the row instead — an asset reference and a{_ref}— which is what the studio reads and writes. - A field that is portable text is answered in its canonical form: one span per run of marks.
Try it
3 query parameters
Create, change and publish documents
The write half, and one request is all it takes to add content: a batch of mutations applied in order. The batch is the unit rather than the request, because create followed by publish is how a document arrives live in a single call, and because a create followed by a patch is how a client saves a document it has just made — splitting those into two requests leaves a half-made document behind whenever the second fails.
| projectId* | string | What a project is addressed by — proj_ and twelve characters. |
| dataset* | string | A dataset in that project. Lowercase letters, digits, _ and -, up to 64 characters — production is the usual first one. |
| mutations* | Mutation[] | Each element names exactly one operation: create, createOrReplace, createIfNotExists, patch, delete, publish, unpublish, restore, approve. They are applied in order, so a create and a publish of the same id in one batch is a document that is live by the time the response is written. |
| atomic | boolean | Fail the whole batch rather than applying what can be applied. One transaction. |
curl -X POST 'https://s76dm6ggpc.execute-api.us-east-1.amazonaws.com/v1/data/mutate/{projectId}/{dataset}' \
-H 'Authorization: Bearer $ACHAR_TOKEN' \
-H 'content-type: application/json' \
-d '{"mutations": [{"create": {"_id": "hello","_type": "post","title": "Hello world","body": "First line\n\nSecond paragraph"}},{"publish": {"id": "hello"}}],"atomic": ""}'{
"results": [
{ "documentId": "hello", "operation": "create", "rev": "01JQ8Z…" },
{ "documentId": "hello", "operation": "publish", "rev": "01JQ8ZA…" }
],
"transactionId": "01JQ8ZB…"
}- To add content and make it live, send
createandpublishin the same batch. One request, applied in order — see Add a document in the quickstart above. - A rich-text field takes a plain string, so nothing has to know the shape of Portable Text to write it:
"body": "First line\n\nSecond paragraph"is stored as one paragraph block per line, with blank lines dropped. A field that is already an array of blocks is stored exactly as sent, which is what the studio does. - A mutation never writes the published row directly.
create,createOrReplace,createIfNotExists,patchandrestorewrite the draft;publishis what moves a draft onto the published id, andunpublishtakes it back off. That is why publishing is a step somebody takes rather than a side effect of typing — and why a site sees nothing until it has been taken. - A string is read as blocks on a create, not on a
patch. A patch edits fields of a document that already exists, so it sends the nodes it read back — a client that has the document has them. - A localized field holds one value per language, and a write says which language a plain value is in with
_language—{"patch": {"id": "hello", "set": {"title": "Bonjour"}, "_language": "fr"}}. Omit it and the value is written in the dataset’s default language. A value that is already an object is taken as the map itself, which is how one element writes two languages; a patch’sunsetpaths are taken as written, sotitleremoves the field andtitle.frremoves one language. - A draft may be missing a required field — that is what a draft is for. Publishing is where a document has to be whole, and it is refused with the fields it is missing.
- A publish is refused while a language a model translated has not been approved — 409
UNAPPROVED_TRANSLATION, naming the languages.approveis what clears it, and it is a person’s: a request carrying an API token is refused. - Every publish is recorded:
GET …/versionsis the history of what a document has said, andrestoreputs one of those back as the draft. - A token needs the
EDITORrole or better to write. See Tokens for what a role reaches.
Try it
Translate a document with a model
AWS Bedrock translates the document’s fields into one of the dataset’s languages, into the draft. Not the published row: nothing a reader sees changes until somebody publishes, and the document comes back marked as a model’s work so that no surface has to guess. Rich text keeps its structure — each run of text is translated where it sits, and marks, links and images stay where they were — and a field with nothing in it is never sent, which is why nothing here costs money for an untranslated document.
| projectId* | string | What a project is addressed by — proj_ and twelve characters. |
| dataset* | string | A dataset in that project. Lowercase letters, digits, _ and -, up to 64 characters — production is the usual first one. |
| id* | string | The document to translate. |
| language* | string | The language to translate into — one of the dataset’s languages. |
| from | string | The language to translate from. Absent means the dataset’s default language. |
| fields | string[] | Only these field paths, and anything under them — ["title","excerpt"]. The escape hatch for a document too large for one model call, since a request that outlives the gateway has no answer at all. |
curl -X POST 'https://s76dm6ggpc.execute-api.us-east-1.amazonaws.com/v1/data/translate/{projectId}/{dataset}' \
-H 'Authorization: Bearer $ACHAR_TOKEN' \
-H 'content-type: application/json' \
-d '{"id": "hello-world","language": "fr","from": "en","fields": ["title"]}'{
"documentId": "hello-world",
"language": "fr",
"from": "en",
"model": "anthropic.claude-3-5-haiku-20241022-v1:0",
"fields": ["title", "excerpt", "body"],
"skipped": ["coverImage"],
"document": { "_id": "hello-world", "_type": "post", "title": { "en": "Hello world", "fr": "Bonjour le monde" }, "_translations": { "fr": { "source": "ai", "model": "anthropic.…", "at": "2026-03-02T10:00:00.000Z" } } }
}- A model may do the work and may not ship it. The language it wrote is recorded as
source: "ai"with the model’s id and no approval, and publishing is refused until a person approves it —{"approve":{"id":"hello-world","languages":["fr"]}}in a mutation batch, which needs a person’s token rather than an API token. - Editing a language’s values clears that language’s approval, because what was approved was a particular text. The studio re-approves after an edit, and so must any other client that edits a translation it did not write.
skippednames the paths there was nothing to translate in: an empty field, an asset, a number, or a pathfieldsasked for that the schema does not declare. An emptyfieldswith a fullskippedis a document that was already translated.- A deployment with no
TRANSLATION_MODELanswers 501, naming the variable. A model this account has not been granted, or a refusal, answers 502 with the provider’s own sentence.
Try it
Every time a document was published
The history, newest first: v1 is the first publish and the number goes up by one each time. Summaries rather than documents, because a history is a list somebody scrolls and a document with forty versions is forty documents of portable text to draw some dates.
| projectId* | string | What a project is addressed by — proj_ and twelve characters. |
| dataset* | string | A dataset in that project. Lowercase letters, digits, _ and -, up to 64 characters — production is the usual first one. |
| documentId* | string | A document, by its own id. The drafts.-prefixed id is accepted and means the same document. |
curl -X GET 'https://s76dm6ggpc.execute-api.us-east-1.amazonaws.com/v1/data/doc/{projectId}/{dataset}/{docId}/versions' \
-H 'Authorization: Bearer $ACHAR_TOKEN'[
{ "documentId": "pricing", "version": 2, "publishedAt": "2026-03-02T10:00:00.000Z", "publishedBy": "a1b2c3…", "rev": "01JQ9A…" },
{ "documentId": "pricing", "version": 1, "publishedAt": "2026-03-01T09:00:00.000Z", "publishedBy": "a1b2c3…", "rev": "01JQ8Z…" }
]- A version is written once and never changes. The policy on the table it lives in grants no
UpdateItemat all, so a history cannot be rewritten even by mistake. publishedByis thesubof whoever published it — absent on versions written before history existed.
Try it
One version, as it was
The document exactly as a client would have read it at the moment it was published: the same fields, the same _rev, the same _updatedAt. Anything derived on the way out would be a version seen through today’s code, and “what did this say in March” is a question about March.
| projectId* | string | What a project is addressed by — proj_ and twelve characters. |
| dataset* | string | A dataset in that project. Lowercase letters, digits, _ and -, up to 64 characters — production is the usual first one. |
| documentId* | string | A document, by its own id. The drafts.-prefixed id is accepted and means the same document. |
| version* | integer | The version number, from the history. |
| language | string | One of the dataset’s languages, as it spells it — fr, pt-BR. Defaults to the dataset’s default language. A language the dataset does not have is a 400 listing the ones it does. Localized fields are answered in it, falling back per field to the default language and naming what fell back in _untranslated. |
curl -X GET 'https://s76dm6ggpc.execute-api.us-east-1.amazonaws.com/v1/data/doc/{projectId}/{dataset}/{docId}/versions/{version}?language=fr' \
-H 'Authorization: Bearer $ACHAR_TOKEN'{
"documentId": "pricing",
"version": 1,
"publishedAt": "2026-03-01T09:00:00.000Z",
"publishedBy": "a1b2c3…",
"rev": "01JQ8Z…",
"document": { "_id": "pricing", "_type": "pricing", "title": "Pricing" }
}- Deleting a document takes its history with it: a snapshot of something that no longer exists is a version nothing can be restored into.
Try it
