Skip to content

Subscription Billing

Charging your own tenants for access to the product you built.

Two money flows, in opposite directions. The payments layer lets each tenant charge its customers into its own merchant account. This page is the other direction: you charging tenant organisations, into your account. Different credentials, different tables, different admin pages:

  • /Admin/PaymentProviders — Payment Providers: how an organisation charges its customers.
  • /Platform/PaymentAccounts — Payment Accounts: how you charge organisations.
  • /Admin/Billing — what an organisation pays you, with its payment history and receipts.
  • /Platform/Billing — every payment organisations have made to you, and every subscription.
  • /Admin/Payments — Payments Received: every payment an organisation’s own customers made to it, through any app, with a refund action. A refund taken there is written to the organisation’s audit log, and the app the payment belongs to is told about it once, with the amount refunded.

Turning It On

Billing is live only when both switches are on:

  1. PlatformSubscriptionBilling, a feature switch at Platform → Platform Settings → Features. It makes the subscription pages exist at all, and it is on by default.
  2. The runtime “billing is live” switch, Enable platform subscription billing at /Platform/PaymentAccounts. It lets you pause charging without a redeploy, and it ships off. The switch only appears while the feature switch is on.

With the feature switch on and the runtime switch off — a fresh install — you get the pages, and three example plans are seeded at startup if the catalogue is empty:

Plan Price Trial Member limit (max.members)
Free Trial free 10 days 3
Pro £29 / month or £290 / year none 25
Business £99 / month or £990 / year none unlimited

None of them grants feature keys or premium apps. Once any plan exists the seed never runs again, so editing or deleting these is permanent.

Nothing else happens: no checkout, no charges, no premium-app gating, no seat limits, and the lifecycle sweep does nothing. Turn the runtime switch on once your platform payment account is connected.

Pausing billing later (runtime switch off) freezes the lifecycle: no renewal charges, and no subscription is moved to past due or expired for a pause it didn’t cause. The sweep catches up when you switch it back on.

If you aren’t selling subscriptions, switch PlatformSubscriptionBilling off at Platform → Platform Settings and the subscription parts go: the Subscriptions tab of /Platform/Billing and the plan picker on /Admin/Billing are hidden, and /Platform/Plans stays in the platform menu, marked Off, showing only a notice that the switch is off. The payment pages stay: an app can still sell through your accounts, and its customers still need their receipts.

Go through ISubscriptionBillingGate if you extend this area. It is the single reader of “is billing live” (feature switch and runtime switch), and checkout, the premium-app gate and the seat limit all ask it. A second reader that checks only one of the switches will disagree with the rest — for example, refusing invitations over a seat limit while premium apps are ungated.

The Pages

  • /Platform/Plans — the plan catalogue: tiers, currency, monthly and yearly prices, trial length, plan features, numeric limits, and which premium apps each plan includes. Features are a checklist of what is actually on offer — the platform’s own declarations plus each enabled plugin’s, grouped by plugin — with no free-text field, so you cannot grant a feature nothing checks or mistype one. A feature already on a plan that nothing loaded declares (its plugin was removed, or renamed the key) shows as Unknown: key (plugin not loaded?) with a remove control; it grants nothing until that plugin is back. While billing is live the page also lists any declared feature that no active plan grants, because every organisation is then without it. A price of zero means that interval isn’t offered; a plan with both prices at zero is free and activates without a checkout. Deactivating a plan hides it from buyers but leaves existing subscribers on it. The seeded plans are ordinary rows — edit or delete them.
  • /Platform/Billing — two tabs. Payments lists every charge taken through your payment accounts — subscription periods and anything an app sells through them (a domain, say) — with filters, per-currency totals (received, refunded, net), a CSV export matching the filters, a receipt PDF per payment and a refund action. Subscriptions lists every organisation’s plan, status, price, MRR and next renewal, filterable and sortable, with a CSV export; opening one shows its details and recent payments, and lets you add free time (extend a trial, or the current paid period at no charge) or cancel at period end or immediately. Both actions are refused while a renewal charge is still being confirmed, and both are written to the platform audit log. MRR is shown per currency, not summed across currencies; the Subscriptions tab loads at most 1,000 subscriptions and says so beyond that. The old /Platform/Subscriptions address opens the Subscriptions tab. The Payments tab appears whatever the PlatformSubscriptionBilling switch says, because an app can sell through your accounts on a deployment that sells no subscriptions.
  • /Admin/Billing — what an organisation admin sees: plan picker with an annual-billing toggle, an auto-renew toggle, a provider choice when more than one platform account is connected, subscribe, pay now (when past due), cancel at period end, cancel now, and resume. Below it, Payment history lists every payment the organisation has made to you — subscriptions and app purchases — each with a receipt PDF. The page and its menu link are always there; with subscriptions off it says so and shows only the history.

