Using the PlayerPaywall & Entitlement

Paywall & Entitlement

How the PanelWave Player gates content — manifest paywall rules, the entitlement snapshot you supply, the paywall overlay and age gate, and the lower-level adapter interfaces.

The player never processes payments. The manifest declares what is gated (paywall.rules); your application tells the player what the reader owns; the player evaluates the rules locally, stops navigation at a gate and shows the paywall overlay (or the age gate). Checkout, sign-in and accounts stay entirely in your app.

Client-side gating is a reading-experience layer, not a security boundary. A determined reader can edit client state. Anything that must not leak — full-resolution art of paid chapters, downloads — has to be withheld by your backend (for example with signed URLs) until the reader is entitled — or left out of the manifest altogether as server-stripped panels. See Monetization.

The entitlement snapshot

Everything the player needs to know about the reader fits in one object:

import type { EntitlementSnapshot } from '@panelwave/player';

const snapshot: EntitlementSnapshot = {
  subscriptionTier: 'supporter',        // active tier, or null when not subscribed
  purchasedProductIds: ['chapter-2'],   // products the reader bought
  ageVerified: true,                    // did your account system verify the age?
  age: 34,                              // optional, used by age gates
};

Without a snapshot the player treats the reader as anonymous (ANONYMOUS_READER: no tier, no purchases, not age-verified) — they see the free preview and the gate. A work without paywall rules is unaffected either way.

If your app already knows what the reader owns, bind it directly:

<pw-player-shell
  [manifestUrl]="url"
  [entitlementSnapshot]="snapshot"
  (paywallAction)="onPaywall($event)" />

Option 2: let the player fetch it

Give the shell an endpoint and the reader's token; it fetches the snapshot once at startup:

<pw-player-shell
  [manifestUrl]="url"
  entitlementEndpoint="https://api.example.com/works/{workId}/entitlement"
  [readerToken]="sessionToken"
  (paywallAction)="onPaywall($event)" />
  • {workId} is replaced with the manifest's meta.id.
  • The request carries Authorization: Bearer <readerToken> when a token is set.
  • The endpoint answers with { "ok": true, "data": { "subscriptionTier": …, "purchasedProductIds": […], "ageVerified": …, "age"?: … } }.
  • If the request fails, the reader stays anonymous — a backend outage shows the gate, never paid content.
  • An explicit entitlementSnapshot wins over the endpoint when both are set.

The same logic is available outside the shell as the exported HttpEntitlementAdapter (config: endpoint, readerToken, optional signedUrlEndpoint for getSignedUrl(), ttlMs — snapshots stay fresh for 5 minutes by default).

How rules are evaluated

The player normalizes each manifest rule and checks it against the snapshot. Manifests carry two generations of field names side by side (the original format's and the CMS exporter's); both work:

RequirementWritten asSatisfied when
SubscriptionentitlementType: "subscription" (+ optional subscriptionTiers), or requireEntitlement: "premium"The reader has a tier — any tier, or one listed in subscriptionTiers
PurchaseentitlementType: "purchase" (+ optional requiredProductIds, a schema field since format 1.6), or any other requireEntitlement valueThe reader bought any one of the listed products (or anything, when none are listed)
Age gateentitlementType: "age_gate", or a rule with minimumAge / ageGate and no other requirementageVerified is true and age ≥ the minimum (default 18)
FreeentitlementType: "free"Always

An age on a subscription or purchase rule (ageGate / minimumAge next to requireEntitlement or entitlementType) is an extra check: the reader needs both. The player asks for the age first (lockReason: "age_verification_required"), then shows the paywall with its Buy / Subscribe options (purchase_required / subscription_required) until the entitlement is there.

