Paywall / Customer portal
Customer portal
The portal is a hosted self-service page for your buyers — receipts, refunds, cancelling and resuming, switching plans, even deleting their account. You don't build any of it. This page covers all three sides: what the buyer sees, the dashboard switches that shape it, and how to open it from your app.
Availability follows your Onelo plan: the portal itself is a plan capability, and two of its actions — Change plan and Request refund — are off on the Free plan (paid Onelo plans include them). Cancelling and receipts are never plan-gated.
What the buyer sees inside
The portal greets them with “Manage your plan” (or “Welcome back” if their plan lapsed) and shows, per purchase: a status pill (Active / Canceled / Payment failed), the plan name and price, the key date (“renews 12 Jul 2026”, “free trial — first charge…”, “ends…” or “access until…”), when they started, and their last payment. Below sit the action buttons — each only appears when it actually applies:
| Action | What happens |
|---|---|
| Change plan / Upgrade plan | Shown when you have more than one plan (and your Onelo plan includes portal plan-changes). Opens your store with the buyer recognised — the unused part of what they already paid is shown as a credit and deducted at checkout, so they only pay the difference (“Pay €X today after applying credits”). |
| Switch to annual · save X% / Switch to monthly | Shown when the product has both prices. Going up (monthly → annual, or a higher tier): a preview shows the exact prorated amount due today, line by line, before they confirm. Going down: no charge today — the cheaper plan simply takes over at the next renewal, so the time already paid for is never cut short. |
| Cancel subscription | Two gentle steps: a confirm (“You'll keep access until {date}, then won't be charged again”) and a why-are-you-leaving picker (8 reasons + optional note) — those reasons land in your dashboard as churn insight. Access runs to the period end; nothing stops immediately. |
| Resume subscription | A cancelled-but-not-yet-ended buyer can undo with one click — “No charge today.” |
| Request refund | Only inside the refund window you set, with the product's self-serve refund opt-in enabled (off by default on every new product — buyers then cancel instead), and when your Onelo plan includes portal refunds. The modal states fees aren't refundable, warns access ends now, asks a reason (7 options) — then: “Your refund is on its way… 5–10 business days.” Outside the window the button is greyed with a plain explanation (“Refund window closed · purchase was N days ago”). |
| Receipts | A list of every payment with date, amount (struck through if refunded), billing address, and a PDF / Receipt link straight from Stripe — if you allow it (see below). |
| Delete account | A quiet link, deliberately heavy to use: password re-entry + an “I understand” checkbox. It schedules deletion in 30 days, cancels subscriptions at period end (“no surprise charges”), signs them out everywhere, and emails them. Signing in before the date cancels it. (Buyers who signed up with Google/Apple — no password — can't use this path.) |
Your switches — what you control
In Paywall → Customer portal, four app-wide decisions shape every buyer's experience:
| Setting | What it does | Default |
|---|---|---|
| Refund outcome | What a buyer's plan becomes when a full refund leaves them with no active plan. Mark as lapsed — the account stays but paid features lock. Enroll in free tier — they're auto-moved onto your free product (price 0) instead. “Enroll” is disabled until a free product exists in Products. | Mark as lapsed |
| Default refund mode | Full refunds 100% of what was paid within the window. Proportional refunds only the unused part — paid amount × time remaining ÷ period length. This is the default for products that don't set their own (each product can override it under Refunds & Legal). | Full |
| Session after refund | Only applies when the outcome is Mark as lapsed: after a refund the buyer is signed out everywhere and the next launch goes sign-in → store. Off = they stay signed in with paid features locked. Hidden when the outcome is free tier — there they keep an active plan, so signing them out would make no sense. | On |
| Payment receipts | App-wide default for the Receipt / PDF links. Per-product override under Products → Refunds & Legal → Receipt visibility. | On |
Three more levers live per product (Products → Refunds & Legal): the refund window in days (it drives the Request-refund button's availability — leave it empty to inherit the app-wide window; set 0 to disable self-service refunds for that product), allow self-serve refunds (off by default on every new product, subscriptions and one-time alike — buyers then cancel instead of refunding), and a per-product refund mode and receipt visibility that override the app-wide defaults above.
Open it from your app
The portal opens with one SDK call — Onelo signs the buyer in behind the scenes (a short-lived secure link tied to the signed-in user; no extra password). It ships in every SDK: Swift (macOS, iOS), JS / Web, Electron, React Native, Flutter and Android. The snippet below assumes the Onelo SDK is already initialised and the buyer is signed in — see the Auth guide if you haven't set that up yet.
onelo_pk_live_YOUR_KEY with your app's publishable key from your app's SDK page in the dashboard. The API URL is already the correct Onelo endpoint — use it as-is.Install
npm install github:onelo-tools/onelo-jsInitialize
import { Onelo } from '@onelo/js'
const onelo = new Onelo({
apiUrl: 'https://api.onelo.tools',
publishableKey: 'onelo_pk_live_YOUR_KEY',
})Usage
// ── 1. When to show the buttons ─────────────────────────────
// Only to a signed-in user who holds a plan. open() throws
// OneloError('not_authenticated') without a session, and a user with no plan has
// nothing to manage (offer the store instead — see the Store tab).
// hasActiveAccess() is false when signed out, so this one call covers both.
async function renderPortalButtons() {
const holdsPlan = await onelo.auth.hasActiveAccess()
manageButton.hidden = !holdsPlan
changePlanButton.hidden = !holdsPlan
}
void renderPortalButtons()
// ── 2. Open the portal ──────────────────────────────────────
// Management home: cancel, refund (within your refund window), invoices, change
// plan. Resolves when the user closes the portal.
manageButton.onclick = () => onelo.customerPortal.open()
// Straight into the plan picker, for an "Upgrade" / "Change plan" button, for a
// paid subscriber. Falls back to the management home when this subscription
// can't change plan (a user on your free plan buys through the store instead). The
// portal confirms the change and credits the unused part of the current plan.
// (onelo.openUpgrade() — Features tab — opens the same plan picker without the
// portal page in front of it.)
changePlanButton.onclick = () => onelo.customerPortal.open({ intent: 'change_plan' })
// ── 3. After the portal closes ──────────────────────────────
// Don't read anything from open() and don't poll: every change reaches the SDK
// over SSE.
// • Plan change or cancel → features.subscribe() fires; re-read feature().
// • Refund with refund_fallback_policy "lapsed" and signout_after_lapsed_refund
// on (the default) → the session is revoked and onAuthStateChange(null)
// fires. Re-open sign-in there — the same handler as on the Auth tab.
// • Refund with refund_fallback_policy "free_tier" → the user stays signed in
// on your free plan; features.subscribe() fires with the free plan's features.
const unsubscribeFeatures = onelo.features.subscribe(() => {
// re-read onelo.features.feature(...) and re-render
void renderPortalButtons()
})
const unsubscribeAuth = onelo.auth.onAuthStateChange((session) => {
if (!session) void onelo.loadAuthView() // signed out (e.g. a lapsed refund) → sign-in
void renderPortalButtons()
})
// ── 4. Plan limits ──────────────────────────────────────────
// Refunds in the portal need portal_refunds on YOUR Onelo plan, and Change plan /
// switch to annual need portal_plan_changes; without them the portal doesn't offer them.That embedded-window call covers most apps. Two more entry points exist for other UI patterns:
| Call | Use it when |
|---|---|
openCustomerPortal(intent:) | The default — opens the portal in the SDK's own window (macOS) or sheet (iOS) and returns when the buyer is done. Pass .changePlan to open straight into the plan picker. |
openCustomerPortalInBrowser() | Hands off to the system browser instead. A browser tab cannot hand a finished purchase back to the app. |
initiateCustomerPortal() | Returns the signed URL so you can present it yourself in custom UI. |
| Platform | Status |
|---|---|
| Swift (macOS / iOS) | Available — the calls above |
| JS / Web | Available — onelo.customerPortal.open() |
| Electron | Available — await onelo.customerPortal.open() |
| React Native | Available — mount <CustomerPortalModal>, then call await onelo.customerPortal.open() |
| Flutter | Available — push OneloCustomerPortalView |
| Android | Available — registerLauncher + openCustomerPortal(...) |
Typical placement: a “Manage subscription” item in your app's account or settings menu. The portal inherits your Branding (logo, colors), so it feels like part of your product.