What You Can Charge

  • Monthly and yearly subscriptions.
  • Pay for the year — auto-renew off: one period is charged and nothing renews. When it ends the subscription goes past due and the organisation renews by hand.
  • Trials, per plan, in days, offered only on an organisation’s first subscription and once per person — a user who has already had a trial in another organisation they own or administer isn’t offered another. A trial takes no payment details: a free plan’s trial simply expires; a paid plan’s trial goes past due at its end and asks for payment.
  • Prorated plan switches — unused time on the old plan is credited against the first charge on the new one, when switching from an active paid period in the same currency. Credit beyond that charge is not carried forward; if the credit covers the whole charge, the switch takes effect immediately with no charge and keeps the current period end. Until a switch’s charge is confirmed, the organisation keeps the old plan’s entitlements, so starting or abandoning a switch never unlocks the new plan for free.

Cancelling at period end keeps access until the end of the paid period and can be undone with Resume until then; cancel now ends access immediately.

Subscription statuses

Status Access Moves to
Incomplete none (or, mid plan switch, the period already paid for; or, for a renewal started while past due, the rest of the grace window) Active when the checkout is paid; Expired after AbandonedCheckoutDays unpaid; Past due if a plan switch’s checkout is still unpaid when the old paid period ends; Expired if a renewal started while past due is still unpaid when the grace window ends
Trialing until the trial ends Expired (free plan) or Past due (paid plan)
Active yes Past due when a renewal isn’t paid; Expired at period end if cancelled at period end
Past due until GraceDays after the period end Active when paid (a retry of the stored method); Incomplete when the organisation renews through a checkout; Expired after the grace window
Cancelled until the end of the paid period Active (resume); Expired at period end
Expired none — subscribe again for a new subscription

Charges run through the same providers as the tenant payments layer — Stripe and PayPal — but against your credentials, configured at /Platform/PaymentAccounts. No tenant payment rows are written.

A platform payment account that has been charged against can’t be deleted. Settling, renewing or refunding those subscriptions needs the credentials they were charged with, and nothing moves a subscription to a different account. To retire a key, replace the keys on the existing account, or rotate them at the provider.

Unattended auto-renewal needs a stored payment method — a Stripe card on file or a PayPal vaulted funding source, captured at checkout. Without one, a renewal moves the subscription to past due and prompts the customer to pay. Neither provider’s off-session renewal has been tested against a live account, so test it against your own before promising customers automatic renewal.

The Lifecycle Sweep

A background sweep activates paid subscriptions, ends trials, handles renewals and period ends, retries failed charges, and expires subscriptions past the dunning grace window. It runs in the background worker under Full and in the web process under Lite, and only one replica runs it at a time.

Configure it under BackgroundServices:Subscriptions:

Key Default Meaning
Enabled true Turns the sweep off entirely.
ProcessingIntervalInSeconds 3600 How often it runs.
GraceDays 7 How long a past-due subscription keeps access before it expires.
AbandonedCheckoutDays 3 When an unpaid checkout is given up on.
MaxSubscriptionsPerTick 500 Work cap per run.

Set GraceDays to the same value in the web host’s appsettings.json and the worker’s. The web host uses it to decide how long a past-due organisation keeps access; the worker uses it to decide when to expire the subscription. If they differ, access ends early or outlives the subscription by the difference.

Past-due retries

Within the grace window the sweep retries the stored payment method at most once a day — only when auto-renew is on and a method is on file — then expires the subscription. Each retry uses an idempotency key of its own, tied to the last time the provider definitely refused the charge: reusing the per-period key would make the provider replay the original failed charge, so no retry could succeed, and an attempt whose outcome is unknown is retried under the same key rather than a fresh one, so it cannot charge twice.