Which panels a rule gates:

  • Panel rules (scope: "panel") gate exactly their targetPanelIds (or their refId). They take precedence, and a free preview never unlocks them.
  • Chapter rules (scope: "chapter") gate only the panels of the chapter named in refId. previewPanelCount / previewPanels is counted within that chapter (its panels in declaration order), so previewPanelCount: 2 keeps the chapter's first two panels free. For its panels a chapter rule wins over a work rule.
  • Work rules (scope: "work" or "global") gate the whole work. The first one wins. previewPanelCount / previewPanels keeps the first n panels readable, counted in reading order across the whole work (chapters in order, panels in declaration order).
  • Extras rules (scope: "extras") never gate panels. They lock the extras block named in refId: the extras viewer shows that block with a lock and does not open it until the reader satisfies the rule.

Precedence for a panel's purchase or subscription requirement: panel rule → chapter rule for its chapter → work rule → free.

Age requirements combine across rules (from the player release after 1.2.0). Every rule that applies to a panel and names an age must be met — its panel rules, its chapter's rules and every work rule, each outside its own free preview — whichever rule decides the purchase or subscription part. A chapter preview or a panel-scoped free rule therefore never skips a work-wide age gate. When several age requirements are unmet, the age gate asks for the highest minimum age, so one answer clears them all. (In 1.2.0 the first matching rule decided everything, age included.)

"paywall": {
  "rules": [
    { "id": "work-sub", "scope": "work", "requireEntitlement": "subscription", "previewPanelCount": 6 },
    { "id": "ch3-buy", "scope": "chapter", "refId": "ch-3", "requireEntitlement": "chapter-3",
      "name": "Chapter 3", "price": { "amount": 2.99, "currency": "EUR" }, "previewPanelCount": 1 },
    { "id": "art-book", "scope": "extras", "refId": "concept-art", "requireEntitlement": "art-book" }
  ]
}

Here the first six panels of the work are free and the rest needs a subscription — except chapter ch-3: its first panel is free and its other panels need a purchase (a subscription does not unlock them, because the chapter rule wins for its panels). The concept-art extras block needs a purchase too; it never blocks reading.

chapter-3 and art-book are not entitlement type names, so both rules are purchase gates. Without requiredProductIds, a purchase gate is satisfied by any product in purchasedProductIds — pass only the purchases that belong to this work in the snapshot. The key still names the product the overlay offers (see below). To require specific products, list them in requiredProductIds (format 1.6+, e.g. "requiredProductIds": ["chapter-3", "complete-edition"]): then only those unlock the rule, any one of them is enough. The CMS exports every unlocking product this way.

To check an extras block yourself, use PaywallService.isExtraLocked(extraId) or the pure isExtraLocked(rules, snapshot, extraId). To check a panel the way the player's renderers do (from the release after 1.2.0), use PaywallService.isPanelLocked(panelId) and re-check whenever PaywallService.changes$ fires — see Core Services.

What readers see at a gate

When the reader tries to move onto a gated panel, navigation stops — they stay on the last panel they were entitled to see — and the player shows:

  • the age gate for age-gate rules (see below), or
  • the paywall overlay for everything else: a title for the gated scope, the reason (e.g. "This part of the story is included with a subscription."), the Buy / Subscribe options the rule offers, Sign in and Maybe later. All texts, including the reason, are translated (English and German ship with the package).

While the paywall or the age gate is open, the story behind it does not move: arrow keys, T and swipes are ignored. Escape still works — it dismisses the paywall (like Maybe later) and closes the age gate, leaving the reader on the panel they were on.

Locked panels on screen

From the player release after 1.2.0, a gated panel stays locked wherever it would be rendered, not only when the reader moves onto it (in 1.2.0, page and canvas view showed gated panels in full):

  • Every view shows a locked placeholder — a lock with "This panel is part of the full edition." (player.locked.* keys) — in place of each panel the reader's snapshot does not open. The other panels of a page stay readable. Placeholders update the moment a lock lifts (age confirmed, refreshEntitlements(), a new entitlementSnapshot).
  • In page view a placeholder is a button (click, Enter or Space; labelled "Unlock this panel"). It raises the gate for that panel in place: the age gate when only the age is missing, otherwise the paywall with that panel's Buy / Subscribe options. Turning onto (or switching to) a page with age-locked panels asks for the age once; confirming unlocks them in place.
  • The entry panel is gated too. On the initial load the reader lands on the entry panel with the age gate or the paywall open over its placeholder; confirming the age reveals it in place. A resumed bookmark or an initialPanelId on a gated panel behaves the same, and a chapter jump from the table of contents into a gated entry stops like any other move — the gate opens and the reader stays where they were.
  • The thumbnail strip and the table of contents show no artwork for locked panels (the strip marks them with a lock).
  • Audio of a gated panel stays silent until the lock lifts, and the preloader does not fetch a locked panel's assets ahead of the gate.

