Knowledge base · Guide
Reading content without an SDK: the delivery API as plain JSON
- Kind
- Guide
- Published
- 26 Jun 2026
- Reading
- 6 min
Applies to: every Coho project, on any tag or environment You will learn: how to read published content with nothing but HTTP, how to get a shape you can hand straight to a template, and the one header that makes repeat reads nearly free
The short version
Coho's delivery API is a GET with a querystring, and it answers JSON. The SDKs are a convenience, not a requirement.
| You want | Ask for |
|---|---|
| A page by its slug | ?slug=about |
| It with its references filled in | &shape=nested&depth=2 |
| Only some fields | &fields=title,body,author |
| A list, ordered and paged | ?type=article&order=-published&limit=20&offset=0 |
GET /v1/projects/{project}/{ref}/entries?slug=about&shape=nested&depth=1
Authorization: Bearer <your delivery key>
Read this section first, even if you read nothing else: send back the ETag you were given, and Coho answers 304 Not Modified with no body. That is the difference between re-downloading your whole site on every request and not.
Start with revalidation
Every read of a tag or an environment comes back with an ETag:
ETag: "9f2c…"
Cache-Control: private, max-age=31536000, immutable
Keep the body and that ETag. Next time you want the same URL, send it back:
GET /v1/projects/acme/prod/entries?slug=about
Authorization: Bearer <key>
If-None-Match: "9f2c…"
If the content has not moved, you get 304 Not Modified with no body, and you serve what you already had. That is four lines of code and it is the single biggest thing you can do for your site's latency and your bill.
⚠️ Store the ETag beside the URL it came from, never on its own. Coho's ETag is the identity of the whole release, not of one response — every URL on a ref carries the same value. That is useful (one comparison answers "has prod moved?") and it has a sharp edge: if you keep one ETag globally and send it with a URL you have never fetched, Coho will correctly answer 304, and you will have nothing to show. One ETag per cached body.
A tag can be cached forever. immutable on a tag is the truth: v1.4.6 will never hold different content. An environment like prod gets a short max-age instead, because a promotion or a rollback must be seen.
private or public depends on how you read. A keyed response is private, so no intermediary may cache it — that is what keeps key revocation a matter of seconds rather than of somebody's cache TTL. A keyless read of a public environment is public, and a CDN may cache it normally.
Two shapes, and why there are two
Add shape=nested and you get an array you can hand to a template. Leave it off and you get the flat shape the SDKs use.
shape=graph — the default, and what every SDK reads:
{
"roots": ["a1…"],
"entries": {
"a1…": { "id": "a1…", "slug": "about", "type": "page",
"fields": { "title": "About us", "author": "b2…" } },
"b2…": { "id": "b2…", "slug": "jo", "type": "person",
"fields": { "name": "Jo" } }
},
"unresolved": {},
"hasMore": false
}
Reference fields hold ids, and you look them up in entries yourself. That is deliberate: it is one copy of each entry however many things point at it, and it is what an SDK needs to build typed objects.
shape=nested — the same read, rendered for a human:
{
"items": [
{ "id": "a1…", "slug": "about", "type": "page",
"title": "About us",
"author": { "id": "b2…", "slug": "jo", "type": "person", "name": "Jo" } }
],
"hasMore": false,
"unresolved": {}
}
Fields sit on the entry — item.title, not item.fields.title — and a reference field holds the entry itself. item.author.name just works.
Both are the same read: the same key, the same ETag, the same 304, the same limits. Only the rendering differs. shape is in the URL rather than a header on purpose, so caches and CDNs key on it correctly.
What shape=nested costs you
An entry that three pages reference is carried three times — once at each place it appears. That is what makes the shape readable, and it means a nested response can be much larger than the flat one for the same content. If you are fetching a hundred articles that all share one large author record, use shape=graph and look the author up once.
Ask for fewer fields
fields=title,body returns only those fields. id, slug, and type are always there, because a response whose items cannot be identified is no use to anybody.
Two things worth knowing:
- A name no type on this ref declares is ignored, not refused. Your content model moves with your releases, and a read should not start failing because one type lost one field.
- A reference field you leave out is not followed. So
fields=titleon a page with anauthorreference does not fetch the author at all — not even as an unresolved id. This is the cheapest thing in the whole API: it saves the bytes and the work.
One URL for a page, an entry, or a list
The listing route takes slug and id as filters, so you rarely need a different path:
?slug=about | the one entry with that slug, as a one-item page |
?id=a1… | the one entry with that id |
?type=article | every article, and every type that extends it |
?type=article&exact=true | articles only, not their subtypes |
A filter that matches nothing is an empty page, not a 404. If you want a 404 for a missing slug — say, to return one from your own router — ask …/entries-by-slug/{slug} instead, which is the route that has one.
⚠️ slug and id each select one entry, so they cannot be combined with type, exact, or order. Sending both is refused rather than quietly ignored, so you never believe you have a filter you do not have.
Locales, order, and paging
locale=en-GBprojects every localized field to that locale. A field with no translation in that locale is absent, not null. Omittinglocalegives you the non-localized fields only — the same answer as asking for a locale nobody has translated into.order=-publishedorders by a field, descending with the-. An order needs atype, because the same field id can mean different things on different types, and onlyinteger,number, anddatetimefields can be ordered on today.limitis at most 100, default 25, withoffsetand ahasMoreflag.
With no order, a list is by id, which is stable — so paging through it never skips or repeats.
Following references, and what the edges mean
depth=2 follows two reference hops from each root. Every reference field is followed, including references inside repeaters, and depth always counts entries rather than nesting.
In the flat shape, an id that is not in entries is explained in unresolved:
"depth" | it is on this ref, but beyond the depth you asked for |
"absent" | it is not on this ref at all — deleted, or never merged |
In the nested shape the same facts appear where you need them, in place of the entry:
{ "_id": "c3…", "_ref": "depth" }
_ref is depth, absent, or cycle. The last one means the entry is an ancestor of this position — it is in the response, further up the same branch — which is how a nested shape stays finite when your content has a loop in it.
unresolved is still in the nested response, and it is worth reading: it is the only place a response says what it could not give you.
Limits that fail loudly
One read returns at most 2,000 entries and 8 MB of them. A read that would exceed either is refused with 400 RESOLUTION_LIMIT, naming the cap it crossed, rather than returning a partial graph that looks complete.
For shape=nested the byte cap is measured on the response as rendered — duplication included — so a nested read of a hundred roots sharing one large reference can meet the cap where the flat read of the same content would not. Ask for less depth, fewer fields, or the flat shape.
Keys, and reading from a browser
A delivery key is a bearer token:
Authorization: Bearer <key>
⚠️ A key in browser JavaScript is a published key. Anyone can read it out of your bundle. There are two safe shapes:
- Hold the key server-side. Your own backend or edge function reads Coho and serves your pages. This is what most sites should do.
- Open an environment to keyless reads. A public environment serves without any key, and those responses are
publicand cacheable by any CDN — so your own CDN or edge can sit in front of them and serve your site from cache.
Keyless reads work from browser JavaScript. A keyless response carries Access-Control-Allow-Origin: * and exposes its ETag, so you can fetch it cross-origin and revalidate with If-None-Match exactly as a server would.
⚠️ Keyed reads do not, and that is on purpose. Coho's CORS preflight does not allow the Authorization header, so a browser refuses a keyed cross-origin read before it is even sent. If you find yourself wanting to add a key to a fetch, that is the signal to either open the environment to keyless reads or move the call to your own backend — rather than a sign that something is misconfigured.
Note that opening an environment opens that environment's name only. The tag behind a public prod still needs a key when you address it directly.
When Coho says no
Every refusal is an RFC 9457 problem document with a stable code. Branch on the code, not on the status:
| Code | Status | What to do |
|---|---|---|
KEY_REQUIRED | 401 | send a key |
PROJECT_NOT_FOUND | 404 | the key does not reach this project — check both |
REF_NOT_FOUND | 404 | no such tag or environment here |
ENTRY_NOT_FOUND | 404 | no such entry on this ref |
INVALID_QUERY | 400 | the detail says which parameter |
PATH_INVALID | 400 | the path has a segment that cannot be forwarded — a ., a .., or an encoded slash |
REQUEST_TOO_LARGE | 413 | a request body past the cap, which only the GraphQL POST has |
RESOLUTION_LIMIT | 400 | ask for less |
RATE_LIMITED | 429 | wait Retry-After seconds, then retry |
PLAN_LIMIT | 403 | the account is past a plan cap; the detail says until when |
429 is the one place this API uses a precise status, because every HTTP client's and SDK's back-off already keys on it and on the Retry-After beside it.
Rate limits are per key, and per environment for a keyless one. So one noisy client cannot take the rest of your site down with it — and it is worth giving each of your clients its own key for exactly that reason.
Common questions
Do I lose anything by not using an SDK?
Caching, mostly. The SDKs keep a response cache and revalidate with If-None-Match for you. The protocol is identical, so the section at the top of this page is the whole of what you are reproducing.
Is shape=nested slower?
It is the same read and the same walk. It renders a little more and it can send a good deal more bytes when your content is heavily shared.
Can I filter on a field's value? Not today. The querystring above is the whole query surface: type, locale, slug, id, and an order on a numeric or date field. Filtering by an arbitrary field's value needs an index Coho does not build yet, and faking it by reading a page and filtering afterwards would give you wrong counts and wrong paging.
Should I put a CDN in front of this?
For a public environment, yes — those responses are ordinarily cacheable and a tag's are immutable. For keyed reads, no: they are marked private and an intermediary must not cache them, because that would serve one consumer's content to another and turn key revocation into a cache expiry.
What is warnings in my response?
Something the response could not give in full — an asset field whose asset is gone, or, in the nested shape, an entry whose own field is called id, slug, type, or locale, which Coho puts under fields on that entry alone rather than losing it. It is absent when there is nothing to say, so treat its presence as information and never fail on a code you do not recognise.