Two protocols that cannot disagree with each other

Your site fetches over REST. Somebody’s app prefers GraphQL. Both are served by the same code — the same query, the same guards, the same published-only rule — because the GraphQL layer is an adapter over the REST implementation rather than a second API.

For the engineers building whatever reads this content

One implementation, two front doors

The public API is a token-authenticated read surface at /api/v1. GraphQL sits at /api/v1/graphql and its resolvers call the very same services the REST controllers call — the same entries query, the same write service.

That is not a tidiness argument, it is a correctness one. The bearer guard, the workspace grant check, the rule that only published entries are visible, and every write invariant apply identically to both, because there is only one place each of them is written. A change to a signature changes two protocols at once, which is exactly the property you want.

A session cookie is not accepted on either. A bearer token is the only way in, so a browser that happens to be signed in to the admin cannot be used to read the API by accident.

The same filtered, localised request written as a REST URL and as a GraphQL query, above the single JSON response both return.

Two protocols, one PublicEntriesQuery. Neither can serve a draft, because neither is where that rule lives.

The read surface

Everything a front end needs, and nothing that leaks a draft.

Route What it returns
GET /v1/content-types Summaries of every type this token’s workspace was granted.
GET /v1/content-types/:name One type’s full field schema, so a client can generate against it.
GET /v1/content/:type A page of published entries, with search, filter, sort, page, sparse fields and locale.
GET /v1/content/:type/:id One published entry, with the same expansion parameters.
…/relations/:field A page of one relation field’s links, rather than everything inline.
…/media Every media field on the entry, resolved to asset metadata and URLs.
…/translations The entry’s other language rows.

Singles are served through the list route too — with translation a single still has one row per language, so the list envelope is honest for both. Take the first item.

Batches, because a client syncing a list is the normal case

A full-scope token can write: create a draft, patch an entry with relation deltas, publish, unpublish, delete. And it can do all of those in batches, because an external client is usually syncing a list and one round trip per record is the difference between a job that finishes and one that times out.

Batch save reports per item and keeps going. Each write opens its own transaction, so there is no batch to roll back even in principle, and rejecting forty-nine good rows because the fiftieth names a missing relation would defeat the point. You get a verdict per item in request order, each failure carrying the same status and the same per-field issues the single-entry call would have given.

Batch publish is deliberately different. It selects its candidates for update and re-validates them inside one transaction, because that is the only thing that closes the window between "this draft validates" and "publish it". Two contracts, and the difference between them is not arbitrary.

Details that decide an integration

Tokens carry a scope and workspaces
Read or full, granted to specific workspaces. A client’s front end can be given a token that reaches its own content and nothing else in the same installation.
The API describes itself
An OpenAPI document is generated from the same types the routes are built from, so it cannot drift from the endpoints it describes.
The same filter grammar as the admin
The filter tree you build in the content library is the JSON your front end sends. What you can narrow to in the interface, you can fetch.
Sparse fieldsets are enforced
Ask for three fields and you get three. Ask for a field that does not exist and you are told — here the caller is naming something they expect back, so an unknown name is an error rather than a silent drop.

A build that has to finish

Your static site generator pulls 4,000 published articles at build time. One request per page of entries, sparse fields so each row is small, and the schema endpoint to generate the types. No draft leaks into the build because the read surface never serves one, and the token that does it cannot write anything even if the build script has a bug.

Integration questions

How do I know when content changes?

Subscribe a webhook endpoint to the events you care about — entry.published and entry.unpublished for a site build — and it is posted to, signed, from a queue. The body names the record rather than carrying it, so your receiver reads it back through this API with its own token and the published-only rule still applies. Polling still works if you would rather keep the decision in your pipeline.

Can I read drafts for a preview environment?

Not through the public API — it serves published entries only. A preview would have to go through the authenticated admin API with a session, which is not a supported integration path today.

Is GraphQL a subset of REST or the other way round?

Neither. They are the same surface. The GraphQL schema assembles the same DTOs and calls the same services, so anything documented for one applies to the other.

What happens to a request for an entry in a language it does not have?

By default it is not found, because listing is strict about locale. Ask for the fallback and you get the default language’s row.

Every feature is included, free

The core is MIT licensed and every feature is in it — none of them is paid-only. Community runs free in production; a plan buys room and governance, not a different product.