Skip to main content
This walkthrough builds a page that lists a club’s published articles and renders one of them, using a publishable content key from the browser. It assumes the content module is installed and you hold a key with your origin on its allowlist; see Authentication. Set up the values you need:

1. Fetch the list

200 OK:
If you get a 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:
The response is the article object on its own, with no 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.
The 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 default html 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:
The reader goes on this URL as well. Without it the gate resolves no member, and a gated article refuses even for a member who is entitled to read it, so the locked branch would paint a lock over a page that should have opened. That renders the whole body except a video. A video embed arrives as an empty <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 follows next_cursor until has_more is false. Pass the cursor back exactly as you received it, and pass the same filters on every request:
The loop stops on 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:
The pinned article is returned in addition to 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:
Four things about that call:
  • 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.id is 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. userId is 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 called identify(), 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 without user_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