Knowledge base · Guide

Reading content without an SDK: the delivery API as plain JSON

90Ø120 pitchØ60 pitchAB
Fig. 01 Spur wheel and pinionThirty teeth to fifteen: the wheel turns once for every two turns of its pinion.
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 wantAsk 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=title on a page with an author reference 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=aboutthe one entry with that slug, as a one-item page
?id=a1…the one entry with that id
?type=articleevery article, and every type that extends it
?type=article&exact=truearticles 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-GB projects every localized field to that locale. A field with no translation in that locale is absent, not null. Omitting locale gives you the non-localized fields only — the same answer as asking for a locale nobody has translated into.
  • order=-published orders by a field, descending with the -. An order needs a type, because the same field id can mean different things on different types, and only integer, number, and datetime fields can be ordered on today.
  • limit is at most 100, default 25, with offset and a hasMore flag.

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:

  1. Hold the key server-side. Your own backend or edge function reads Coho and serves your pages. This is what most sites should do.
  2. Open an environment to keyless reads. A public environment serves without any key, and those responses are public and 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:

CodeStatusWhat to do
KEY_REQUIRED401send a key
PROJECT_NOT_FOUND404the key does not reach this project — check both
REF_NOT_FOUND404no such tag or environment here
ENTRY_NOT_FOUND404no such entry on this ref
INVALID_QUERY400the detail says which parameter
PATH_INVALID400the path has a segment that cannot be forwarded — a ., a .., or an encoded slash
REQUEST_TOO_LARGE413a request body past the cap, which only the GraphQL POST has
RESOLUTION_LIMIT400ask for less
RATE_LIMITED429wait Retry-After seconds, then retry
PLAN_LIMIT403the 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.