API reference

Read Achar from your own code

A content lake with a query language, a schema you write rather than one you are given, a document API with drafts and publishing, and an asset pipeline where the bytes never pass through a server. This is the half of it an API token reaches — the half a site, a build or a script uses. Everything here is served from https://s76dm6ggpc.execute-api.us-east-1.amazonaws.com — the same API this site renders its own pages from.

Make a tokenSee the query language13 routes an API token reaches · 4 groups

Try it here

Signed in, make one here: pick a project you administer, name the token and choose what it may do — the secret is shown once and goes straight into the requests below. Signed out, paste a token you already have. Either way the request goes from your browser to the API with the credential in the header and nothing in between.

Every route below takes an API token, which is what this reference is: the routes a gateway authorizer would refuse are not on it. A token is scoped to one project, and its role decides whether it may write.

Quickstart

  1. 1. Make a project, and a dataset in it. A project owns datasets and people; a dataset is the content store everything below is addressed by. Both are made in the studio, and a new dataset starts with no content types — you write them, in the studio’s type editor or through the API route below, as TypeScript or from a sample of your own data.

  2. 2. Issue a token. On the project’s API screen, as an admin. It carries a role — VIEWER, EDITOR or ADMIN — and optionally one dataset, and the secret is shown exactly once.

  3. 3. Ask it something. The first call anybody makes is the one that needs no credential at all:

    cURL
    curl -X GET 'https://s76dm6ggpc.execute-api.us-east-1.amazonaws.com/v1/info'
  4. 4. Add a document. One request writes content and makes it live: a batch is applied in order, so create followed by publish is a document a site serves by the time this answers. A rich-text field takes a plain string — one paragraph per line — so nothing here has to know the shape of Portable Text.

    cURL
    curl -X POST 'https://s76dm6ggpc.execute-api.us-east-1.amazonaws.com/v1/data/mutate/proj_647baf1fe6b1/production' \
      -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"}}]}'
  5. 5. Then query your own content. GROQ is the whole point: one language over the dataset, evaluated server-side, with parameters rather than string interpolation.

    cURL
    curl -X GET 'https://s76dm6ggpc.execute-api.us-east-1.amazonaws.com/v1/data/query/proj_647baf1fe6b1/production?query=*%5B_type+%3D%3D+%22post%22%5D+%7C+order%28publishedAt+desc%29%5B0...5%5D&params=%7B%22type%22%3A%22post%22%7D&perspective=published&shape=schema&language=fr' \
      -H 'Authorization: Bearer $ACHAR_TOKEN'

Authentication

Achar accepts two kinds of caller, and which one you hold decides which routes answer you. Both go in the same header — Authorization: Bearer … — and they are verified in two different places, which is the part that surprises people.

An API token

achar_<tokenId>_<secret>. Scoped to one project, with a role, and revocable on its own — so it keeps working when the person who issued it leaves. Verified by the handler, because API Gateway’s authorizer only understands Cognito.

