> ## Documentation Index
> Fetch the complete documentation index at: https://docs.fourdos.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Articles

> GET /v1/content/articles and GET /v1/content/articles/{slug} - parameters, responses, paging, and errors

Both endpoints need a publishable content key and a registered `Origin`; see
[Authentication](/content/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](#response-body).

| Field               | Type                   | Notes                                                                                                                                |
| ------------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| `id`                | string                 | Stable id. Key your lists on this, not on the slug                                                                                   |
| `slug`              | string                 | What you build your article URLs from                                                                                                |
| `title`             | string                 |                                                                                                                                      |
| `excerpt`           | string or null         | Short summary, when the author wrote one                                                                                             |
| `feature_image_url` | string or null         | The article's lead image                                                                                                             |
| `tags`              | array of strings       | Trimmed and lowercased when written, and each one matches `^[a-z0-9][a-z0-9_.:-]{0,63}$`, so `.` and `:` are ordinary tag characters |
| `author_name`       | string or null         | The byline. Null when the author has left the club or set no name                                                                    |
| `published_at`      | string                 | ISO 8601 with timezone. What the list is ordered by                                                                                  |
| `updated_at`        | string                 | ISO 8601 with timezone                                                                                                               |
| `format`            | `"html"` or `"blocks"` | Which body shape this object carries                                                                                                 |

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.

| Field               | Type                   | Notes                                                                                     |
| ------------------- | ---------------------- | ----------------------------------------------------------------------------------------- |
| `id`                | string                 | Stable article id                                                                         |
| `slug`              | string                 | Article slug                                                                              |
| `title`             | string                 | Article title                                                                             |
| `excerpt`           | string or null         | Always `null` while the row is locked                                                     |
| `feature_image_url` | string or null         | Always `null` while the row is locked                                                     |
| `tags`              | array of strings       | Article tags                                                                              |
| `author_name`       | string or null         | The byline, when one is available                                                         |
| `published_at`      | string                 | ISO 8601 timestamp                                                                        |
| `updated_at`        | string                 | ISO 8601 timestamp                                                                        |
| `format`            | `"html"` or `"blocks"` | The format requested by the caller; it does not add a body key                            |
| `locked`            | `true`                 | Discriminator for the locked row                                                          |
| `required_tier_id`  | string                 | Id of the tier required to open the article                                               |
| `required_tier`     | string                 | Name of the tier required to open the article                                             |
| `member_tier_id`    | string or null         | The reader's tier id, or `null` below the first rung or when the reader did not resolve   |
| `member_tier`       | string or null         | The reader's tier name, or `null` below the first rung or when the reader did not resolve |
| `xp_gap`            | string                 | Integer XP minor units still needed to reach the required tier                            |
| `unlocks_at`        | string or null         | ISO 8601 release moment, or `null` when no scheduled release applies                      |

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

| Parameter | Default  | Notes                                                                                                                                                |
| --------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| `key`     | required | Your `ck_live_…` content key                                                                                                                         |
| `format`  | `html`   | `html` or `blocks`. Any other value, including an empty one, is a `422`                                                                              |
| `user_id` | none     | The reader's platform user id. Blank or whitespace is absent; a non-UUID value is a `422`. It is used only when reader naming is enabled for the key |
| `limit`   | `10`     | Page size, clamped into `1`-`50`. A value that is not a number is a `422`                                                                            |
| `tag`     | none     | Filter to articles carrying this tag                                                                                                                 |
| `pinned`  | none     | Slug of one article to hoist to the top of the first page                                                                                            |
| `cursor`  | none     | Opaque cursor from the previous page's `next_cursor`                                                                                                 |

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

```bash theme={null}
curl "$API_BASE_URL/v1/content/articles?key=$CONTENT_KEY&limit=1" \
  -H "Origin: https://www.yourclub.example"
```

### 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`.

```json theme={null}
{
  "articles": [
    {
      "id": "10000001-0000-7000-8000-000000000000",
      "slug": "match-report",
      "title": "Match report",
      "excerpt": "We won",
      "feature_image_url": null,
      "tags": ["match-report"],
      "author_name": null,
      "published_at": "2026-07-20T10:00:00.000Z",
      "updated_at": "2026-07-21T09:00:00.000Z",
      "format": "html",
      "html": "<p>We won.</p>"
    }
  ],
  "page_info": { "next_cursor": null, "has_more": false }
}
```

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

Paging is a cursor, not a page number. Ask for the first page, then follow
`page_info.next_cursor` until `has_more` is `false`:

```bash theme={null}
# First page
curl "$API_BASE_URL/v1/content/articles?key=$CONTENT_KEY" -H "Origin: $ORIGIN"

# Next page, passing back the next_cursor the first page returned
curl "$API_BASE_URL/v1/content/articles?key=$CONTENT_KEY&cursor=MjAyNi0wNy0yMFQxMDowMDowMC4wMDBafDEwMDAwMDAxLTAwMDAtNzAwMC04MDAwLTAwMDAwMDAwMDAwMA" \
  -H "Origin: $ORIGIN"
```

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.

<Warning>
  `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.
</Warning>

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](#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

```bash theme={null}
curl "$API_BASE_URL/v1/content/articles?key=$CONTENT_KEY&tag=match-report" \
  -H "Origin: https://www.yourclub.example"
```

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":

```json theme={null}
{
  "error": {
    "code": "VALIDATION_ERROR",
    "status": 422,
    "message": "Invalid tag \"match report\": tags must match ^[a-z0-9][a-z0-9_.:-]{0,63}$ after trim and lowercase",
    "details": { "tag": "match report" }
  }
}
```

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:

```bash theme={null}
curl "$API_BASE_URL/v1/content/articles?key=$CONTENT_KEY&pinned=cup-final-preview" \
  -H "Origin: https://www.yourclub.example"
```

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/{slug}

One published article, by slug.

### Query parameters

| Parameter | Default  | Notes                                                                                                                                                |
| --------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| `key`     | required | Your `ck_live_…` content key                                                                                                                         |
| `format`  | `html`   | `html` or `blocks`. Any other value is a `422`                                                                                                       |
| `user_id` | none     | The reader's platform user id. Blank or whitespace is absent; a non-UUID value is a `422`. It is used only when reader naming is enabled for the key |

The list's filter and paging parameters have no meaning here and are ignored.

### Request

```bash theme={null}
curl "$API_BASE_URL/v1/content/articles/match-report?key=$CONTENT_KEY" \
  -H "Origin: https://www.yourclub.example"
```

### 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:

```json theme={null}
{
  "id": "10000001-0000-7000-8000-000000000000",
  "slug": "match-report",
  "title": "Match report",
  "excerpt": "We won",
  "feature_image_url": null,
  "tags": ["match-report"],
  "author_name": null,
  "published_at": "2026-07-20T10:00:00.000Z",
  "updated_at": "2026-07-21T09:00:00.000Z",
  "format": "html",
  "html": "<p>We won.</p>"
}
```

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:

```json theme={null}
{
  "error": {
    "code": "NOT_FOUND",
    "status": 404,
    "message": "No published article was found for this slug",
    "details": { "slug": "not-a-real-slug" }
  }
}
```

## 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:

```html theme={null}
<figure>
  <img src="https://images.example.com/matchday.jpg" alt="The winning goal going in" />
  <figcaption>The winner, 88 minutes</figcaption>
</figure>
```

**Video embeds.** A video is an empty `div` carrying the address, and it is
**not a player**:

```html theme={null}
<div
  data-block="video-embed"
  data-src="https://www.youtube.com/watch?v=aqz-KE-bpKQ"
  data-provider="youtube"
></div>
```

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`](/content/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:

```js theme={null}
container.innerHTML = article.html;
for (const el of container.querySelectorAll('[data-block="video-embed"]')) {
  el.replaceWith(myVideoPlayer(el.dataset.src, el.dataset.provider));
}
```

`data-src` is the address the author pasted, unchanged;
[Block format](/content/blocks) 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:

```json theme={null}
{
  "slug": "match-report",
  "format": "blocks",
  "contract_version": 1,
  "blocks": [{ "type": "paragraph", "html": "<p>We won.</p>" }]
}
```

Every block type and the fields it carries are on
[Block format](/content/blocks).

## 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.

| Status | `code`                | When                                                                                                                                                                                                                                                                                          |
| ------ | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `401`  | `UNAUTHORIZED`        | No key, an unknown key, a revoked key, or a credential of another type                                                                                                                                                                                                                        |
| `403`  | `FORBIDDEN`           | The `Origin` is not on the key's allowlist, or the content module is not installed                                                                                                                                                                                                            |
| `403`  | `CONTENT_TIER_LOCKED` | The published article is gated and the resolved reader does not meet its requirement. Read `error.details.slug`, `error.details.requiredTierId`, `error.details.requiredTier`, `error.details.memberTierId`, `error.details.memberTier`, `error.details.xpGap` and `error.details.unlocksAt`. |
| `404`  | `NOT_FOUND`           | No published article has that slug (including a draft's slug)                                                                                                                                                                                                                                 |
| `422`  | `VALIDATION_ERROR`    | A blank or unknown `format`, a non-numeric `limit`, a blank or malformed `tag`, a `cursor` the API cannot read, or a non-UUID `user_id`                                                                                                                                                       |
| `429`  | `RATE_LIMITED`        | The per-key window is spent (600 requests per minute, shared across both endpoints)                                                                                                                                                                                                           |
| `500`  | `INTERNAL_ERROR`      | An unexpected server failure, or an article with no stored render on the single-article endpoint                                                                                                                                                                                              |

`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`:

```json theme={null}
{
  "error": {
    "code": "VALIDATION_ERROR",
    "status": 422,
    "message": "Query failed validation",
    "details": {
      "formErrors": [],
      "fieldErrors": {
        "format": ["Invalid option: expected one of \"html\"|\"blocks\""]
      }
    }
  }
}
```

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](#filtering-by-tag), and an unreadable cursor, which
names itself:

```json theme={null}
{
  "error": {
    "code": "VALIDATION_ERROR",
    "status": 422,
    "message": "cursor is not a valid pagination cursor",
    "details": { "cursor": "page-2" }
  }
}
```

An unexpected server failure is a generic `500` with no detail:

```json theme={null}
{
  "error": {
    "code": "INTERNAL_ERROR",
    "status": 500,
    "message": "Unexpected API error"
  }
}
```

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:

```json theme={null}
{
  "error": {
    "code": "INTERNAL_ERROR",
    "status": 500,
    "message": "This article carries no stored render and cannot be served. Re-publish it.",
    "details": { "slug": "match-report" }
  }
}
```

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

* [Block format](/content/blocks) for the `?format=blocks` shape.
* [Render a club's articles](/walkthroughs/render-club-articles) for a working
  page built on both endpoints.
