Skip to main content
Both endpoints need a publishable content key and a registered 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.
A club with nothing published gets an empty 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 follow page_info.next_cursor until has_more is false:
The order is by 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.
next_cursor is opaque. It is a string to hand back verbatim and nothing else: do not parse it, decode it, construct one, or store it as a permanent bookmark. Its internal format is not part of this contract and can change.
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

The value is trimmed and lowercased before matching, so ?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”:
One tag per request. There is no multi-tag or exclusion filter.

Pinning a featured article

pinned takes the slug of one published article and hoists it to the top of the first page:
Three behaviours worth knowing:
  • 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.
A pinned article must satisfy the same filters as the rest of the page: it is not returned if the 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:
A slug that names no published article is a 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:
Video embeds. A video is an empty div carrying the address, and it is not a player:
We do not put a third-party <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:
Every block type and the fields it carries are on Block format.

Caching

A successful response without user_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, and code 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:
Two refusals are raised by the read itself rather than by the schema, so they carry their own message and a flat details instead: the tag refusal shown under Filtering by tag, and an unreadable cursor, which names itself:
An unexpected server failure is a generic 500 with no detail:
One 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:
The fix is in the console: republish that article. The list endpoint handles the same article differently, dropping it from the page instead of failing the whole request, which is why a page can be shorter than limit.

Where to go next