1. Fetch the list
200 OK:
403 here, the Origin you sent is not on the key’s allowlist.
Browsers set that header themselves; curl does not, which is why the example
sets it by hand.
To evaluate the list for a member, add user_id to the URL. The key must have reader naming enabled; an absent, blank or unresolved reader gets the strictest answer for each gated article.
One thing to fix in your head before you render any of this: html is the only
field that arrives as HTML, and the only one sanitised for you. title,
excerpt, author_name, feature_image_url and the block fields src, alt
and caption are plain text an author typed. Every example below sets them
through the DOM (textContent, img.alt = …) rather than pasting them into a
template string, because an author writing Kane's "winner" in an alt box is
enough to break markup built that way, and an author who means harm can do worse
than break it.
2. Fetch one article
The list already contains every article’s body, so a news index needs one request. Fetch a single article when you are rendering its own page, from a route like/news/match-report:
articles wrapper.
Send user_id here too, and send the same one the list carried. This endpoint evaluates the gate the same way the list does, so a call that names no reader gets the strictest answer and a gated article comes back as a 403 for everyone. Leave the parameter out only when nobody is signed in.
3. Render the index in a page
Everything below runs in the browser. The key is publishable, so it belongs in this code.locked branch renders the lock from the list response itself. The required tier name, reader’s tier name, XP gap and unlock moment are all on that row, so the index needs no second call to fill in the lock.
excerpt, feature_image_url and author_name are each nullable, which is
why every one of them is guarded above.
Setting textContent and image.src assigns a value to a property; nothing in
it is ever parsed as markup, so there is no escaping to remember and no way for
a stray quote in a title to close an attribute. append with a string adds a
text node, so the byline is safe the same way.
4. Render an article body
The defaulthtml format is one string, already rendered and sanitised at
publish, so it is the one field you hand to innerHTML. The title beside it is
not, so it goes in as text:
<div data-block="video-embed" data-src="…" data-provider="…">, never an
<iframe>, so a browser shows nothing where it sits. If the club publishes
video, take the block format below, or swap those elements for your own player
after the line that sets innerHTML. Articles has the
element and the swap.
5. Or render your own media components
Ask for?format=blocks when you want your own image and video components
instead of our markup. Text blocks still arrive as HTML fragments, so only the
two media arms are yours to write:
block.html is the only value here that goes anywhere near innerHTML, and it
is the only one sanitised at publish. alt and caption are the author’s own
text, which is why they are set as properties instead.
Every block type and its fields are on Block format,
including how to read a video id out of block.src for your own player.
6. Page through the archive
An archive page followsnext_cursor until has_more is false. Pass the
cursor back exactly as you received it, and pass the same filters on every
request:
has_more, not on how many articles came back. A page can
hold fewer than limit (an article that cannot be served is dropped rather than
failing the page), so stopping on a short page would silently cut the archive
off there.
Each article carries a whole rendered body, so a page of 50 is a large
response. Prefer the default limit of 10 for anything a reader waits on, and
save the full walk for a build step.
7. A featured article and a tag filter
A homepage usually wants one article pinned to the top and a section index wants one tag:limit and is removed from its
natural position, so it appears exactly once. If you page beyond the first
page, pass pinned on every request or it appears a second time. A pinned
slug that names nothing published is ignored and the page is served normally,
so a homepage does not break when the featured article is unpublished.
8. Report the read
The page now renders an article. One more call tells the platform that a member read it, which raises that member’s affinity for the tags the article carries and puts the article on the club’s analytics screen:- It takes the other key. Reading articles uses a content key (
ck_live_…); reporting an interaction rides the engagement rail and uses a publishable tracking key (ek_live_…). Both are publishable, and neither works in the other’s place. - It takes the id, not the slug.
article.idis in every response above. - It takes no tags, and offers no way to send any. The server reads them off the article, so the affinity a read raises is for the tags your marketer chose in the editor.
- It needs a signed-in reader.
userIdis the reader’s platform user id. There is no anonymous path, so a logged-out visitor is not counted. Passing an empty string is safe: the call is dropped without a request, and it is dropped even when you have calledidentify(), so a read after the reader signs out is never filed under the reader who signed in before.
reportInteraction never throws into your page and never blocks the render. A
failed report is reported through the client’s onError callback and nowhere
else, and a read made as the reader leaves goes out with navigator.sendBeacon
rather than being lost. A refresh does not inflate anything: a read counts once
per member, per article, per day, and a repeat of a report that is still in
flight (an effect firing on every render, say) is dropped rather than sent.
If reads for one article stay at zero while others climb, check that the article
has tags. An article published without any cannot produce an interaction event at
all, and the call answers content_untagged.
The engagement SDK reference has
every refusal reason and the delivery table. Interactions never earn XP.
Caching
Responses withoutuser_id carry Cache-Control: private, max-age=60, so an anonymous browser can reuse them for a minute. A request with user_id carries Cache-Control: no-store, even when the key does not opt in or the reader does not resolve. It is deliberately not public: the key is in the URL, and revoking a key has to take effect at once. If you want a shared cache, call these endpoints from your backend or build step and cache the result under your own rules.
Where to go next
- Articles for every parameter and every error response.
- Block format for the
?format=blocksshape. - Engagement SDK for reporting reads, shares and likes.