Plans for Users vs Plans for Workspaces
Sell a plan to a person, to a workspace, or to both, and check each one by naming whose plan you mean.
A plan belongs to one of two subjects. A workspace plan is billed to the workspace and covers what the workspace holds: its members, seats and data. A user plan is billed to one person, on their own card, and covers what is theirs: their API keys, the agents they connect, their devices. BuildBase never merges the two. Every check says whose plan it reads, and a screen that cares about both asks twice.
Note
Requires @buildbase/sdk 1.0.0 or later. The entitlement hooks and gates on this page, usePreviewSubscriptionChange and useUpcomingInvoice arrive with it.
import { useEntitlement } from '@buildbase/sdk/react';
function ProjectToolbar({ workspaceId, projectCount }: ToolbarProps) {
// The person's own plan: their assistant goes wherever they work.
const assistant = useEntitlement('ai-assistant', {
subject: { user: true },
});
// The workspace's plan: how many projects this workspace may hold.
const projects = useEntitlement('max-projects', {
subject: { workspaceId },
current: projectCount,
});
return (
<>
{projects.allowed ? <NewProjectButton /> : <UpgradeWorkspaceCard />}
{assistant.allowed ? <AssistantButton /> : <UpgradeAccountCard />}
</>
);
}Before you start
Personal plans live in a pricing group whose payer type is user. In the
console, go to Billing > Pricing groups, press Create pricing group and
set Who pays to A person (it locks once a version is published), then
press Set default on its row to make it the group people buy from by
default; the default's Free plan is also the one a person with no plan is
given. With the admin API, it is POST /api/subscriptions/groups with
"payerType": "user" and "isDefault": true. A <PricingPage for="user" slug="..."> offers its own group in the
person's Billing whether or not it is the default, and an org with exactly
one published group for people needs no default for people to buy from it.
Items on it take "scope": "user". A group's payer type is fixed once a
version of it is published.
Note
The { user: true } subject on the SDK hooks needs an SDK release with user
plans. The routes under /api/v1/public/me answer today, for the signed-in
person.
The rules
Six rules decide every case on this page. When a question is not covered below, one of these answers it.
- A credential is never the thing that pays. An API key, an OAuth grant or a device belongs to whoever holds it. The plan that counts is the plan of whoever owns what the credential touches.
- Every check names its subject.
{ user: true }is the signed-in person and{ workspaceId }is a workspace. Leave the subject out and the SDK answers for the selected workspace, never for the person. With a person's credential, check the person's plan for what is theirs (keys, connected agents, devices) and the workspace's plan for what belongs to the workspace. Nothing is inferred from the credential alone. - Plans do not bleed across subjects. A personal upgrade does not lift a workspace, and a workspace plan does not lift its members. The one bridge is one you set yourself: a workspace plan can grant its owner or every member a personal item (
ownerGrants,memberGrants), written on each person for as long as the workspace's plan grants it. When you want "either plan will do", ask both and combine the answers. - Cap free containers per person; sell paid ones on their own plan.
workspaces-owned-per-useron a personal plan caps how many workspaces a person owns, and withcountRule.unpaidOnlyit counts only the ones without a paid plan. That is the usual shape, not a constraint: you can cap paid ones too. Unlimited (-1) sets no ceiling from the plan and no per-day creation limit. The org's Max workspaces per user setting (workspace settings in the console) still caps it at the lower of the two, and binds the first workspace created for a person at sign-up too, so set it when a free workspace costs you something. - A consumer app sells to people. Declare a pricing group for users. Personal mode is a UI preset that gives each person one workspace and hides the switcher; it does not move any plan. See Personal mode.
- A plan is not a permission. No role check reads a plan, no plan feature becomes a token scope, and a role a person holds never answers false because their plan lacks a feature. When an action needs both a role and a plan feature, check both.
Checking a plan
useEntitlement and useLimit (see Entitlement checks) take the subject as an option:
| Option | Answers for |
|---|---|
| none | The selected workspace |
subject: { workspaceId } | That workspace |
subject: { user: true } | The signed-in person's own plan |
subjects: [...] with resolve: 'any' | Allowed when any named subject allows it (a feature) |
subjects: [...] with resolve: 'max' | The higher of the named ceilings (a limit) |
member: true | A per-member allowance inside the workspace plan, against the pool |
subjects is how you say "either plan": a feature a person can have on their own plan or through their team's. A plan that does not mention the feature does not outvote one that turns it off: a person whose plan locks it, in a workspace whose plan never names it, reads locked. When no named plan mentions it, the answer is unknown, which allows (unless strictAbsent).
const exports = useEntitlement('pdf-export', {
subjects: [{ user: true }, { workspaceId }],
resolve: 'any',
});source on the answer says which layer decided it, as it does for a workspace. The subscription and billing hooks take the same subject beside their workspace forms: useSubscription({ user: true }), usePreviewSubscriptionChange({ user: true }), useUpcomingInvoice({ user: true }) and useCreateCheckoutSession({ user: true }).
import { useCreateCheckoutSession } from '@buildbase/sdk/react';
function UpgradeAccountButton({ planVersionId }: { planVersionId: string }) {
const { createCheckoutSession, loading } = useCreateCheckoutSession({
user: true,
});
return (
<button
disabled={loading}
onClick={() => createCheckoutSession({ planVersionId })}
>
Upgrade your account
</button>
);
}The checkout is on the person's own Stripe customer, so the card and the invoices are theirs, not a workspace's.
On your server, bb.entitlements.check({ slug, workspaceId, user, current, wanted }) answers once per subject you name, in the order you name them, each with its source, and bb.entitlements.get({ user }) returns the person's whole answer. bb.me.* is the signed-in person's billing (with bb.me.subscription.preview and bb.me.subscription.getUpcomingInvoice) and bb.users.* a named person's, under an org API token you pass as BuildBase({ apiToken }). On the MCP server the person's tools are get_user_subscription, get_user_entitlement, get_user_plans, check_user_limit, claim_user_limit, release_user_claim, preview_user_plan_change and get_user_upcoming_invoice.
The routes underneath
| Route | What it answers |
|---|---|
GET /api/v1/public/me/subscription | The person's subscription, standing and entitlement |
GET /api/v1/public/me/subscription/entitlement | The entitlement alone |
GET /api/v1/public/me/subscription/plan-group | The plans a person can pick from; 404 when you sell none |
POST /api/v1/public/me/subscription/checkout | A checkout on the person's own Stripe customer |
POST /api/v1/public/me/entitlements/check | One slug for subjects in order, each { "user": true } or a workspace |
GET /api/v1/public/me/limits/:slug | The ceiling, claimed and remaining |
GET /api/subscriptions/users/:userId/entitlement | A named person's entitlement, under an org API token |
GET /api/subscriptions/users/entitlements/check | ?slug=&subjects=user:<id>,workspace:<id>, answered in that order |
GET .../plan-group answers the group of the person's own plan; else the group ?slug= names, which must be a published, non-archived group whose payer type is user (anything else is the same 404); else your default group for people; else, when you publish exactly one group for people, that one. <PricingPage for="user"> sends its slug, so the person's Billing offers the plans the page listed.
The /me routes also carry the plan change, cancel, resume, pause, billing portal, invoices and the person's credit wallet, each the twin of the workspace route with the same body. A workspace the person is not a member of is refused on the check with 403 before anything about it is read.
Who checks what
Most of the work is deciding which subject a resource belongs to. Ask who owns the thing being created or spent, not who is signed in.
| Resource | Subject | Check |
|---|---|---|
| A personal API key | The person | api-keys-per-user on { user: true } |
| An agent the person connects through OAuth | The person | connected-agents-per-user, claimed for you at consent |
| A project, record or file in a workspace | The workspace | The workspace's limit or quota |
| A call made with a personal key that spends a workspace's quota | Both | The key on the person, the quota on the workspace |
| A workspace the person creates | The person | workspaces-owned-per-user, counted at create |
| A seat | The workspace | The workspace plan's seat settings |
A user plan is never a seat in a workspace. Seats, seat counting and seat prices stay on the workspace plan, and publishing a plan for people refuses seat pricing other than a price per installation. Its quotas may bill overage, as a workspace plan's do: a person's overage is metered to their own Stripe customer on their own subscription, and held to their own spend cap when they set one with PATCH /api/v1/public/me/quotas/:slug.
A plan sold to a person cannot grant credits yet. Publishing a person's plan that grants credits is refused with 400 and field: 'creditGrant': turn the credit grant off, or sell the plan to workspaces. A person still holds a credit wallet of their own and buys credit packs into it. An operator's credit grant or revoke takes a workspace only for now; the console shows a person's balance, holds and automatic top-up on their page, read only.
Personal API keys
BuildBase counts a person's API keys; it does not issue them. Your app mints and stores the key, and holds a slot for it on api-keys-per-user with the key's id as the resourceId, released when the key is revoked. That is the prerequisite: until your app has its own key object, there is nothing to count. A call made with that key is then checked twice, as the table says: the key against the person's plan, and what it spends against the workspace that owns it.
The routes for a named person, /api/subscriptions/users/:userId/..., take an org API token from Settings > API keys in the console, never your end user's session. See Admin API.
Claims
A claim holds one named thing against a limit: a key, an installed app, a project. Claim when it is created, release when it goes, and BuildBase keeps the count, so two requests one below the ceiling never both get in.
curl -X POST https://api.console.buildbase.app/api/v1/public/me/limits/api-keys-per-user/claim \
-H "x-session-id: $SESSION_ID" \
-H "Content-Type: application/json" \
-d '{"resourceId": "key_7Hc2"}'| Answer | When |
|---|---|
201 | A new claim; the body has the claim, claimed and the limit |
200 with existing: true | That resource already held a claim; claiming it again is the same claim |
402 PLAN_LIMIT_REACHED | At the ceiling; upgradeTarget is user or workspace, whose plan to raise |
403 CEILING_REACHED | A workspace limit you marked as an end-user boundary; the body names no plan |
409 CLAIM_LINKED | A slug BuildBase claims itself, such as connected-agents-per-user |
429 CLAIM_CAP_REACHED | Too many active claims on a slug with no ceiling |
400 naming metadata | metadata takes more than 2,048 bytes as JSON |
404 | No LIMIT item declares the slug and no plan limits it |
POST .../release with the same resourceId gives the slot back, and answers 409 RELEASE_BUDGET_EXHAUSTED with resetsAt when the item's claimPolicy.maxReleasesPerPeriod is spent. GET .../claims lists them, active by default (?status=released or all). The workspace twins are under /api/v1/public/workspaces/:workspaceId/limits/:slug: claim and release need permission to record usage, and the reads need billing view.
resourceId and partitionKey match ^[A-Za-z0-9._:-]{1,128}$. With a partitionKey, the ceiling counts per partition: three installed apps per project, rather than three per workspace.
Count each slug one way. A slug is counted by claims or by the current your app passes, never both. An app that claims some keys and also passes its own key count counts them twice.
What unlimited means
A limit of -1 reads as unlimited: limit: null and unlimited: true. Three bounds stay on behind it, so a bug in a loop is slowed and seen rather than billed:
- Claim and release requests are limited to 120 a minute per subject.
- A slug with no ceiling holds at most 10,000 active claims per subject, slug and partition by default; past that,
429 CLAIM_CAP_REACHED. - Creating a workspace takes a per-person lease for up to ten seconds, so parallel creates are counted one at a time; the second answers
409 BUSYand can retry.
Webhook payloads
Every subscription.* and payment.* event names its subject, so one handler serves both kinds of plan:
{
"event": "subscription.created",
"timestamp": 1760000000,
"data": {
"subject": { "type": "user", "id": "66f0c1d2e3a4b5c6d7e8f901" },
"subscriptionId": "sub_1Q2w3E4r",
"planVersionId": "66f0c1d2e3a4b5c6d7e8f9aa",
"amount": 1200,
"currency": "usd"
}
}A person's plan carries subject with type: "user" and no workspaceId. A workspace's carries both, subject.type being workspace. Branch on subject.type, not on whether workspaceId is there.
| Event | Key fields |
|---|---|
subscription.*, payment.* | subject, plus workspaceId for a workspace |
oauth.consent_granted | userId, clientId, actorId, scope |
oauth.consent_revoked | userId, clientId, reason |
entitlement.ceiling_reached | workspaceId, slug, limit, current, userId, at |
quota.threshold_reached | subjectType, subjectId, quotaSlug, percent, resetsAt |
oauth.consent_granted fires the first time a person allows an agent; widening the scopes of a consent they hold is not a new one. reason on oauth.consent_revoked is user_disconnected, consent_revoked (your app revoked it for them) or client_deleted. entitlement.ceiling_reached fires when one of your end users meets a workspace limit you marked endUserBoundary: they get a neutral refusal, and you hear about it at most once an hour per workspace and slug. See Webhooks.
Two customers, one email
A person who holds a plan of their own and owns a workspace with a plan of its own appears in your Stripe account as two customers with the same email. Tell them apart by metadata: the person's customer carries subjectType: "user" and userId, the workspace's carries workspaceId. A promotion code limited to first-time customers treats the person as new once per customer, so they can use it once on each.
Flags are not entitlements
A user feature flag (WhenUserFeatureEnabled, useUserFeatures()) is a switch you target from the dashboard: on or off for a person, a list or an audience, with no value, no expiry and no source. It never carries a plan or a grant. A user plan's features and limits are read with useEntitlement(slug, { subject: { user: true } }). Use the flag to roll out a feature; use the entitlement to sell it.
When the answer is unknown
Each gate fails one way on purpose, and nothing locks while the answer loads.
| Gate or hook | While unknown | Why |
|---|---|---|
useEntitlement, <WhenEntitled> | Allows | A slug an older plan version never named, or a failed read, must not lock a customer out. The server still enforces the real limit |
<WhenNotEntitled> | Renders nothing | No upsell for something the person may already have |
<WhenLimitReached> | Renders nothing | Never a wall before the answer |
<WhenWithinLimit> | Renders | The same rule from the other side |
WhenWorkspaceFeatureEnabled | Renders nothing | A flag your users toggle; unset means off |
WhenUserFeatureEnabled | Renders nothing | A flag targets people explicitly; not targeted means off |
Pass whileUnknown="deny" to an entitlement gate, or whileUnknown="allow" to WhenWorkspaceFeatureEnabled, to make them agree. strictAbsent reads a slug the plan does not mention as locked; see Fails open.
Personal mode
Personal mode is a UI preset; a B2C plan belongs on the user subject. The preset gives each person one workspace and hides the switcher, and it moves no plan: a plan bought inside it still belongs to that workspace. A consumer app sells to the person instead, from a pricing group whose payer type is user, through POST /api/v1/public/me/subscription/checkout, so the plan is billed to their own card and covers them wherever they work. A person can also hold a plan of their own beside a workspace plan: they are two subjects, and nothing stops both.
An app that already sold plans on Personal-mode workspaces moves each customer's plan onto the person with POST /api/v1/public/workspaces/:workspaceId/subscription/move-to-user: the plan moves to whoever calls it, and they need permission to manage the workspace's billing. It opens a checkout on the person's own customer that starts when the workspace's period ends, with no proration and no trial, so nothing already paid for is charged again. The workspace plan stays live until that checkout completes, so for a while the person holds both; then the workspace plan is cancelled and its usage moves with the person. A paused subscription cannot be moved, nor one within an hour of renewing.
Several workspaces on one card
A workspace can be paid for by someone other than itself: the signed-in person, or another workspace they manage billing for. Pass payer on the workspace checkout, and the subscription is created on the payer's Stripe customer, so one card pays for every project. Each workspace keeps its own plan and its own subscription, and its entitlement still comes from its own plan: a payer's plan never lifts a workspace it pays for.
import { useCreateCheckoutSession } from '@buildbase/sdk/react';
function PayWithMyCard({
workspaceId,
me,
}: {
workspaceId: string;
me: string;
}) {
const { createCheckoutSession } = useCreateCheckoutSession(workspaceId);
return (
<button
type="button"
onClick={() =>
createCheckoutSession({
planVersionId: 'plan-version-id',
payer: { type: 'user', id: me },
})
}
>
Pay for this project with my card
</button>
);
}The rules, each checked before anything reaches Stripe:
- A person pays only as themselves, and a workspace pays only for someone holding its billing-manage permission (403
PAYER_NOT_ALLOWED; a payer that does not exist is 404PAYER_NOT_FOUND). - One level: a workspace someone else pays for cannot pay for others, and a workspace that pays for others cannot be paid for (409
PAYER_IS_PAID_FOR,SUBJECT_IS_PAYER). A person's own plan is never paid for by someone else (400PAYER_SUBJECT_KIND). - One payer pays for at most
maxChildrenworkspaces, 100 unless the org sets another number withPATCH /api/organizations/settings/payers(409PAYER_CHILD_LIMIT). - A Stripe customer holds one currency, so a payer billed in one currency is refused a plan in another (409
PAYER_CURRENCY_MISMATCH). - Two plans on one payer may not bill overage on the same quota: usage is counted per customer, so each would be billed both (409
PAYER_METERED_OVERLAP).
GET /api/v1/public/billing/payers/:payerId/rollup lists what one payer pays for, each workspace with its plan and status. POST /api/v1/public/workspaces/:workspaceId/subscription/transfer-payer moves a workspace's subscription to another payer, or back to the workspace itself: a checkout on the new payer's customer, on the same plan, that starts when the current period ends, with nothing charged twice. It is refused during a trial, for a paused or ending subscription, within an hour of renewal, and while the subscription carries recurring add-ons (409 TRANSFER_NOT_POSSIBLE; NO_PAID_SUBSCRIPTION when there is no Stripe subscription to move, SAME_PAYER when that payer already pays). Every payer code is listed in Error codes. The webhook events payer.subject_added and payer.subject_removed say when a payer starts and stops paying for a workspace.
A seat-priced plan can bill a person once across everything one payer pays for: set seatScope: 'payer' on its seat pricing, and a member who already takes a seat in an earlier workspace of the same payer takes none in the next.
Licensing your own self-hosted product
If you sell a product your customers run on their own servers, count installations with claims: one claim per installation id, on a limit such as installations-per-workspace. The check your product runs on the box is advisory. Activation, heartbeats and grace periods for a box that goes quiet stay in your product; BuildBase counts the installations and enforces the ceiling when a new one is claimed.
What each scenario needs
The 34 ways products bill people and teams, and where each stands today.
| Id | Scenario | Today | How |
|---|---|---|---|
| S01 | Personal API keys owned by the person | Supported | Claims on api-keys-per-user; your app issues the key |
| S02 | Workspace-owned keys and service accounts | Supported | Claims on service-accounts-per-workspace; your app issues the credential |
| S03 | One person owns several separately billed workspaces | Supported | One subscription per workspace |
| S04 | An agent or connector the person authorizes through OAuth | Supported | connected-agents-per-user, claimed at consent; the consent events |
| S05 | An app installed into a workspace | Supported | Claims on a slug you declare, such as installed-apps-per-workspace; more slots or a paid app sold as a recurring add-on. See Apps installed into a workspace |
| S06 | Plans for people and plans for teams in one app | Supported | A pricing group per payer type, items scoped to each |
| S07 | The owner of a resource supplies the plan | Supported | An explicit subject on every check |
| S08 | One metric capped per person and per workspace | Supported | useLimit with subjects and resolve: 'max' |
| S09 | A team plan gives each member something of their own | Supported | memberGrants and ownerGrants on the workspace plan |
| S10 | Seat plans with seat types and included seats | Partly | Seat counting by role, a minimum and seat blocks; a second seat type sold as a seat class; a seat true-up is not supported |
| S11 | A per-member allowance inside a team plan | Supported | perMember quotas, with a fallback to the pool |
| S12 | A per-person cap on free workspaces | Supported | workspaces-owned-per-user with countRule.unpaidOnly |
| S13 | A container count sold on the paying container's plan | Partly | Claims on a workspace limit; modules that count their own children adopt it one by one |
| S14 | Licensing installations of your self-hosted product | Partly | Claims count installations; the lifecycle on the box is yours |
| S15 | Device slots on a personal plan | Supported | A claim per signed-in device, with a release budget |
| S16 | Projects or sites under one person's card | Supported | One workspace per project, each paid for by the person's card (payer) |
| S17 | A parent account paying for many child organizations | Supported | A workspace pays for others (payer), one level deep, up to maxChildren, read together through the payer roll-up |
| S18 | A person spending a personal allowance inside a workspace | Partly | An operator can grant the workspace the item; a person moving their own allowance into a workspace is not supported |
| S19 | Moving between workspaces; a team seat replacing a personal plan | Partly | supersedes on memberGrants keeps or cancels the personal plan; move-to-user moves a plan to a person; moving a plan into a workspace means cancelling and buying again |
| S20 | A family or group plan run by one person | Supported | A workspace plan with memberGrants, perMember, maxMembers and a change cooldown. See Family and group plans |
| S21 | An allowance, then refuse, wait or meter under a spend cap | Supported | Spend caps, resetsAt and quota.threshold_reached on a workspace or a person; a personal plan's quota bills overage like a workspace's, capped through PATCH /api/v1/public/me/quotas/:slug |
| S22 | A trial once per person | Supported | Trial records per person, on their own plans |
| S23 | Over the ceiling and past a failed payment | Partly | lapsePolicy: free and suspend apply today; read_only is stored and reported as the lapse's then on a lapsed subject's answer, but does not yet stop a workspace's writes |
| S24 | Agent and service identities beside people | Supported | Never counted as seats; agent-identities-per-workspace claimed when one joins, and sold per agent as an add-on |
| S25 | Operator grants, overrides and promotional credit | Partly | Operator grants per item with a reason and an optional end, and comped plans; operator credit grants and revokes take a workspace only for now |
| S26 | One check naming both subjects, saying which decided | Supported | bb.entitlements.check and POST /me/entitlements/check |
| S27 | An end user meets a workspace ceiling | Supported | endUserBoundary on the item, a neutral refusal, entitlement.ceiling_reached |
| S28 | A consumer app with no teams | Supported | A pricing group for users; move-to-user for plans bought on a workspace |
| S29 | A personal account kept beside team membership | Supported | defaultWorkspaceId on the profile |
| S30 | One person in several paid workspaces | Supported | seatScope: 'payer' bills a person once across the workspaces one payer pays for |
| S31 | A payer who is not the entitled subject | Supported | payer on the workspace checkout; transfer-payer moves it |
| S32 | Prepaid credits on a person or a workspace | Supported | Credit wallets on either subject, with automatic top-up; consumable add-ons with consumeOrder and rolloverCapUnits; a plan sold to a person cannot grant credits yet |
| S33 | Who buys, who sees billing, whether the payer takes a seat | Supported | Billing permissions, seat counting that leaves roles out, billing contacts |
| S34 | Spend caps for parts of one bill | Partly | Caps in units per partitionKey: partitionCaps on the workspace's PATCH /api/v1/public/workspaces/:workspaceId/quotas/:slug, each partition held to the lesser of its cap and the workspace's room; caps in money are not supported |
Next steps
- Entitlement checks - The answer, its
source, and the gates. - Webhooks - Every event and its payload.
- Add-ons - Offers sold to a workspace or to a person, and the grants they write.
- Credits & usage - Credit wallets, which a person can hold too.