Origin; see
Authentication. Both serve published articles only.
Article fields
Every article carries these fields in both formats, plus the format-specific body described under Response body.
Field names are
snake_case, as everywhere in the REST API.
Gated articles
A gated article that the current reader cannot open stays in every list where it would appear, in the same position and with the same paging behaviour. The list returns a locked row instead of the article body.Locked row fields
The locked row has exactly these fields.excerpt and feature_image_url are always null, and no body key is present: there is no html, blocks or contract_version on a locked row.
The response is still complete for an ungated article or a reader who can open a gated article. A club that gates nothing receives the same article responses and paging behaviour as before this feature, and the gate adds no reader or tier work to those reads.
The body is the only field that arrives as HTML.
html (and, in the block
format, each text block’s html fragment) is sanitised when the article is
published, which is what makes it safe to inject. Every other field, title,
excerpt, author_name, tags, feature_image_url and the media blocks’
src, alt and caption, is plain text an author typed and is not escaped for
you. Set those through the DOM (textContent, img.alt = …) rather than
building markup out of them.
GET /v1/content/articles
A page of the club’s published articles, ordered newest published first.Query parameters
Parameters this list does not define are ignored, so you can append your own
cache-busting or analytics parameters to these URLs.
An unset
pinned, limit or user_id is treated as absent rather than
refused: clubs template these from configuration, and ?pinned= with nothing
after it means the template variable was never filled in, not that the club
asked for something. user_id belongs in that list for the same reason. A page
that templates the signed-in viewer renders an empty value when nobody is
signed in, and that means no reader rather than a malformed one. Those three
are the whole lenient list. format, tag and cursor each refuse an empty
value with a 422. That includes ?format=, which is an
error and not a fallback to html, so if you template the format into the URL,
make the template leave the parameter out rather than emit it empty.
Request
Response
200 OK, with Cache-Control: private, max-age=60 when no user_id was provided. A request with a named reader returns Cache-Control: no-store.
articles array with
has_more: false, not a 404. An empty array on its own does not mean the walk
is over: see Paging.
Paging
Paging is a cursor, not a page number. Ask for the first page, then followpage_info.next_cursor until has_more is false:
published_at descending, with a tie-break that keeps articles
published in the same batch in a stable order, so a walk never repeats or skips
an article.
A cursor the API cannot read is a 422 rather than a silent restart from the
first page, so a broken paging loop shows up as an error instead of an infinite
scroll.
Keep your filters the same across a walk. tag, format, limit and pinned
are not carried inside the cursor, so pass each page the same values you passed
the first.
has_more is the only stopping condition. A page can hold fewer articles than
limit, or none at all, while has_more is still true: an article that
cannot be served is dropped from the list rather than failing the whole page
(the single-article endpoint raises instead, see Errors). Loop on
has_more, never on articles.length, or a walk stops at the first page that
dropped one and silently loses everything older.
Filtering by tag
?tag=Match-Report
finds articles tagged match-report. A value that cannot be a tag at all is a
422 rather than an empty page, so a typo does not read as “this club has
written nothing”:
Pinning a featured article
pinned takes the slug of one published article and hoists it to the top of
the first page:
- It is extra. The pinned article is returned in addition to
limit, so?limit=10&pinned=…returns 11 articles on the first page. - It appears exactly once. The pinned article is removed from its natural position in the ordering, on every page of the walk.
- Pass it on every page. The exclusion only happens on requests that carry
pinned. Drop it after the first page and the article shows up a second time in its natural position.
tag filter excludes it. A pinned slug that names
nothing published is ignored, and the page is served normally, because a
club’s homepage names that slug in configuration and the article can be
unpublished long afterwards.
GET /v1/content/articles/
One published article, by slug.Query parameters
The list’s filter and paging parameters have no meaning here and are ignored.
Request
Response
200 OK with Cache-Control: private, max-age=60 when no user_id was provided. A request with a named reader returns Cache-Control: no-store. The article object itself, with no wrapper:
404. So is the exact slug of a
draft, with the same body, so you cannot tell an unpublished article from one
that never existed:
Response body
?format=html (default)
The article carries "format": "html" and one html string: the whole body,
rendered and sanitised when the article was published. Place it in a container
and style it with your own CSS.
These are all the elements it can contain, and the whole set: p, h2, h3,
blockquote, ul, ol, li, br, hr, strong, em, a, img,
figure, figcaption, and one div per video embed. Nothing carries a
class, an id or a style, so your own stylesheet works against the element
names.
Images. A plain image is <img src="…" alt="…" />. One the author
captioned is wrapped:
div carrying the address, and it is
not a player:
<iframe> in the markup we serve you, so this
element renders as nothing in a browser: no player, no placeholder, no
error. If your club publishes video, you have two choices. Take
?format=blocks and render the video_embed block with your
own player, which is the shorter road. Or keep the HTML format and swap these
elements yourself after you insert the body, reading data-src and
data-provider off each one:
data-src is the address the author pasted, unchanged;
Block format lists every shape it can take and how to read
the video id back out of one. Treat data-provider as an open set, and expect
data-src to be absent on an embed whose address was rejected at publish.
Links. Every anchor is stamped rel="noopener noreferrer nofollow" and
target="_blank", whatever the author wrote.
?format=blocks
The article carries "format": "blocks", a contract_version number and a
flat blocks array:
Caching
A successful response withoutuser_id carries Cache-Control: private, max-age=60. A successful response with user_id carries Cache-Control: no-store, even when the key has reader naming disabled or the named reader does not resolve. Refusals carry no cache header.
private is deliberate. The URL contains the key, and revoking or rotating a
key is supposed to take effect at once, which a shared cache still answering
from a minute of stored responses for the old token would quietly undo. If you
want a shared cache in front of the content API, put it on your own side of the
request: call these endpoints from your backend or build step, and cache the
result under your own rules.
Errors
One envelope everywhere, andcode is the stable part. Message wording is not
a contract.
CONTENT_TIER_LOCKED is the refusal from a public article read. Its details object carries the same tier facts as a locked list row, using camelCase names: the required tier, the reader’s tier, the integer-string XP gap, and the scheduled release moment when one applies. memberTierId and memberTier are null below the first rung or when the named reader did not resolve, and unlocksAt is null when there is no scheduled release. The platform withholds the body itself, but the reader’s identity is asserted by the integrator rather than verified by the platform: the id in user_id is resolved to an active member of the key’s organization and nothing more.
A parameter the query schema rejects returns VALIDATION_ERROR with
"message": "Query failed validation" and a details object of
{ formErrors, fieldErrors }. The offending parameter is a key of
fieldErrors, and the messages under it come from the schema library, so read
the key and not the wording. ?format=blocs:
details instead: the tag refusal shown
under Filtering by tag, and an unreadable cursor, which
names itself:
500 with no detail:
500 is specific, and it is the one you are most likely to meet. An article
whose stored render is missing (it was published before the render existed, or
the stored body was lost) cannot be serialised, and the single-article endpoint
says so:
limit.
Where to go next
- Block format for the
?format=blocksshape. - Render a club’s articles for a working page built on both endpoints.