While a host entitlementAdapter decides access, the manifest rules do not lock rendering — see the warning there.

Buy and Subscribe options

The options come from the rule that blocks the panel:

RuleOptions
PurchaseOne Buy option per entry in requiredProductIds (format 1.6)
SubscriptionOne Subscribe option per entry in subscriptionTiers
Neither list setOne option whose product id is the rule's requireEntitlement key — or the rule id when the key is just the type name ("purchase", "subscription")
Age gate, freeNo options (age gates use the age gate instead)

Each option is labelled from the manifest's paywall.products (format 1.6): the entry with the option's id supplies its name and description — in the reader's locale, falling back to the work's default locale — and its price. Options without an entry fall back to the rule's name (a single option) or the product id / tier (several options), and to the rule's description and price. Rules without any price still get their options. The options are also on the gate as gate.options (a PurchaseInfo[]), so a custom overlay can render the same list.

Handling the reader's choice

Clicking a button emits paywallAction and closes the overlay. The payload is { action, gate, productId? }:

actionWhenproductId
'purchase'A Buy optionThe chosen product id
'subscribe'A Subscribe optionThe chosen subscription tier
'login'Sign in—
'dismiss'Maybe later, close, Escape or backdrop—

Run your sign-in or checkout, then hand the player the new state:

import { Component, ViewChild } from '@angular/core';
import { PlayerShellComponent } from '@panelwave/player';
import type { PaywallAction, PaywallGate } from '@panelwave/player';

@Component({ /* … */ })
export class ReaderComponent {
  @ViewChild(PlayerShellComponent) player!: PlayerShellComponent;

  async onPaywall(e: { action: PaywallAction; gate: PaywallGate; productId?: string }): Promise<void> {
    if (e.action === 'dismiss') return;
    const snapshot = e.action === 'login'
      ? await this.auth.signIn()                              // your flow
      : await this.checkout.run(e.productId!, e.action);      // 'purchase' | 'subscribe'
    await this.player.refreshEntitlements(snapshot);          // closes the overlay if the gate is now open
  }
}

Called without an argument, refreshEntitlements() re-fetches from entitlementEndpoint. Binding a new [entitlementSnapshot] has the same effect.

Edge mutations wait for the gate

An edge's action mutations (for example a counter the move increments) are applied only once the paywall or age gate lets the move through. A blocked attempt changes no variables; a move held by the age gate applies its mutations when the reader passes.

Server-stripped panels

Client-side rules can be bypassed, so a server that publishes a paid work for anonymous readers can leave the paid content out instead: it keeps each paid panel's id and position, strips its content and marks it "x-locked": true — an extension property allowed on panels since format 1.7. From the release after 1.2.0 the player:

  • renders such a panel as the locked placeholder, in every view;
  • treats it as locked whatever the rules say — no free preview, entitlement or entitlementAdapter opens it, because its content is not in the manifest;
  • raises the applying rule's gate while that rule locks the panel; otherwise the gate asks for a subscription (when the applying rule is a subscription rule) or a purchase — never for the age, so a confirmed age does not re-open the age gate on a stub;
  • reports PaywallService.isFreeWork as false when any panel is locked.

Only the boolean true counts ("true" as a string is not a lock); the exported isLockedPanel(panel) applies the same test. To show a stripped panel after a purchase, hand the shell a manifest that contains it.

Age gates

Age-gate rules are answered inside the player — no checkout involved:

The reader hits an age-gated panel

