Skip to content

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.

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.

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/resolve
Authorization: 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.

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.

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.

gated-content.ts
// 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: string
declare 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.kind is signIn for verified access, form (with a formId) when an open audience names a form, or none when it names no way in.
  • unlock is a normal page result, localised and personalised from your ctx. 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.

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.

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.

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