The organisation’s active owners and admins are emailed when a subscription goes past due and again when a past-due subscription expires, with a link to their billing page. No email is sent for other expiries (a cancelled period ending, a free trial ending). Every successful payment, a retry included, sends a receipt email — see The Payment Ledger. The emails go through the organisation’s outbox and are sent with the deployment’s email settings (/Platform/Email), so they need email delivery configured there.

If a renewal charge’s outcome can’t be confirmed with the provider, the subscription is left active rather than dunned (dunning would switch to a new idempotency key and could charge twice). If it is still unresolved after the grace window, the subscription’s error on /Platform/Billing (Subscriptions tab) says so and an error is logged — reconcile it with the provider by hand.

While such a charge is unconfirmed, a plan switch or a cancellation first asks the provider about it. If it went through, it is recorded in the ledger and the action goes ahead; if it failed, the period it would have paid for is taken back — the subscription goes past due, with the usual email — before the action goes ahead; if the provider still can’t say, the action is refused with a “try again in a few minutes” message, so nothing drops the only reference to a charge that may yet succeed.

Activation: polling, plus a webhook fast path

A subscription is confirmed by polling the provider when the buyer returns from checkout, and again in the sweep. A webhook at /api/platform-billing/webhook/{provider} (Stripe or PayPal) activates it sooner: register that URL with the provider against your platform account. The signature is verified against your platform account’s credentials, and a verified event only triggers a poll of pending checkouts — the payload itself is never trusted to activate anything. The one exception is a refund or dispute, which is read from the verified payload and recorded in the payment ledger (below), so enable those events on the same webhook: Stripe charge.refunded, charge.dispute.created and charge.dispute.closed; PayPal PAYMENT.CAPTURE.REFUNDED, PAYMENT.CAPTURE.REVERSED, CUSTOMER.DISPUTE.CREATED and CUSTOMER.DISPUTE.RESOLVED. Polling stays the safety net, so a missed webhook delays activation rather than losing it.

Only one checkout reference per subscription is polled. Any code that stops tracking a checkout — a plan switch replacing it, a cancellation, an abandoned session — must expire it at the provider first (IPlatformBillingService.ExpireCheckoutAsync). Otherwise the buyer can return to the old tab (a hosted checkout lives around 24 hours), pay, and be charged with nothing on your side to activate. Keep AbandonedCheckoutDays above your provider’s hosted checkout lifetime so the link is dead before polling stops.

The Payment Ledger

Every charge taken through your platform payment accounts is recorded in one ledger (PlatformPayment, in the platform database) — subscription periods and anything an app sells through those accounts. The platform records it, not the app: checkout creation, the status poll, off-session charges and refunds all pass through the platform’s own job handlers, and each writes the ledger. An app selling through your accounts therefore shows up on /Platform/Billing without any code of its own.

  • A row is created Awaiting payment when a checkout starts, and becomes Paid when the provider confirms it. Unpaid attempts are kept for 90 days and then removed; paid rows are never removed, not even when the organisation is deleted — they are your financial record.
  • Organisation deletion settles that organisation’s money first, while its record is still there: a renewal charge still being confirmed is asked about and ledgered if it went through, and an open checkout is expired at the provider. A checkout the provider says was already paid is activated and recorded in the ledger, and an error is logged telling you to refund it if the deletion completes (a deletion that stops — a failed archive keeps the organisation — leaves it on the plan it paid for). Anything the provider cannot answer for is logged as an error naming the reference to reconcile by hand; the deletion carries on.
  • Receipts. Each paid row gets a receipt number (PR-yyyyMMdd- followed by the payment’s whole id, upper-cased without its hyphens, so no two receipts share one) and a PDF — at /api/billing/receipts/{id} for the organisation’s own admins (their own receipts only) and /platform/billing/receipts/{id} for a platform admin. The CSV exports are /platform/billing/payments.csv and /platform/billing/subscriptions.csv. The organisation’s owners and admins are emailed when a payment is confirmed. The seller details printed on receipts — business name, address, tax number, billing email — are set under Receipts at /Platform/Settings. Amounts are printed as charged; the template calculates no tax.
  • Refunds from /Platform/Billing go back through the account the charge was taken on. A refund is refused if the payment’s refunded amount changed since you opened it (another refund, from the dashboard or a lost reply that went through), so a retry cannot refund twice. Refunds and disputes taken in the provider’s own dashboard reach the ledger through the webhook above. An app refunding its own charge may name the checkout or order reference it holds; the platform finds the Stripe payment or PayPal capture that has to be refunded, from the ledger or by asking the provider. A refund does not cancel a subscription or undo a purchase — do that separately. An app can be told about refunds and disputes on its own charges by implementing IPlatformPaymentEventHandler.