It reaches the content, asset and schema routes: /v1/data/**, /v1/assets/** and /v1/schema/**.

A person’s ID token

A Cognito ID token from signing in. The studio and the console send this, and it is verified at the gateway — before any handler runs — which is why an API token presented to a management route is refused with a 401 rather than reaching code that could explain itself.

It reaches everything else: projects, datasets, members, tokens, webhooks, and the whole-schema write.

There is no OAuth here, and that is a decision rather than an omission: a token is issued by an admin to a script, and the alternative — asking a third party’s users to consent to scopes — belongs to a product that has users to ask. A token is also a kind of credential as well as a rank: it can never create or delete a project, whatever role it carries.

The other half of the API is the management API — projects, datasets, members, tokens, webhooks, and writing a dataset’s schema in one piece — and it takes a signed-in person’s ID token, which API Gateway verifies before any handler runs. A token is refused there, so those routes are not on this page: they are what the studio calls, and the studio is where they are used. The one every client needs from it, issuing a token, is the panel above.

The query language

Achar speaks a subset of GROQ, and it says which subset rather than failing quietly: anything outside it is a 400 naming the position in the query.

Filters, projections, ordering
*[_type == "post" && featured] {
  title,
  "author": author->name,
  categories[]->title
} | order(publishedAt desc)[0...10]
Parameters, counts, slices
{
  "total": count(*[_type == "post"]),
  "draft": *[_id == $id][0],
  "tagged": *[_type == "post" && $tag in tags]
}

Understood: *, filters with &&, ||, !, comparisons, in, match, defined(), count(), order(), slices, projections, -> dereference, ^ parent, $param and the pipe operator.

Service

What the deployment is. This is where a client starts, because it is the one route that answers without a credential — and because the stage and version it names are what a bug report should carry.

GET/v1/infoNo credential

The service itself

Name, version, stage and region. Anonymous on purpose: a deployment that cannot be asked whether it is up is a deployment somebody debugs by guessing.

cURL
curl -X GET 'https://s76dm6ggpc.execute-api.us-east-1.amazonaws.com/v1/info'
200 OK
{
  "name": "achar",
  "version": "1.0.0",
  "stage": "dev",
  "region": "us-east-1"
}
  • There is no GET /v1/health. This is it: a route that answers means the handler ran, which is the only health a Lambda has.
  • The version is baked into every function at deploy time, so it says which deployment answered rather than what the repository looks like.

Try it

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.

GET/v1/data/query/{projectId}/{dataset}API token

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.

Path
projectId*stringWhat a project is addressed by — proj_ and twelve characters.
dataset*stringA dataset in that project. Lowercase letters, digits, _ and -, up to 64 characters — production is the usual first one.
Query
query*stringThe GROQ query. See the subset this service implements, below.
paramsJSON objectWhat $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.
languagestringOne 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
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&params=%7B%22type%22%3A%22post%22%7D&perspective=published&shape=schema&language=fr' \
  -H 'Authorization: Bearer $ACHAR_TOKEN'
200 OK
{
  "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
}
Response
resultanyWhat the query asked for. A projection, a count, a single document — whatever the query says.
msintegerHow 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.
documentsReadintegerHow 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, ->, ^, $param and the pipe operator. Anything outside it is a 400 naming the position, never a silently empty result.
  • A query that reads a previewDrafts perspective sees drafts; the default does not.
  • A whole document is answered as the type that declares it. An image, video or file field 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. null means the thing pointed at is gone. Ask for shape=stored to 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 a strong phrase inside it is a second — which is the shape the schema describes and the one a person can read.

Try it

5 query parameters
No token yet — paste one above and the request will carry it.
GET/v1/data/list/{projectId}/{dataset}API token

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.

Path
projectId*stringWhat a project is addressed by — proj_ and twelve characters.
dataset*stringA dataset in that project. Lowercase letters, digits, _ and -, up to 64 characters — production is the usual first one.
Query
type*stringThe _type to list.
limitintegerHow many to answer with. The API has its own ceiling and will not go past it.
nextTokenstringThe page to read next, taken from the previous response. Opaque — pass it back unchanged, and stop when it is absent.
searchstringFilters the page by the text the preview draws.
orderstringOne 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”.
languagestringOne 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
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'
200 OK
{
  "items": [
    {
      "_id": "hello-world",
      "_type": "post",
      "_rev": "01JQ8Z…",
      "_updatedAt": "2026-03-01T09:00:00.000Z",
      "hasDraft": false,
      "published": true,
      "title": "Hello world"
    }
  ],
  "nextToken": null
}
  • search and order are 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 a query.

Try it

7 query parameters
No token yet — paste one above and the request will carry it.
GET/v1/data/doc/{projectId}/{dataset}/{documentId}API token

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.

Path
projectId*stringWhat a project is addressed by — proj_ and twelve characters.
dataset*stringA dataset in that project. Lowercase letters, digits, _ and -, up to 64 characters — production is the usual first one.
documentId*stringA document, by its own id. The drafts.-prefixed id is accepted and means the same document.
Query
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.
languagestringOne 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
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'
200 OK
{
  "_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" }
}
Response
_idstringThe document’s id, unprefixed.
_typestringThe content type it is, which is what the schema calls it.
_revstringThe revision this write produced. Two clients compare it to notice a change.
_createdAtstringISO 8601. When the document first existed.
_updatedAtstringISO 8601. When this row was last written.
_draftbooleanTrue when this is the drafts.<id> row.
_publishedbooleanWhether a published row exists beside it.
_editablebooleanWhether the caller may change it, resolved on read like Project.role.
_languagestringThe language this answer is in — the one asked for, or the dataset’s default.
_untranslatedstring[]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.
_translationsRecord<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 published rather 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=stored answers 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
No token yet — paste one above and the request will carry it.
POST/v1/data/mutate/{projectId}/{dataset}API token

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.

Path
projectId*stringWhat a project is addressed by — proj_ and twelve characters.
dataset*stringA dataset in that project. Lowercase letters, digits, _ and -, up to 64 characters — production is the usual first one.
Body
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.
atomicbooleanFail the whole batch rather than applying what can be applied. One transaction.
cURL
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": ""}'
200 OK
{
  "results": [
    { "documentId": "hello", "operation": "create", "rev": "01JQ8Z…" },
    { "documentId": "hello", "operation": "publish", "rev": "01JQ8ZA…" }
  ],
  "transactionId": "01JQ8ZB…"
}
  • To add content and make it live, send create and publish in 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, patch and restore write the draft; publish is what moves a draft onto the published id, and unpublish takes 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’s unset paths are taken as written, so title removes the field and title.fr removes 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. approve is what clears it, and it is a person’s: a request carrying an API token is refused.
  • Every publish is recorded: GET …/versions is the history of what a document has said, and restore puts one of those back as the draft.
  • A token needs the EDITOR role or better to write. See Tokens for what a role reaches.

Try it

No token yet — paste one above and the request will carry it.
POST/v1/data/translate/{projectId}/{dataset}API token

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.

Path
projectId*stringWhat a project is addressed by — proj_ and twelve characters.
dataset*stringA dataset in that project. Lowercase letters, digits, _ and -, up to 64 characters — production is the usual first one.
Body
id*stringThe document to translate.
language*stringThe language to translate into — one of the dataset’s languages.
fromstringThe language to translate from. Absent means the dataset’s default language.
fieldsstring[]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
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"]}'
200 OK
{
  "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.
  • skipped names the paths there was nothing to translate in: an empty field, an asset, a number, or a path fields asked for that the schema does not declare. An empty fields with a full skipped is a document that was already translated.
  • A deployment with no TRANSLATION_MODEL answers 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

No token yet — paste one above and the request will carry it.
GET/v1/data/doc/{projectId}/{dataset}/{docId}/versionsAPI token

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.

Path
projectId*stringWhat a project is addressed by — proj_ and twelve characters.
dataset*stringA dataset in that project. Lowercase letters, digits, _ and -, up to 64 characters — production is the usual first one.
documentId*stringA document, by its own id. The drafts.-prefixed id is accepted and means the same document.
cURL
curl -X GET 'https://s76dm6ggpc.execute-api.us-east-1.amazonaws.com/v1/data/doc/{projectId}/{dataset}/{docId}/versions' \
  -H 'Authorization: Bearer $ACHAR_TOKEN'
200 OK
[
  { "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 UpdateItem at all, so a history cannot be rewritten even by mistake.
  • publishedBy is the sub of whoever published it — absent on versions written before history existed.

Try it

No token yet — paste one above and the request will carry it.
GET/v1/data/doc/{projectId}/{dataset}/{docId}/versions/{version}API token

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.

Path
projectId*stringWhat a project is addressed by — proj_ and twelve characters.
dataset*stringA dataset in that project. Lowercase letters, digits, _ and -, up to 64 characters — production is the usual first one.
documentId*stringA document, by its own id. The drafts.-prefixed id is accepted and means the same document.
version*integerThe version number, from the history.
Query
languagestringOne 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
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'
200 OK
{
  "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

1 query parameter
No token yet — paste one above and the request will carry it.

Assets

Images, videos and files. The bytes never pass through a handler: an upload claims a ticket, PUTs straight to S3, and commits the metadata. That is what makes a forty-megabyte video an ordinary upload rather than a Lambda’s memory problem.

GET/v1/assets/{projectId}/{dataset}API token

The asset library

The dataset’s assets, paged, and filterable by kind.

Path
projectId*stringWhat a project is addressed by — proj_ and twelve characters.
dataset*stringA dataset in that project. Lowercase letters, digits, _ and -, up to 64 characters — production is the usual first one.
Query
kind"image" | "video" | "file"Only this kind. Absent means all three.
limitintegerHow many to answer with. The API has its own ceiling and will not go past it.
nextTokenstringThe page to read next, taken from the previous response. Opaque — pass it back unchanged, and stop when it is absent.
cURL
curl -X GET 'https://s76dm6ggpc.execute-api.us-east-1.amazonaws.com/v1/assets/{projectId}/{dataset}?limit=20' \
  -H 'Authorization: Bearer $ACHAR_TOKEN'
200 OK
{
  "items": [
    {
      "assetId": "01JQ8Z…",
      "kind": "image",
      "filename": "cover.png",
      "contentType": "image/png",
      "size": 84213,
      "width": 1200,
      "height": 800,
      "reference": "image-01JQ8Z…-1200x800-png",
      "url": "https://cdn.example/images/…/cover.png"
    }
  ],
  "nextToken": null
}
Response
referencestringWhat a document stores in a field. image-<assetId>-<w>x<h>-<ext>, video-…, file-<assetId>-<ext>.
urlstringThe CDN address, built on read rather than stored — so a distribution that moves does not orphan every document pointing at it.

Try it

3 query parameters
No token yet — paste one above and the request will carry it.
POST/v1/assets/{projectId}/{dataset}/upload-urlAPI token

Reserve an asset and presign a PUT

Step one of an upload: the row is reserved, and the answer carries a URL that S3 itself accepts for a few minutes. Step two is the PUT of the bytes to that URL — no credential of ours goes with it.

Path
projectId*stringWhat a project is addressed by — proj_ and twelve characters.
dataset*stringA dataset in that project. Lowercase letters, digits, _ and -, up to 64 characters — production is the usual first one.
Body
filename*stringWhat the file is called. It becomes the extension on the reference.
contentType*stringThe MIME type the PUT will declare. It must match what the PUT sends.
kind*"image" | "video" | "file"Which half of the library it belongs in.
size*integerHow many bytes are about to be sent.
cURL
curl -X POST 'https://s76dm6ggpc.execute-api.us-east-1.amazonaws.com/v1/assets/{projectId}/{dataset}/upload-url' \
  -H 'Authorization: Bearer $ACHAR_TOKEN' \
  -H 'content-type: application/json' \
  -d '{"filename": "cover.png","contentType": "image/png","kind": "image","size": 84213}'
200 OK
{
  "assetId": "01JQ8Z…",
  "uploadUrl": "https://achar-dev-assets.s3.us-east-1.amazonaws.com/assets/…?X-Amz-Signature=…",
  "expiresIn": 900
}
  • Write the ticket with ClientRequestToken-style care: a reservation that is never committed is a row with no bytes, and uploading again mints a new asset id rather than repairing the old one.

Try it

No token yet — paste one above and the request will carry it.
POST/v1/assets/{projectId}/{dataset}API token

Commit the metadata after the PUT

Step three. The bytes are in the bucket and this is what makes them an asset: the row is marked committed and the dimensions are recorded. The dimensions are sent by the client because the browser is where the image — or the video’s first frame — is already decoded.

Path
projectId*stringWhat a project is addressed by — proj_ and twelve characters.
dataset*stringA dataset in that project. Lowercase letters, digits, _ and -, up to 64 characters — production is the usual first one.
Body
assetId*stringThe id the ticket answered with.
widthintegerImages and videos. For a video it is the frame size, which is what lets a page hold the space before the first frame arrives.
heightintegerThe other half of the frame.
blurHashstringA placeholder colour or hash, so a list can draw before the image arrives.
cURL
curl -X POST 'https://s76dm6ggpc.execute-api.us-east-1.amazonaws.com/v1/assets/{projectId}/{dataset}' \
  -H 'Authorization: Bearer $ACHAR_TOKEN' \
  -H 'content-type: application/json' \
  -d '{"assetId": "01JQ8Z…","width": 1200,"height": 800,"blurHash": ""}'
200 OK
{
  "assetId": "01JQ8Z…",
  "kind": "image",
  "reference": "image-01JQ8Z…-1200x800-png",
  "url": "https://cdn.example/images/…/cover.png"
}
  • Committing twice is not an error: the second answer is the asset as it already stands.

Try it

No token yet — paste one above and the request will carry it.
DELETE/v1/assets/{projectId}/{dataset}/{assetId}API token

Delete an asset

The object and its row. Documents that referenced it keep the reference they were written with, and draw a placeholder from then on.

Path
projectId*stringWhat a project is addressed by — proj_ and twelve characters.
dataset*stringA dataset in that project. Lowercase letters, digits, _ and -, up to 64 characters — production is the usual first one.
assetId*stringThe asset, by the id in its reference.
cURL
curl -X DELETE 'https://s76dm6ggpc.execute-api.us-east-1.amazonaws.com/v1/assets/{projectId}/{dataset}/{assetId}' \
  -H 'Authorization: Bearer $ACHAR_TOKEN'

Answers 204 No Content with no body.

  • Uploading the same file again mints a new asset id. It does not repair the documents that point at the old one.

Try it

No token yet — paste one above and the request will carry it.

Content types

A dataset starts with no content types, because what it holds is the dataset’s own decision. This is how one gets written from outside the studio — the same route the studio’s type editor calls, so a schema built by a script and a schema built by hand are the same schema.

POST/v1/schema/{projectId}/{dataset}/typesAPI token

Add or replace one content type

One type, not the whole schema. That is the difference that matters: a caller sending a whole types array has to have read it first, and two editors doing that at once lose one of the two types. The API merges instead — reading, merging, and writing only if the revision it read is still the one there — so two of these are applied one after the other rather than one over the other.

Path
projectId*stringWhat a project is addressed by — proj_ and twelve characters.
dataset*stringA dataset in that project. Lowercase letters, digits, _ and -, up to 64 characters — production is the usual first one.
Body
name*stringWhat the type is filed under, and what a document of it stores as _type. A TypeScript identifier, because it is the name your queries write.
titlestringWhat the studio calls it. Absent means the name, made readable — post becomes Post.
kind"document" | "object"Absent means document. An object is a type that is only ever a field of another type — an address, a link.
iconstringA lucide icon name, drawn beside the type in the studio.
descriptionstringA sentence for whoever reads this schema next.
fields*SchemaField[]What a document of this type holds, in the order a form draws them. Each is { name, title, type } plus whatever that type needs — options for a picker, of for an array, to for a reference, required for one a publish is refused without.
replacesstringThe name this write stands in for: an edit, or a rename. Absent, the write only adds — a name already in the schema is a 409 rather than a quiet overwrite. Present, it is an upsert, which is what makes a bootstrap script safe to run twice.
cURL
curl -X POST 'https://s76dm6ggpc.execute-api.us-east-1.amazonaws.com/v1/schema/{projectId}/{dataset}/types' \
  -H 'Authorization: Bearer $ACHAR_TOKEN' \
  -H 'content-type: application/json' \
  -d '{"name": "post","title": "Blog post","kind": "document","icon": "FileText","description": "","fields": [{"name": "title","title": "Title","type": "string","required": true}],"replaces": "post"}'
201 Created · 200 OK
{
  "projectId": "proj_647baf1fe6b1",
  "dataset": "production",
  "types": [
    {
      "name": "post",
      "title": "Blog post",
      "kind": "document",
      "icon": "FileText",
      "fields": [
        { "name": "title", "title": "Title", "type": "string", "required": true }
      ]
    }
  ],
  "revision": "9f2c1a…",
  "updatedAt": "2026-03-01T09:00:00.000Z"
}
Response
typesSchemaType[]Every type the dataset has now, not only the one written, in the order the studio draws them.
revisionstringA hash of types. It changes when they change and not otherwise, which is what the next write is made conditional on.
updatedAtstringISO 8601. When this schema row was last written.
  • 201 means it added a type; 200 means it replaced one — so a second run of a bootstrap script can tell that it is a second run.
  • Without replaces, a name that is already taken answers 409 TYPE_EXISTS. A create that stood in for an existing type would be a create that destroys one, with one typo and nothing in the answer to say so.
  • The parts of a type this body does not speak for — preview, orderings and groups — are carried across a replace from what is stored. But a field the type you sent leaves out is cleared, so an edit can remove a description as well as set one.
  • A rename is a replaces whose name differs. The name a type is filed under is its identity, so a rename is this type arriving where that one was rather than an edit to a field — and renaming onto a name another type already holds is refused, because a schema may not repeat one.

Try it

No token yet — paste one above and the request will carry it.

Errors

One envelope, whoever refused the request — a handler, the gateway, or the validator. code is what a program branches on; message is written for a person and is safe to show.

400 Bad Request
{
  "error": {
    "code": "VALIDATION_FAILED",
    "message": "Pricing is not valid",
    "details": {
      "issues": [ { "path": "title", "message": "is required" } ]
    }
  }
}
400BAD_REQUESTThe request is malformed — a missing field, a body that is not JSON, a query that does not parse. details says which.
400VALIDATION_FAILEDThe document does not satisfy its schema. details.issues lists every field that is wrong, not just the first.
400UNKNOWN_TYPEThe document names a _type the dataset’s schema does not declare. details.types lists the ones it does.
401UNAUTHORIZEDNo credential, or one that is not valid — an expired session, a revoked token, a token presented to a route that takes a person’s session.
403FORBIDDENA valid credential that may not do this. A project that does not exist and a project the caller is not in both answer 403, so that a stranger cannot walk the id space.
404NOT_FOUNDThe project, dataset or document is not there — or exists only as a draft, at the published perspective.
409CONFLICTA document changed while this batch was being prepared. Compare _rev and retry.
409DATASET_EXISTSA dataset with that name is already in the project. Dataset names are unique within a project.
429TOO_MANY_REQUESTSThe service is throttling reads. Retry with a delay.
500INTERNAL_ERRORA bug. The request id in the response header is what finds it in CloudWatch.

Conventions

The things that are true of every route, said once instead of forty times.

  • Everything is under /v1. Every route needs a bearer token — an API token or a person’s ID token — except GET /v1/info, which exists so a deployment can be asked whether it is up.
  • The credential goes in Authorization: Bearer <token>, for both kinds. Which kind you hold decides which routes you can reach, and a route answers 401 rather than pretending a token is a session.
  • Bodies are JSON, and so are answers. Timestamps are ISO 8601 strings; durations are milliseconds.
  • Lists are paged by an opaque nextToken rather than by an offset, and a page answers with nextToken: null when it is the last one.
  • Errors are one envelope — see below — and code is the part a program should branch on. message is written for a person.
  • Documents are written as documents, not as fields: mutate takes operations rather than a shape per endpoint, and a publish is an operation rather than a flag.
  • A whole document is answered as the type that declares it — assets as addresses, references as the documents they name — and ?shape=stored answers the row instead. It is the one parameter that changes a shape rather than selecting data, and it exists because a site and an editor want different things from the same document.

The reasoning behind all of this — why two kinds of credential, why publishing is an operation, why history lives in its own table — is in the repository, in docs/architecture.md.