Entitlement Checks
Gate a feature, a limit or a quota by its slug, with the answer the server composes for each workspace.
Gate what a workspace may do by the slug your code names, not by the plan it is on. BuildBase composes one answer per workspace, the plan plus anything granted on top of it, and the SDK reads it. A check follows a plan change, an add-on or an override on its own.
Note
Requires @buildbase/sdk 1.0.0 or later. useEntitlement, useLimit, the When... gates on this page and invalidateEntitlement arrive with it; @buildbase/sdk 0.0.90 has none of them. On a server without the entitlement, the hooks read the older fields: a workspace feature as WhenWorkspaceFeatureEnabled reads it, a limit only for members who can read the subscription, and a quota from its usage.
import {
useEntitlement,
useLimit,
WhenEntitled,
WhenWithinLimit,
} from '@buildbase/sdk/react';
function Projects({ projects }: { projects: Project[] }) {
const analytics = useEntitlement('advanced-analytics');
const limit = useLimit('max-projects', { current: projects.length });
return (
<>
<WhenEntitled
slug="advanced-analytics"
fallbackComponent={<UpgradeCard />}
>
<Analytics />
</WhenEntitled>
<WhenWithinLimit
slug="max-projects"
current={projects.length}
fallbackComponent={<UpgradeCard />}
>
<NewProjectButton />
</WhenWithinLimit>
{limit.pressure === 'nearing' && (
<p>{limit.remaining} projects left on your plan.</p>
)}
</>
);
}Why by slug
WhenSubscriptionToPlans plans={['grow']} answers "is this workspace on Grow". It is right for upsell copy and wrong for gating a capability: it breaks the day you add a plan, rename one, or sell the feature as an add-on. Gate on the slug instead. The plan editor sets what each plan includes, and the check reads it.
The answer
useEntitlement(slug, options?) answers a feature, a limit or a quota slug:
| Field | Meaning |
|---|---|
kind | feature, limit, quota, or unknown when the plan does not mention the slug |
state | granted, locked or unknown |
allowed, locked | allowed is state !== 'locked'; locked is state === 'locked' |
limit | The ceiling for a limit, included for a quota; null for a feature or when unlimited |
unlimited | The limit has no ceiling |
used, remaining | Your current for a limit; the workspace's usage for a quota |
source | Where the answer came from: plan, floor, add, override or default (see below) |
standing | entitled (a live plan), lapsed (payment failed past its retries, or paused) or none (no plan) |
planSlug | The plan's slug; null with no plan |
expiresAt | When the earliest grant behind the answer ends, if any |
loading, error | loading until this workspace's answer is in; error when it could not be read |
refetch | Read it again |
Options: current (how many the workspace has, for a limit), wanted (how many to add, default 1), subject: { workspaceId } for a workspace other than the selected one, and strictAbsent.
For a limit, pass the count you already hold: the screen that lists the projects knows how many there are. A quota counts as allowed while it has units left, or past that when the plan allows overage.
Fails open
Only an explicit no locks. While the answer loads nothing locks, a failed read is unknown, and a slug the plan does not mention is unknown, all of which allow. That keeps a workspace on an older plan version, written before you added a slug, from losing the feature the day you start gating on it.
Once every plan version you publish carries every slug you gate on, read an unmentioned slug as locked:
<SaaSOSProvider entitlement={{ strictAbsent: true }} {...rest}>or per check, useEntitlement('advanced-analytics', { strictAbsent: true }).
A workspace with no plan, or one whose payment lapsed, is not unknown: its answer is known, and its paid features read locked. standing says which. See Failed payments for how a payment lapses.
Where the answer came from
source names the layer that decided the slug:
plan: the plan the workspace holds. During a downgrade that waits for the end of the period, features and limits stay on the plan being left until then, and quotas move at once.floororadd: a grant on top of the plan, such as an add-on or a comp.override: a workspace feature the plan does not govern, read from the workspace's own setting.default: a workspace feature at its default.
Limits
useLimit(slug, { current }) reads a limit against your count: limit, unlimited, used, remaining, reached and pressure. pressure is quiet, nearing or reached; nearing is the last fifth, never less than the last one, so a plan of three reads nearing at two. An unlimited or unknown limit never reads reached, and a ceiling of zero always does.
Gates
| Component | Renders children when |
|---|---|
<WhenEntitled slug> | the workspace is entitled (unknown counts, by default) |
<WhenNotEntitled slug> | it is not: the upsell side |
<WhenLimitReached slug current> | the limit is reached |
<WhenWithinLimit slug current> | there is room, or the limit is unlimited or unknown |
<WhenUserEntitled slug> | the signed-in person's own plan and grants allow it |
Each takes loadingComponent and fallbackComponent, and the entitlement gates take whileUnknown: 'allow' (the default) treats an unknown answer as entitled, 'deny' as not. A gate showing its locked side reports feature_gate_shown, and a reached limit limit_gate_shown, once per page; see Events.
WhenWorkspaceFeatureEnabled stays the gate for a flag your users toggle. It renders nothing for a slug it has no value for, unless you pass whileUnknown="allow".
Keeping it fresh
The SDK's own billing calls (a checkout, a plan change, a cancellation) refresh the answer. After a change made somewhere else, call invalidateEntitlement():
import { invalidateEntitlement } from '@buildbase/sdk';
await fetch('/api/addons', { method: 'POST', body });
invalidateEntitlement();When the BuildBase API refuses a call with 402 and PLAN_LIMIT_REACHED or FEATURE_NOT_IN_PLAN, the SDK refreshes the answer for you, at most once every five seconds.
On your server
const { allowed, remaining } = await bb.entitlements.check({
workspaceId,
slug: 'max-projects',
current: await countProjects(workspaceId),
});
if (!allowed) {
return Response.json({ error: 'Project limit reached' }, { status: 402 });
}bb.entitlements.get({ workspaceId }) returns the whole answer. Both read the workspace, so a member without billing permission is answered too. On the MCP server the tool is check_entitlement.