Skip to content
BuildBase

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.

  1. 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.
  2. 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.
  3. 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.
  4. Cap free containers per person; sell paid ones on their own plan. workspaces-owned-per-user on a personal plan caps how many workspaces a person owns, and with countRule.unpaidOnly it 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.
  5. 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.
  6. 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:

OptionAnswers for
noneThe 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: trueA 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

RouteWhat it answers
GET /api/v1/public/me/subscriptionThe person's subscription, standing and entitlement
GET /api/v1/public/me/subscription/entitlementThe entitlement alone
GET /api/v1/public/me/subscription/plan-groupThe plans a person can pick from; 404 when you sell none
POST /api/v1/public/me/subscription/checkoutA checkout on the person's own Stripe customer
POST /api/v1/public/me/entitlements/checkOne slug for subjects in order, each { "user": true } or a workspace
GET /api/v1/public/me/limits/:slugThe ceiling, claimed and remaining
GET /api/subscriptions/users/:userId/entitlementA 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.

ResourceSubjectCheck
A personal API keyThe personapi-keys-per-user on { user: true }
An agent the person connects through OAuthThe personconnected-agents-per-user, claimed for you at consent
A project, record or file in a workspaceThe workspaceThe workspace's limit or quota
A call made with a personal key that spends a workspace's quotaBothThe key on the person, the quota on the workspace
A workspace the person createsThe personworkspaces-owned-per-user, counted at create
A seatThe workspaceThe 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"}'
AnswerWhen
201A new claim; the body has the claim, claimed and the limit
200 with existing: trueThat resource already held a claim; claiming it again is the same claim
402 PLAN_LIMIT_REACHEDAt the ceiling; upgradeTarget is user or workspace, whose plan to raise
403 CEILING_REACHEDA workspace limit you marked as an end-user boundary; the body names no plan
409 CLAIM_LINKEDA slug BuildBase claims itself, such as connected-agents-per-user
429 CLAIM_CAP_REACHEDToo many active claims on a slug with no ceiling
400 naming metadatametadata takes more than 2,048 bytes as JSON
404No 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 BUSY and 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.

EventKey fields
subscription.*, payment.*subject, plus workspaceId for a workspace
oauth.consent_granteduserId, clientId, actorId, scope
oauth.consent_revokeduserId, clientId, reason
entitlement.ceiling_reachedworkspaceId, slug, limit, current, userId, at
quota.threshold_reachedsubjectType, 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 hookWhile unknownWhy
useEntitlement, <WhenEntitled>AllowsA 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 nothingNo upsell for something the person may already have
<WhenLimitReached>Renders nothingNever a wall before the answer
<WhenWithinLimit>RendersThe same rule from the other side
WhenWorkspaceFeatureEnabledRenders nothingA flag your users toggle; unset means off
WhenUserFeatureEnabledRenders nothingA 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 404 PAYER_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 (400 PAYER_SUBJECT_KIND).
  • One payer pays for at most maxChildren workspaces, 100 unless the org sets another number with PATCH /api/organizations/settings/payers (409 PAYER_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.

IdScenarioTodayHow
S01Personal API keys owned by the personSupportedClaims on api-keys-per-user; your app issues the key
S02Workspace-owned keys and service accountsSupportedClaims on service-accounts-per-workspace; your app issues the credential
S03One person owns several separately billed workspacesSupportedOne subscription per workspace
S04An agent or connector the person authorizes through OAuthSupportedconnected-agents-per-user, claimed at consent; the consent events
S05An app installed into a workspaceSupportedClaims 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
S06Plans for people and plans for teams in one appSupportedA pricing group per payer type, items scoped to each
S07The owner of a resource supplies the planSupportedAn explicit subject on every check
S08One metric capped per person and per workspaceSupporteduseLimit with subjects and resolve: 'max'
S09A team plan gives each member something of their ownSupportedmemberGrants and ownerGrants on the workspace plan
S10Seat plans with seat types and included seatsPartlySeat counting by role, a minimum and seat blocks; a second seat type sold as a seat class; a seat true-up is not supported
S11A per-member allowance inside a team planSupportedperMember quotas, with a fallback to the pool
S12A per-person cap on free workspacesSupportedworkspaces-owned-per-user with countRule.unpaidOnly
S13A container count sold on the paying container's planPartlyClaims on a workspace limit; modules that count their own children adopt it one by one
S14Licensing installations of your self-hosted productPartlyClaims count installations; the lifecycle on the box is yours
S15Device slots on a personal planSupportedA claim per signed-in device, with a release budget
S16Projects or sites under one person's cardSupportedOne workspace per project, each paid for by the person's card (payer)
S17A parent account paying for many child organizationsSupportedA workspace pays for others (payer), one level deep, up to maxChildren, read together through the payer roll-up
S18A person spending a personal allowance inside a workspacePartlyAn operator can grant the workspace the item; a person moving their own allowance into a workspace is not supported
S19Moving between workspaces; a team seat replacing a personal planPartlysupersedes 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
S20A family or group plan run by one personSupportedA workspace plan with memberGrants, perMember, maxMembers and a change cooldown. See Family and group plans
S21An allowance, then refuse, wait or meter under a spend capSupportedSpend 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
S22A trial once per personSupportedTrial records per person, on their own plans
S23Over the ceiling and past a failed paymentPartlylapsePolicy: 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
S24Agent and service identities beside peopleSupportedNever counted as seats; agent-identities-per-workspace claimed when one joins, and sold per agent as an add-on
S25Operator grants, overrides and promotional creditPartlyOperator grants per item with a reason and an optional end, and comped plans; operator credit grants and revokes take a workspace only for now
S26One check naming both subjects, saying which decidedSupportedbb.entitlements.check and POST /me/entitlements/check
S27An end user meets a workspace ceilingSupportedendUserBoundary on the item, a neutral refusal, entitlement.ceiling_reached
S28A consumer app with no teamsSupportedA pricing group for users; move-to-user for plans bought on a workspace
S29A personal account kept beside team membershipSupporteddefaultWorkspaceId on the profile
S30One person in several paid workspacesSupportedseatScope: 'payer' bills a person once across the workspaces one payer pays for
S31A payer who is not the entitled subjectSupportedpayer on the workspace checkout; transfer-payer moves it
S32Prepaid credits on a person or a workspaceSupportedCredit 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
S33Who buys, who sees billing, whether the payer takes a seatSupportedBilling permissions, seat counting that leaves roles out, billing contacts
S34Spend caps for parts of one billPartlyCaps 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.