Entitlements

A plan carries plan features and numeric limits, resolved per organisation through IEntitlementService. An organisation with no entitled subscription gets an empty set — no access, not default access.

Plan features are declared, not typed. A plugin lists what it sells in its manifest (SubscriptionPlanFeatures — see Plugins → Plan features), the platform’s own in HostSubscriptionPlanFeatures, and the plan editor offers exactly that list. The platform’s own list ships empty: nothing in the platform itself checks a feature today, and a checkbox that changes nothing would read as a feature. A plan stores the keys as plain strings and they compare ignoring case; a buyer sees the declared names on /Admin/Billing, never a raw key, and a key nothing declares is not shown at all. Each plugin checks its own features, and the recommended check allows everything while billing is not live and for the platform organisation, so a deployment that sells no subscriptions is unaffected.

Plan feature or premium app? A premium app (below) is all-or-nothing: an organisation whose plan does not include it cannot open any of it. A plan feature is a part of a plugin — the rest stays usable on any plan. A plugin uses features when some of it has to remain reachable whatever the plan says, typically because the organisation already owns things there it must keep control of (records it created, things it bought and must be able to renew or cancel).

Going live takes features away

The moment billing goes live, every organisation whose plan does not grant a plugin’s features loses them — and the seeded example plans grant none. For a plugin that serves public pages that can mean those pages go offline. Tick the features on your plans before you switch billing on. To keep a plugin away from every organisation regardless of plan, there is a separate switch: Available to orgs at Platform → Apps (/Platform/Apps) hides it everywhere, its pages included.

Two entitlements are enforced out of the box, both only while billing is live:

  • Premium apps. A plugin is premium when Plugins:{id}:RequiresSubscription is true (or its manifest says so), and a plan grants it by listing the plugin’s full id. Premium apps a plan doesn’t include are hidden from the nav, shown in the launcher as a locked “Upgrade” tile, and redirected to /Admin/Billing if opened directly. Non-premium apps and the platform organisation are always allowed. Platform admins aren’t exempt: inside a tenant they see that tenant’s entitlements, upgrade prompt included, so the nav, the page and the plugin’s own API all agree. To work on a premium plugin, switch to the platform organisation.
  • Seat limits. The max.members limit caps members at invite and add. Active members and still-valid pending invitations both count against it.

EntitlementKeys also defines platform.priority-support, platform.api-access and platform.remove-branding as naming conventions, but nothing reads them, so none is offered on the plan editor. To sell one, check it where it matters and declare it in HostSubscriptionPlanFeatures in the same change — the declaration is what puts it on the checklist. (The “Powered by” footer credit is controlled by Legal:AttributionRequired, not by a plan.)

Subscription state lives in the platform database, because entitlement has to be decided before tenant context is resolved — it decides whether the request may reach the tenant at all.

Not every platform charge is a subscription charge. A charge marked as a subscription charge (IsSubscriptionCharge) is blocked while billing is paused; other platform charges are not, so a deployment that sells no subscriptions can still take one-off payments through the same account. Set that flag correctly on anything new you add.

Outbound Webhook Events

The subscribed organisation’s own webhook subscriptions (/Admin/Webhooks) can receive four events. Each fires only when the status actually changes — a renewal that rolls an active period forward, a plan switch that stays active, or a daily dunning retry that stays past due sends nothing.

Event Fired when
subscription.activated The subscription becomes active or starts a trial: a free plan or trial is chosen, a checkout is confirmed (on the buyer’s return, by the billing webhook or by the sweep), a past-due renewal retry succeeds, or a cancellation is resumed
subscription.past_due A renewal or the end of a paid trial could not be charged
subscription.cancelled An organisation admin cancels at /Admin/Billing, or an operator at /Platform/Billing (at period end or immediately)
subscription.expired A trial, a cancelled period or the dunning grace runs out, or an abandoned checkout is closed

The payload carries the subscription id, organisation id, plan id, the new and previous status (previous is null for a first subscribe), and the period and trial end dates. Delivery is best effort: a failure to publish never fails the billing operation that triggered it.