Navigation pauses and pw-age-gate asks for a birth date (month, day, year; month names in the reader's language). The minimum age comes from the rule (minimumAge / ageGate, default 18). Impossible dates such as 31 February are rejected. Keys typed into the date fields never reach the story.

The reader passes

The result is stored on the device (localStorage key pw-age-verified), folded into the entitlement snapshot, and the interrupted navigation is retried — including its transition. If the rule also requires a purchase or subscription the reader doesn't have, the paywall opens next with its Buy / Subscribe options and the reader stays where they were. Returning readers on the same device are not asked again.

Or fails, or closes the dialog

A too-young answer keeps the dialog open with an explanation; closing it (or pressing Escape) leaves the reader where they were.

Every answer emits ageVerified ({ verified, age?, birthDate? }) so you can record it. If your account system already verified the reader's age, pass ageVerified: true and age in the snapshot and the prompt never appears.

The birth-date prompt is a self-declaration, suitable as an age gate, not as legal age verification. Its texts are translated (age_gate.* keys, see Localization).

Custom access checks

Two lower-level interfaces exist for integrations the snapshot model doesn't cover. Both are named EntitlementAdapter in the source, but they are different types — pick the one matching where you plug in.

The shell's [entitlementAdapter] input

The shell accepts a small adapter that replaces the manifest-rule check:

// Declared in player-shell.component.ts; NOT re-exported under this shape.
interface ShellEntitlementAdapter {
  /** Awaited before every panel navigation; false raises the paywall overlay. */
  hasAccess(panelId: string): Promise<boolean>;
  /** Called once at startup; the key/values are seeded into variables. */
  getContext(): Promise<Record<string, unknown>>;
  /** Declared but not called by the shell. */
  purchase?(productId: string): Promise<boolean>;
}
  • getContext() values are seeded (a privileged write that may populate readOnly variables, applied after initialVariables), so conditions can reference entitlement flags like {"var": "premium"} or a verified {"var": "user.age"} that in-story content cannot tamper with.
  • A false from hasAccess() stops navigation and raises the paywall overlay for that panel.
  • While an adapter is set, the manifest's paywall rules and the age gate are not consulted for navigation, and the rules do not lock rendering either (x-locked stubs stay locked).

An adapter decides navigation, not rendering. A panel the adapter refuses is still rendered: a move onto it stops at the gate, but when the adapter refuses the entry panel (initial load, bookmark resume) the paywall opens over the rendered content, and page and canvas view show refused panels in full. Don't rely on an adapter to hide content — strip it on the server ("x-locked": true stubs stay locked whatever the adapter says) or pass an entitlementSnapshot, which locks rendering too.

The EntitlementAdapter type you can import from @panelwave/player is the richer interface below (resolveEntitlement, …), not this one. Declare a structurally identical interface in your app, as above. For most integrations entitlementSnapshot is the simpler and better choice.

The full adapter and EntitlementService

The exported EntitlementAdapter interface powers the standalone EntitlementService — useful for custom flows outside the shell (your own paywall screens, signed asset URLs, access checks in a library view):

import type {
  EntitlementAdapter,
  EntitlementContext,
  EntitlementStatus,
  PaywallGate,
  UserInfo,
} from '@panelwave/player';

export interface EntitlementAdapter {
  /** Required: resolve entitlements for a work/chapter/panel context. */
  resolveEntitlement(context: EntitlementContext): Promise<EntitlementStatus>;
  /** Optional: return a signed URL for a gated asset. */
  getSignedUrl?(assetId: string, purpose: 'stream' | 'download'): Promise<string>;
  /** Optional: show your paywall UI; resolve when completed/dismissed. */
  showPaywallUI?(gate: PaywallGate): Promise<void>;
  /** Optional: verify age for age-restricted content. */
  verifyAge?(minimumAge: number): Promise<boolean>;
  /** Optional: authentication status. */
  isAuthenticated?(): boolean;
  /** Optional: current user information. */
  getCurrentUser?(): Promise<UserInfo | null>;
}

HttpEntitlementAdapter implements it. Supporting types:

EntitlementContextobject

workId (required), optional chapterId, panelId, userToken, metadata.

EntitlementStatusobject

ok: boolean (access granted), entitlements: Record<string, boolean> (flag dictionary, e.g. { premium: true }), optional user: UserInfo, reason (denial explanation), expiresAt (epoch ms — controls cache lifetime).

UserInfoobject

id (hashed/pseudonymous), optional displayName, email, age, tier, purchased: string[], tokens: number, plus custom properties.

PaywallGateobject

scope: 'work' | 'chapter' | 'panel', optional refId (the chapter id for chapter gates, the panel id for panel gates), requireEntitlement, required reason (English sentence), optional preview (previewPanels, mode: 'blur' | 'low-res' | 'watermark' | 'time-limited', durationSeconds), ruleId (the manifest rule that raised the gate), lockReason (subscription_required, purchase_required, age_verification_required or entitlement_required — the overlay translates it), and options: PurchaseInfo[] (what the rule offers, see Buy and Subscribe options).

import { inject } from '@angular/core';
import { EntitlementService } from '@panelwave/player';

const entitlements = inject(EntitlementService);
entitlements.setAdapter(new MyFullAdapter());

await entitlements.hasAccessToWork('work-1');
await entitlements.hasAccessToChapter('work-1', 'ch-2');
await entitlements.hasAccessToPanel('work-1', 'ch-2', 'p-9');

EntitlementService behavior worth knowing:

  • It is independent of the shell. Registering an adapter here does not change what pw-player-shell gates — the shell uses the snapshot (or its own [entitlementAdapter] input).
  • Caching: results are cached per workId:chapterId:panelId key until the status's expiresAt, otherwise 5 minutes. clearCache() resets; showPaywall(gate) clears the cache afterwards so a completed purchase is re-checked.
  • Defaults: with no adapter registered, the service uses NullEntitlementAdapter, which denies everything. MockEntitlementAdapter grants everything with a fake premium user — handy in tests.
  • Observables: getEntitlementStatus$() and getCurrentUser$() for reactive UI.
  • Age checks: verifyAge(minimumAge) delegates to the adapter or falls back to user.age; without either it denies.

The pure evaluation functions are exported too (evaluatePanelAccess, evaluateWorkAccess, rulesFromManifest, satisfiesRule, isExtraLocked, chapterOrderFromManifest — the chapter id and within-chapter index per panel, which chapter rules need, …) — for example to render lock icons in your own chapter list with exactly the player's semantics.

Paywall UI components

Both overlays are exported and can be used on their own:

  • pw-paywall-overlay (PaywallOverlayComponent) — inputs visible, gate: PaywallGate, purchaseOptions: PurchaseInfo[] (renders the options above Sign in), locale, title/message (LocalizedString), allowPreview, showLogin (default true); outputs action: PaywallAction, purchase: string (product id), close. Escape and a backdrop click dismiss it.
  • pw-age-gate (AgeGateComponent) — inputs visible, minimumAge (default 18), locale, warningMessage, allowDismiss; outputs verify: AgeVerificationResult, close.

PurchaseInfo describes an offer: productId, name, optional price: { amount, currency }, type: 'one-time' | 'subscription' | 'token', optional description. The shell passes gate.options to its overlay; when you use the overlay on its own, pass your own list.

PurchaseInfo.price is optional since 2026-09-27 (rules without a price still offer Buy / Subscribe). Code that reads option.price.amount must handle a missing price, or it no longer type-checks.

Which integration should I use?

  • You know what the reader owns → [entitlementSnapshot] + (paywallAction) + refreshEntitlements().
  • Your backend can answer "what does this reader own?" per work → entitlementEndpoint + readerToken.
  • You need a per-panel yes/no from your own service, or entitlement flags as story variables → the shell's [entitlementAdapter] (note it bypasses manifest rules and the age gate, and gates navigation only — it doesn't hide content), or initialVariables for the flags alone.
  • Paid content must not reach anonymous readers at all → serve a manifest with server-stripped panels, combined with any of the above.
  • You're building screens outside the player (library, store, signed downloads) → the full adapter with EntitlementService, or the exported evaluator functions.