Candidate paywalls
Build candidate offer selection, embedded checkout, entitlement checks, and billing management.
A
JBuild the candidate job-access purchase flow. Offers are public, but checkout, checkout state, grants, and billing-portal access all belong to the signed-in candidate.
Prerequisites
board.context().features.candidatePaywallistrue.- Candidate authentication and a completed candidate profile.
@stripe/stripe-jsin the browser that mounts embedded checkout.- A server-owned session for every
board.me.accessrequest.
Load offers and the current grant
12345678910111213141516171819202122// src/data/access.tsimport type { FetchOptions } from '@cavuno/board';import { board } from '../lib/board';function session(accessToken: string): FetchOptions {return {cache: 'no-store',headers: { authorization: `Bearer ${accessToken}` },};}export async function loadAccessPage(accessToken: string) {const [context, offers, grant] = await Promise.all([board.context(),board.paywall.offers(),board.me.access.grant(session(accessToken)),]);if (!context.features.candidatePaywall) return null;return { offers: offers.data, grant };}
grant always resolves. No access is { hasAccess: false, … }, not an error. Use it for the rendering decision instead of inferring access from gatedCount or a previous checkout redirect.
Start embedded checkout
12345678910111213export async function startAccessCheckout(accessToken: string,offerKey: 'daily' | 'weekly' | 'monthly' | 'quarterly' | 'yearly' | 'lifetime',) {return board.me.access.checkout({offerKey,returnPath: '/account/access',colorMode: 'light',},session(accessToken),);}
The response is a mount kit containing sessionId, clientSecret, stripeAccountId, and publishableKey. In the browser, initialize Stripe.js with both the publishable key and connected account, then mount embedded checkout with the returned client secret.
123456789101112131415161718192021// src/browser/mount-access-checkout.tsimport type { AccessCheckoutSession } from '@cavuno/board';import { loadStripe } from '@stripe/stripe-js';export async function mountAccessCheckout(kit: AccessCheckoutSession,selector: string,) {const stripe = await loadStripe(kit.publishableKey, {stripeAccount: kit.stripeAccountId,});if (!stripe) throw new Error('Stripe.js failed to load');const checkout = await stripe.initEmbeddedCheckout({clientSecret: kit.clientSecret,});checkout.mount(selector);return () => checkout.destroy();}
Call the returned cleanup function when the checkout view unmounts.
Confirm access after checkout
123456789101112131415export async function confirmAccess(accessToken: string,sessionId: string,) {const options = session(accessToken);const checkout = await board.me.access.retrieveCheckout(sessionId,options,);if (checkout.status !== 'complete') return { checkout, grant: null };const grant = await board.me.access.grant(options);return { checkout, grant };}
An open checkout can be remounted with its returned clientSecret. An expired checkout needs a new session. Even after complete, unlock content only when a fresh grant returns hasAccess: true.
Open subscription management
123456const portal = await board.me.access.portal({ returnPath: '/account/access' },session(accessToken),);// Redirect to portal.url with your framework.
Only recurring grants have a billing portal. Lifetime access does not.
Expected result
The page shows current public offers and the viewer’s grant. Checkout returns an embedded-checkout mount kit; a completed checkout followed by grant() returns the authoritative access decision.
Verify the result
- Disabled paywalls do not render purchase routes or controls.
- Offer labels and prices come from
board.paywall.offers. - Checkout is unavailable without a signed-in candidate profile.
- A browser return does not unlock content until
grant.hasAccessis true. - Lifetime users never see a recurring-subscription management action.
Errors and edge cases
offers()returns an empty list when the paywall is disabled.- Past-due, unpaid, incomplete, canceled, and pending grants can all return
hasAccess: false; render from both fields without inventing a local status. returnPathmust remain a safe relative path.- The SDK does not mount Stripe UI or process card details.
Production cautions
- Never cache grants, checkout state, or portal URLs publicly.
- Keep card handling inside Stripe’s embedded checkout.
- Re-read the grant on every protected server render, not only in the browser.
- Log API request IDs, not checkout secrets or session credentials.
Continue with Frontend infrastructure to make these routes canonical, indexable, and visually consistent.