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

# Content API authentication

> A publishable ck_live_ key in the query string, with a per-key origin allowlist as the real control

The content endpoints do not use your organization API key. They take a
**content key**, a fourth credential type alongside the three on
[Authentication](/authentication):

| Credential  | Prefix     | Where it lives            | What it's for                          |
| ----------- | ---------- | ------------------------- | -------------------------------------- |
| Content key | `ck_live_` | Client code (publishable) | `GET /v1/content/articles…` reads only |

You create, rotate and revoke content keys in the console, on the integrations
page, along with the list of origins each key may be used from.

## The key is publishable

The key ships in your website's source, where anybody can read it. That is
intended, not an oversight: a browser fetching articles on your public site
cannot hide a secret, so the key is not treated as one. It is returned in full
at creation and remains viewable in the console afterwards.

What makes that safe is the **per-key origin allowlist**. A request is served
only when its `Origin` matches an origin you registered for that key, so a copy
of the key lifted from your page is of no use anywhere else. Rate limiting is
the second control. Key secrecy is not a control at all.

<Warning>
  Do not put an `org_` organization API key in a web page in order to read
  articles. It would not work here (the content endpoints accept `ck_live_` keys
  and nothing else) and it would expose your whole partner API surface.
</Warning>

## Presenting the key

The key travels as the `key` query parameter, the same transport
`POST /v1/track` uses, because a `<script>` tag can build a URL and cannot
always set a header:

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

There is no organization id anywhere in the request. The key resolves to
exactly one organization, so a key can only ever read its own club's articles.

## Naming a reader

Add `user_id` to either article endpoint when the response should be evaluated for a member:

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

The value is the member's platform user id, not an id from your own database. It must be a UUID. An empty or whitespace-only value is treated as if `user_id` was not sent.

Reader naming is an opt-in on each content key and is off by default. A platform operator enables it for the key that should receive member-specific answers. A key with the setting off treats `user_id` as absent, so the response is the strictest answer for a gated article.

The platform enforces the tier decision and withholds the article body when the resolved reader does not qualify. The identity is asserted by the integrator: the platform does not verify that the caller is the person represented by the supplied id. It only resolves that id to an active member of the key's organization. A missing reader, an unknown id, a member in another organization, or an inactive member receives the strictest answer, which is the locked response for a gated article.

When a request names a reader, a successful response is sent with `Cache-Control: no-store`, including when reader naming is disabled or the id does not resolve. This keeps one member's tier result out of another response's cache. Anonymous successful responses keep `Cache-Control: private, max-age=60`; refusals carry no cache header.

## The Origin header is required

Every request must carry an `Origin` header naming a registered origin. This
holds for server-side callers too: there is no exemption for a request that is
not from a browser, because an exemption would make every published key
readable from anywhere.

Browsers set `Origin` themselves. If you call these endpoints from your own
backend, set it explicitly, as the `curl` example above does.

Matching is on the whole origin (scheme, host and port), after lowercasing and
dropping default ports. `https://www.yourclub.example` and
`https://yourclub.example` are different origins; register both if you serve
both. There are no wildcards.

A development origin is an entry like any other, so register it too while you
are building: `http://localhost:5173` is `http`, not `https`, and its port is
part of it, so it matches nothing you registered for the live site. Add the one
your dev server actually prints, port included, and drop it from the allowlist
when you are done.

## Rotation takes effect immediately

Rotating a key revokes the previous token in the same operation. The old token
stops working on the next request, with no grace period. Deploy the new key to
your site before you rotate, or expect a gap.

## Cross-origin requests

The endpoints answer a CORS preflight from any origin and echo the request
origin back on the real `GET`, including on refusals. A preflight carries no
key, so there is nothing to enforce it against; the allowlist is applied to the
actual read. The practical consequence is a good one: a request from an
unregistered origin fails as a readable `403` your code can log, rather than as
an opaque browser CORS error.

## What each failure looks like

Every response below is the standard error envelope, and `code` is the stable
part. Refusals carry no `Cache-Control` header.

### No key, an unknown key, a revoked key, or a key of another type

All four are the same `401`, on purpose: a prober cannot learn which check
failed, or whether a given token ever existed.

```json theme={null}
{
  "error": {
    "code": "UNAUTHORIZED",
    "status": 401,
    "message": "unknown content key"
  }
}
```

### An origin that is not on the key's allowlist

`403`, whether or not the key itself was valid. A missing `Origin` header, an
empty allowlist and a non-matching origin all land here.

```json theme={null}
{
  "error": {
    "code": "FORBIDDEN",
    "status": 403,
    "message": "Origin is not allowed for this content key"
  }
}
```

### The content module is not installed

Also a `403`, with a different message. The club's articles still exist; the
API is switched off until the module is reinstalled.

```json theme={null}
{
  "error": {
    "code": "FORBIDDEN",
    "status": 403,
    "message": "The content module is not installed for this organization"
  }
}
```

### Too many requests

Rate limiting is per key, shared across both endpoints, at 600 requests per
minute in a fixed window.

```json theme={null}
{
  "error": {
    "code": "RATE_LIMITED",
    "status": 429,
    "message": "Content read rate limit exceeded for this key"
  }
}
```

The full error list, including validation and not-found responses, is on
[Articles](/content/articles).
