# 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.

```tsx
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:

```tsx
<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](/billing/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.
- `floor` or `add`: 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](/tracking/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()`:

```tsx
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

```typescript
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`.
