Freemius Checkout
Render pricing and open the Freemius Checkout overlay from a React frontend so a user can buy any modality — a recurring subscription or a one-off / top-up (credit) purchase — and have the result synced server-side.
Source: React Starter Kit Checkout components https://freemius.com/help/documentation/saas-sdk/react-starter/components/#checkout-components
Framework note. The examples in this Skill are Vite/Hono-flavored, but nothing here requires that stack: the server side (owned by
freemius-core) works with any framework supporting Web-StandardRequest/Responserouting, and the React Starter Kit components target React 19+ with shadcn UI + Tailwind — install them vianpx shadcn@latest add https://shadcn.freemius.com/all.json. If the app doesn't use shadcn/React, download the Starter Kit and use it as a reference to implement the same UI in the app's own framework. Prefer reusing the application's existing components (buttons, inputs, cards, dialogs) over re-creating primitives — port the Starter Kit's structure and behaviour onto the app's own design system rather than introducing new primitives.
Sandbox. Build and validate in sandbox mode — a checkout mode that accepts test credit cards (not a discount; no coupon is needed or involved). It is wired to the dedicated
FREEMIUS_SANDBOXenv var (neverNODE_ENV) and owned byfreemius-core— the server-created checkout already carries the sandbox flag; this Skill inherits it.
Golden rules (do not violate)
- The overlay is the only thing that runs in the browser. It is primed with
a server-created serialized checkout (
checkout.serialize()fromfreemius-core). ThesecretKey/apiKeynever reach the client. - The browser never trusts itself. A successful overlay purchase fires a
callback that POSTs the payload to your backend, which re-fetches the
authoritative purchase from Freemius and upserts the entitlement. The paywall
is enforced server-side (HTTP 402); the UI gate is only UX.
- ⛔️ The overlay sync and the hosted redirect are TWO SEPARATE endpoints
with DIFFERENT payload contracts — never merge them. The overlay
purchaseCompletedcallback POSTs a JSON body to a dedicatedPOST /api/purchase; that handler reads thelicense_idout of the overlay payload and callssyncEntitlementByLicenseId(licenseId). The hosted return is a separateGET /api/checkout/redirectthat runsfreemius.checkout.processRedirect(url, PUBLIC_URL)on the signed query string (seefreemius-core→ purchase-processing). Do NOT route the overlay POST throughfreemius.checkout.request.createProcessor(...)/processRedirect(...), and do NOT pointsyncEndpointat/api/checkout/redirect?action=process_purchase. Those SDK paths expect a signed redirect /license_idin a shape the overlay payload does not provide — the POST then returns400 "License ID is required in the request body", the frontend.catch()swallows it, anduser_fs_entitlementstays empty for every user while the UI looks fine. (createProcessorbelongs to the customer-portal headless endpoint, not to overlay purchase sync — don't borrow it here.)
- ⛔️ The overlay sync and the hosted redirect are TWO SEPARATE endpoints
with DIFFERENT payload contracts — never merge them. The overlay
- Pricing data comes from one server endpoint. The frontend fetches plans +
prices + per-plan checkout context from your backend
(
GET /api/checkout/pricing); it never calls the Freemius API directly. - Cover every modality. Subscriptions use a billing-cycle toggle
(
monthly/annual); one-off/top-up plans open the overlay withbilling_cycle: 'lifetime'. Render both — don't hardcode the demo's subscription-only path. - Never ask the maker to restate the subscription model. The pricing tables
auto-fetch plans + prices from
GET /api/checkout/pricing, which is sourced from the Freemius Dashboard. The Dashboard is the single source of truth for plans/pricing — detect the modalities from that data (a lifetime-only plan →Topup; monthly/annual plans →Subscribe); don't hard-specify them in a build prompt or intake question. - The app owns the post-purchase confirmation UX. Open the overlay with
show_confirmation_dialog: false(skip Freemius' confirmation modal) and show an in-app success state instead — see In-app success message. In the sameonPurchasehandler, hide the paywall and clear any stale gate error/result from the gated action's 402, then re-check entitlement — otherwise a "need subscription"/"upgrade" message lingers on the gated surface until reload. - Purchase surfaces are entitlement-aware. Never show a plain Subscribe
button to an already-subscribed user (a plain subscribe checkout creates a
second parallel subscription — double-billing); their path is the
server-built license-upgrade checkout (license fetch +
setLicenseUpgradeByKey— seefreemius-customer-portal; notcheckout.create({ licenseId }), which 500s for renewing licenses). Hide the credits top-up for active-unlimited subscribers; mark the owned plan; note or hide Lifetime for subscribers. See references/pricing-tables.md → "Entitlement-aware rendering".
The components
Port these as editable shadcn/ui-style React source (not an opaque package). Each has a focused reference — read the one you need:
| Component | What it does | Reference |
|---|---|---|
CheckoutProvider |
Holds one overlay instance; exposes useCheckout() |
references/checkout-provider.md |
Subscribe |
Subscription pricing table (monthly/annual toggle) | references/pricing-tables.md |
Topup |
One-off / credit pricing table (lifetime billing) |
references/pricing-tables.md |
Paywall |
Overlay that shows the right table when a feature is gated | references/paywall.md |
| Custom buttons | Any element that calls useCheckout().open(...) |
references/custom-buttons.md |
The Next.js→Vite/Hono porting surface (env vars, the @/@shared alias, the
two server endpoints) is in
references/vite-hono-porting.md.
Quick start
1. Install the frontend SDK
npm install @freemius/checkout
2. Prime the provider with a server-created checkout
// The base checkout is created server-side (freemius-core) and exposed at
// GET /api/checkout → { options, link, baseUrl }. Load it, then wrap the UI.
const checkout = await getBaseCheckout(); // CheckoutSerialized
<CheckoutProvider
checkout={checkout}
syncEndpoint="/api/purchase" // POST target for the success callback
fetcher={portalFetch} // attaches auth headers
// re-fetch entitlement after success
>
<Subscribe />
<Topup />
</CheckoutProvider>;
3. Open the overlay for a chosen plan
const checkout = useCheckout();
// Subscription (with billing cycle):
checkout.open({
plan_id: planId,
billing_cycle: 'annual',
show_confirmation_dialog: false, // default — the app shows its own success state
});
// One-off / top-up (consumable credits):
checkout.open({
plan_id: planId,
billing_cycle: 'lifetime',
show_confirmation_dialog: false,
});
open() merges your params over the base options and wires the
purchaseCompleted callback, which POSTs to syncEndpoint.
Default show_confirmation_dialog: false in every open() call (or bake it
into the provider): Freemius' own post-purchase confirmation modal is skipped in
favor of the app's success state — better UX, one dialog instead of two. Because
the Freemius dialog is off, the app must show its own confirmation (next
section).
In-app success message (after purchase)
With show_confirmation_dialog: false, the app owns the confirmation UX on
both purchase paths:
- Overlay path —
purchaseCompleted→ the provider POSTs to/api/purchase(entitlement sync) →onPurchasefires. Use it to (1) re-fetch the entitlement/balance and (2) show a visible success state — a toast/banner like "Purchase complete — premium unlocked" (subscription) or "Credits added to your account" (top-up). Never complete a purchase silently. - Hosted-redirect path — Freemius redirects back to your purchase-redirect
handler (
freemius-core→ purchase-processing); its result page must render the same success message.
See references/checkout-provider.md → "In-app success message" for the wiring.
Mental model
Server (freemius-core) Frontend (this Skill) Freemius
GET /api/checkout ───────────────▶ getBaseCheckout()
{ options, link, baseUrl } ──────▶ new Checkout(options,…) (one instance)
GET /api/checkout/pricing ───────▶ <Subscribe/> + <Topup/> tables
user clicks "Subscribe"/"Buy"
useCheckout().open({plan_id, billing_cycle}) ─▶ overlay
user pays ───────────────────────────────────▶ license/payment
POST /api/purchase ◀────────────── purchaseCompleted(data) callback
(re-fetch + upsert entitlement)
402 on gated feature ────────────▶ <Paywall/> shows the right table
Hosted-checkout fallback
If you can't load @freemius/checkout (or want the simplest path), every plan
in the pricing response also carries a hosted checkoutLink. Navigate the
browser to it; Freemius redirects back to your purchase-redirect handler
(handled in freemius-core → purchase-processing). Prefer the overlay for UX;
keep the link as a graceful degradation.
Dashboard configuration (hand to the human)
In the Freemius Developer Dashboard (https://dashboard.freemius.com/):
- Product → Plans → define each plan and its pricing. A plan with only a
lifetime price (no monthly/annual) renders in the
Topuptable; plans with monthly/annual prices render inSubscribe. - Top-up precondition: the one-off/top-up modality only appears if a
lifetime-only plan exists in the product. If the maker wants top-ups (or you
need to demo/validate that modality), ask them to create one first — treat it
as an intake/precondition question, and hide the
Topuptable gracefully whenoneoffcomes back empty. - Checkout → appearance / behaviour → optional overlay branding.
- The webhook + redirect URL setup is owned by
freemius-core.
Granting what a purchase entitles the user to (feature unlock, credit balance)
is server-side and owned by freemius-core — for top-up credits, follow
freemius-core → entitlement-logic.md → "Usage-based / credit metering
(top-up model)" (balance in a separate user-keyed table, never an extension
of user_fs_entitlement).