Freemius Customer Portal
Give a signed-in user one page to manage everything post-purchase: subscriptions, upgrades, cancellation (with a retention offer), billing details, and invoices — without your app reimplementing any billing logic.
Source: React Starter Kit Customer Portal component https://freemius.com/help/documentation/saas-sdk/react-starter/components/#customer-portal-component
Framework note. The route examples in this Skill are written for Hono, but the SDK's portal processor works with any framework supporting Web-Standard
Request/Responserouting — adapt the thin route to the app's real HTTP framework. The React Starter KitCustomerPortalcomponent targets React 19+ with shadcn UI + Tailwind (install vianpx shadcn@latest add https://shadcn.freemius.com/all.json); if the app doesn't use shadcn/React, use it as a reference implementation.
Sandbox. While validating, keep the processor's
isSandboxwired to the dedicatedFREEMIUS_SANDBOXenv var (neverNODE_ENV) so it matches the checkout's sandbox mode — sandbox is a checkout mode with test credit cards, owned byfreemius-core.
Golden rules (do not violate)
- The portal is headless and server-driven. The SDK ships a
PortalRequestProcessor: your backend exposes one endpoint (GET|POST /api/portal) and the SDK handles data retrieval and every action behind signed, per-action URLs. The component never calls Freemius directly. - Actions are token-signed. Portal data arrives with pre-signed action URLs
(cancel, coupon, billing-update, invoice). The component just hits those
URLs; the SDK verifies the token server-side. You only supply the
authenticated user's identity. (Upgrade is the exception — it goes through a
server-built license-upgrade checkout (
setLicenseUpgradeByKey), which avoids the double-subscription / renewing-license 500 pitfalls.) - Identity is your job; everything else is the SDK's. The processor's
getUserresolves the Freemius user from your session (e.g. by email). All Freemius credentials stay server-side. - Cancellation should try to retain first. If a renewal-cancellation coupon is available, offer it before completing the cancellation.
The one endpoint
// src/server/services/freemius/portal.ts
import { freemius, IS_SANDBOX } from './client';
import { env } from '../../env';
const PORTAL_ENDPOINT = `${env.publicAppUrl}/api/portal`;
export async function processPortalRequest(
request: Request,
email: string
): Promise<Response> {
const processor = freemius.customerPortal.request.createProcessor({
portalEndpoint: PORTAL_ENDPOINT, // must match the public URL the browser hits
isSandbox: IS_SANDBOX,
getUser: async () => {
const fsUser = await freemius.api.user.retrieveByEmail(email);
return fsUser?.id ? { id: String(fsUser.id), email } : { email };
},
});
return processor(request); // dispatches by ?action= and verifies tokens
}
// src/server/routes/portal.ts — route stays thin: auth, then delegate.
portalRoute.on(['GET', 'POST'], '/', requireUser, async (c) =>
processPortalRequest(c.req.raw, c.get('user').email)
);
The processor dispatches on the ?action= query param:
| Method | ?action= |
Result |
|---|---|---|
| GET | portal_data |
subscriptions + billing + payments + signed URLs |
| POST | billing |
update billing information |
| POST | subscription_cancellation |
cancel renewal (+ optional feedback) |
| POST | subscription_cancellation_coupon |
apply the retention coupon |
| GET | invoice |
stream the invoice PDF |
See references/headless-endpoint.md.
The component
Port CustomerPortal as editable shadcn/ui-style React source. It fetches
?action=portal_data once and renders four sections, each driven by signed URLs
baked into that data:
| Section | What the user does | Reference |
|---|---|---|
| Subscription | See current plan; upgrade via a server-built license-upgrade checkout (setLicenseUpgradeByKey) — the robust path (avoids double-subscription / renewing-license 500s) |
references/subscription-and-upgrade.md |
| Cancellation | Cancel renewal — disclaimer → retention coupon → feedback | references/cancellation-and-coupon.md |
| Billing | Edit business name / tax id / address | references/billing.md |
| Payments & invoices | View payment history, download invoice PDFs | references/payments-and-invoices.md |
Quick start (frontend)
import { CustomerPortal } from './react-starter/components/customer-portal';
import { PORTAL_ENDPOINT, portalFetch } from './api';
// portalFetch attaches the auth header; the component does the rest.
<CustomerPortal endpoint={PORTAL_ENDPOINT} fetcher={portalFetch} />;
// Loading portal data:
const res = await fetcher(`${endpoint}?action=portal_data`);
const data = (await res.json()) as PortalData;
// data.subscriptions.{primary,active,past}, data.billing, data.payments,
// data.cancellationCoupons — each carries signed action URLs.
Mental model
Frontend <CustomerPortal> Backend (one endpoint) Freemius
GET /api/portal?action=portal_data ─▶ processPortalRequest()
{ subscriptions, billing, payments, ◀─ freemius.customerPortal.request
+ signed action URLs } .createProcessor() → getUser()
POST <signed cancelRenewalUrl> ───────▶ processor verifies token ──────────▶ cancel renewal
POST <signed couponUrl> ──────────────▶ processor verifies token ──────────▶ apply coupon
POST <signed billing updateUrl> ──────▶ processor verifies token ──────────▶ update billing
GET <signed invoiceUrl> ─────────────▶ processor verifies token ──────────▶ invoice PDF
GET /api/checkout/upgrade ───────────▶ license.retrieve + checkout.create()
{ link } ◀──────────────────────────── .setLicenseUpgradeByKey(secret_key) ─▶ plan-change
.getLink() — browser navigates ─────▶ checkout
⚠️ The upgrade path is the server-built license-upgrade checkout above. And don't use
checkout.create({ licenseId })for it: that option 500s for actively-renewing licenses (the SDK internally calls a payment-method-update endpoint that rejectsexpiration: nulllicenses as "lifetime"). Fetch the license server-side and usesetLicenseUpgradeByKey(license.secret_key)instead. See references/subscription-and-upgrade.md.
Dashboard configuration (hand to the human)
- Product → Coupons → create a renewal-cancellation (retention) coupon so
cancellationCouponsis populated and the wizard can offer it. - The webhook + redirect setup that keeps the local entitlement in sync is owned
by
freemius-core; upgrades/cancellations arrive there as webhook events (license.plan.changed,license.cancelled, …).
Post-action sync (how changes reach your app)
Portal actions change state at Freemius first; your local
user_fs_entitlement mirror catches up in two ways:
- Immediately in the portal UI — re-fetch
?action=portal_dataafter any action (it reads live Freemius data). - In your entitlement mirror — via
freemius-core's webhooks (license.plan.changedon upgrade,license.cancelled/expiry on cancellation). ⚠️ After setting or changing the webhook URL in the Dashboard, allow ~5 minutes for it to propagate — events fired before the URL is active are never delivered (not retro-sent). Seefreemius-core→references/webhooks.md(andfreemius-troubleshootingfor the missed-event symptom).