Serve pages only to members
Editors can restrict a whole site, a section or a single page to an audience: the members of a
group such as “customers” or “whitepaper readers”. The API enforces it. A request for a restricted
page from a visitor who is not a member is answered with 403 members_only, and the page’s content,
title and address are not in the response.
Personalization is not access control. A personalized variant only changes what a page says, and with client-resolve every variant reaches the browser. Use a restriction when a visitor must not see the page at all.
What an editor sets
Section titled “What an editor sets”In Composer, a page’s Visitor access card offers three choices:
| Choice | Meaning |
|---|---|
| Follow the parent | The page is restricted if the closest page above it with a rule is. The site root’s rule applies to the whole site. |
| Open to everyone | Overrides a restriction above, so one page in a members-only section can be public. |
| Restrict to an audience | Only members of the chosen audience may read the page and everything under it that follows it. |
A restricted page also has an unlock page: an Experience, built on the platform’s Unlock page Contract, that visitors without access see in its place. A page can name its own, with different text per language, or fall back to the site’s default, or to a plain built-in message. The unlock page is always public, even when it sits inside a restricted section.
An audience decides how strict its members’ access is. Under open membership, being in the audience is enough, and the audience can name a form that makes a visitor a member. Under verified access, a member must also prove they hold their email address by following a link sent to it. Anyone can type anyone else’s address into a form, so use verified access for anything that matters.
How a visitor is recognised
Section titled “How a visitor is recognised”Your site does not tell the Content API who a visitor is. The Audience delivery API returns, for every visitor, a signed token of which audiences they are in, and Content trusts that and
nothing else: an audience list you assemble yourself, in ctx or anywhere, never unlocks a restricted page (ctx refuses audiences with 400 audiences_not_in_ctx).
The call is POST /resolve:
POST /audience/delivery/v1/resolveAuthorization: Bearer <Audience delivery key>
{ "visitor": "<the device's visitor token>", "consent": { "cmp": "granted" } }The answer holds access.token, access.expiresAtUtc and the grants. A visitor nobody recognises still gets a token, holding only audiences worked out from context, and the right thing to
show them is the unlock page. A token lasts 15 minutes, and each renewal re-reads membership, so removing a person from an audience takes effect within one lifetime.
Only a stored member can read a restricted page. An audience worked out from the request’s own context, such as a locale, is exactly what a caller could assert, so its grant (tagged
context) never opens a page. An audience with verified access is granted only while the visitor also holds a live proof of an address the person holds.
With the SDK, nothing to wire
Section titled “With the SDK, nothing to wire”Give the content client an audience option. It asks which audiences your content uses, asks Audience about exactly those, keeps the token in memory, renews it, and sends it on every read.
A Forms submission, a sign-in link followed in another tab, the tab returning after a pause, and, for as long as a link you asked for is pending (up to 15 minutes), a look every few seconds each refresh it, so a link opened on a phone unlocks the page on the desktop with no reload (useSignIn does this by itself; useAudienceWatch(active) and audience.watch() do it for a wait of your own), and <Experience> re-reads by itself. Your code renders the pages it has; it
does not handle gating. When a verification link is opened on another device, the page it leads to can verify that browser too: the “Continue” link carries a one-time code in the URL fragment (#ebitex-verify=<code>), which the client reads, removes from the address bar and redeems for the browser’s own visitor, so the page works there with no extra code (audience: { handoff: false } turns it off; audience.redeemHandoff(code) is there if you read the code yourself). On a server, content.forVisitor({ visitor, consent }) is a client for one request’s visitor.
Handle the 403
Section titled “Handle the 403”Send the token in the X-Ebitex-Audiences header on GET /path and
GET /nodes. A header that is missing, expired, forged, or for another
organization is the same as holding no audience; a response header, X-Ebitex-Audiences-Status, says expired or invalid so a client renews.
// Serving a page only to the visitors it is restricted to. Give the client an `audience` option and it does the rest: it asks which audiences your content uses, asks Audience// which of them the visitor is in, keeps the signed answer, renews it, and sends it with every read. Nothing about gating is yours to wire.import { createContentClient, membersOnlyOf, type MembersOnlyBody } from '@ebitex/content-sdk'
declare const DELIVERY_KEY: stringdeclare const AUDIENCE_DELIVERY_KEY: string
// Run this on your server. One shared client; each request gets its own view of it for the visitor it serves.const content = createContentClient({ apiKey: DELIVERY_KEY, site: 'main', locale: 'en', audience: { apiKey: AUDIENCE_DELIVERY_KEY } })
export type Rendered = { status: 200; page: unknown } | { status: 403; unlock: MembersOnlyBody }
export async function render(path: string, visitorToken: string): Promise<Rendered> { // A client made for a visitor never shares its cache with another, so a member's page cannot reach anyone else. const visitor = content.forVisitor({ visitor: visitorToken, consent: { cmp: 'granted' } })
try { const page = await visitor.resolveLocation(path) return { status: 200, page } } catch (error) { // The visitor may not read this page. The response says which door to offer and carries the unlock page to show them. It never // contains the restricted page itself: not its title, its address or any of its content. const gated = membersOnlyOf(error) if (gated) return { status: 403, unlock: gated } throw error } finally { visitor.audience?.dispose() }}
// A visitor at a verified-access audience asks for a sign-in link. The answer is the same for every address, so show it as it is.export async function requestSignIn(email: string, visitorToken: string, audienceId: string): Promise<string> { const visitor = content.forVisitor({ visitor: visitorToken }) try { const { message } = await visitor.audience!.signIn(email, { audienceId, returnUrl: 'https://www.example.com/members' }) return message } finally { visitor.audience?.dispose() }}
// The navigation a member sees includes the restricted pages they may read; everyone else's leaves them out.export function navigationFor(visitorToken: string) { return content.forVisitor({ visitor: visitorToken }).getTree()}The 403 body names the door to offer and carries the unlock page, resolved exactly as GET /path
would have returned it:
{ "error": "members_only", "gate": { "audienceId": "…", "access": "verified", "door": { "kind": "signIn" } }, "unlock": { "kind": "presentation", "nodeId": "…", "path": "/unlock", "presentation": { } }}door.kindissignInfor verified access,form(with aformId) when an open audience names a form, ornonewhen it names no way in.unlockis a normal page result, localised and personalised from yourctx. When no unlock page can be served it is{ "kind": "builtin", "heading", "message", "doorLabel" }, in English.
A restricted page is never in a sitemap, in include=paths listings, or in /nodes for a visitor who
may not read it. Every response that depends on a token is private, no-store, so a shared cache can
never hand one visitor’s page to another. Do not cache these responses in your own layer either; the
SDK’s content client keeps a separate cache for each set of audiences and never persists a member’s page.
Sign in without a password
Section titled “Sign in without a password”For verified access, the unlock page offers a sign-in. Ask for a link with
POST /sign-in, passing the address, the visitor token, and optionally the
audience and a returnUrl. The link works once and expires in 15 minutes. Following it and pressing the
button on the page it opens proves the address for that device, and the next token includes the
audience.
The answer is 202 with the same message for every address, whether it is unknown, a non-member, or
throttled, so the route cannot be used to find out who is a member. At most three links are sent to one
address in an hour. When the device has granted recognition the proof is remembered for 30 days;
otherwise it lasts the session, never more than four hours.
returnUrl must be an https address (or http on localhost) without credentials, and from a browser
it must be the page’s own origin. The page offers it as a link naming the site; it is never followed
automatically.
In .NET
Section titled “In .NET”PathOptions.AudienceToken and NodesOptions.AudienceToken send the token, AudienceClient.GetAccessAsync fetches it (one call, never throws into your page) and StartSignInAsync requests a sign-in link, and
ContentDeliveryException.MembersOnly carries the typed body of a 403 members_only: the gate, and either the unlock page to render or the built-in message.
See Render content from a .NET server for setting up the client.
Limits worth knowing
Section titled “Limits worth knowing”- A token carries at most 64 audiences, and a request that would need more is refused (
400 too_many_audiences), never truncated. Ask only about the audiences Content uses. - Turning on verified access for an audience that already has members locks them out until they prove their email, and members who hold no email address stay locked out. The Audience app shows the count before you confirm.
- A restriction protects the page, not the things it points at. Content written inline in a restricted page is protected with it, but a shared Component the page references can still be read
by anyone who knows its id through
GET /components/{provider}/{key}, and so can a file in the Assets API. Keep anything that must stay private inline in the page.