HyperSaaS
FrontendBilling

Billing

A workspace's plan, credit and usage, Stripe Checkout and the billing portal.

A plan belongs to a workspace, so billing lives in the workspace's settings: Billing & usage, at /dashboard/workspaces/{id}/settings/billing. It opens from the workspace menu at the top of the sidebar, from the credit meter at the bottom, and from /dashboard/billing, which redirects to the billing page of the workspace used last.

Everything on the page comes from one backend call, GET /api/workspaces/{id}/plan/ (see Plans): the plan and its limits, the subscription, who pays for it, what the viewer may do, and usage.

The Billing Page

SectionShows
Current planName, status (Active, Trial, Payment due, Cancels on…), price and interval, when the trial ends or the plan renews. Manage billing for the person who pays; for anyone else, "Billing is managed by …"
UsageAI credit used against the period's credit, with when it renews; documents and storage against the plan's limits; largest file; which models the plan includes. Amber from 80% of the credit, red when it's used up
UpgradeShown while the workspace has no paid plan. Monthly or yearly, the paid plans with their features, Team highlighted. Members who aren't the owner or an admin see the plans with the buttons disabled

The sidebar shows the same credit as a small meter on every workspace page.

Checkout

Choosing a plan posts the price and the workspace, and the backend answers with Stripe's URL for the session:

const result = await callApi<{ url: string }>("/api/subscriptions/checkout", {
  method: "POST",
  body: { price_id: price.price_id, workspace_id: workspace.id },
});
if (result.ok) window.location.assign(result.data.url);

No Stripe.js is needed. Only the workspace's owner and admins can start checkout, for a workspace without a subscription; changing plan afterwards happens in the billing portal.

Stripe returns to the same billing page:

  • ?checkout=success: the page shows "Activating your plan…" and checks /plan/ every two seconds until the webhook has attached the subscription, then refreshes. If that takes more than 30 seconds, it says the plan will appear shortly.
  • ?checkout=canceled: a note that nothing was charged.

Billing Portal

The Stripe portal belongs to the customer who pays. Manage billing asks for a portal session for this workspace:

const result = await callApi<{ url: string }>("/api/subscriptions/create-portal-link", {
  method: "POST",
  body: { workspace_id: workspace.id },
});
if (result.ok) window.location.assign(result.data.url);

The backend opens it only for whoever pays for that workspace's subscription, and the portal's return link comes back to the workspace's billing page. Plan changes, cards, invoices and cancelling all happen there.

Limits in the UI

Limits are enforced by the backend, which refuses with a message that names the limit ("Insufficient credits…", "The Free plan doesn't include …", a document or storage limit). The app shows that message rather than repeating the plan's rules, so the two can't drift apart. The chat's model picker locks the models the plan doesn't include, from GET /api/workspaces/{id}/ai-models/.

API Routes

RouteMethodBackend
/api/workspaces/{id}/planGET/api/workspaces/{id}/plan/
/api/subscriptions/checkoutPOST/api/subscriptions/checkout/ (price_id and workspace_id required)
/api/subscriptions/create-portal-linkPOST/api/subscriptions/customer-portal/ (with workspace_id)

Each passes on the backend's status and message. Prices for the page are read on the server from /api/subscriptions/subscribable-product/. Stripe's webhook goes straight to the backend, at /api/subscriptions/webhook.

Configuration

The frontend needs no Stripe keys. The secret key and webhook secret are configured on the backend.

On this page