# Entitlement Evaluation Patterns

A complete reference for boolean gates, limit checks, and metered entitlements — from resolution layering to caching and error handling.

<!-- docs-source -->
Source: https://learning.monetizekit.app/docs/guides/entitlement-patterns
<!-- /docs-source -->

Every gated feature, resource limit, and billable action in your product resolves through one of three entitlement check shapes. Picking the right shape — and branching on the right field in the response — is the single most common source of subtle monetization bugs. This guide walks through all three, how they resolve across plan/add-on/override layers, and how to wire them into REST, GraphQL, and SDK call sites correctly the first time.

## The three check shapes

| Shape | Answers | Endpoint | Possible verdicts |
| --- | --- | --- | --- |
| Boolean gate | Can this customer use the feature at all? | `GET /entitlements/{customerId}/{featureKey}` | `ALLOW`DENY`` |
| Limit check | Has the customer reached a hard cap on a resource? | `GET /entitlements/{customerId}/{featureKey}` | `ALLOW`DENY`` |
| Metered entitlement | Should this usage event be billed, and is the customer still within budget? | `POST /usage/events` | `RECORDED`DEGRADE`REQUIRE_TOP_UP`DENY`` |

## Node SDK method reference

The Node SDK exposes four methods that cover all three check shapes above — there is no separate "limit check" method; limit-type features are read from the same `entitlements.check()` call as boolean gates.

| Method | Used for |
| --- | --- |
| `mk.entitlements.check(customerId, featureKey)` | Boolean gates and limit checks (1 & 2 below) |
| `mk.entitlements.getAll(customerId)` | Rendering a full pricing/settings page (see FAQ) |
| `mk.usage.submit({ customerId, meterId, value, idempotencyKey })` | Metered entitlements (3 below) |
| `mk.usage.get(customerId, meterId)` | Raw meter consumption — not a limit check (see callout) |

> [!NOTE] Combined check: POST /entitlements/preflight
>
> For a single call that resolves entitlement, budget, and credit balance together (e.g. before starting a paid job), use `POST /entitlements/preflight` with `{ customerId, featureKey, estimatedValue, requiredCredits }`. It returns `allowed` plus nested `entitlement`/`budget`/ `credits` objects and a `reasons` array explaining any denial. This endpoint is REST and GraphQL only — the installed `@monetizekit/node` SDK has no `entitlements.preflight()` method yet, so call it with `fetch` directly.

## Decision verdicts

Every entitlement check resolves to exactly one of five verdicts. Branch on the verdict — inferred from the response fields below — never on raw HTTP status, so the same decision model works across REST, GraphQL, and every SDK.

| Verdict | Meaning | Typical response shape |
| --- | --- | --- |
| `ALLOW` | Entitled — serve the feature. | `{"allowed":true,"reasonCode":"granted","reason":"Entitlement grants access"}` |
| `DENY` | Not entitled — block and surface an upgrade path. | `{ "allowed": false, "reasonCode": "not_in_plan", "reason": "Feature is not included in the Pro plan" }` |
| `REQUIRE_TOP_UP` | A usage event was still recorded, but a require_topup budget was exceeded — prompt a top-up. | `{ "status": "success", "requiresTopup": true }  // from POST /usage/events` |
| `DEGRADE` | A usage event was still recorded, but a degrade budget was exceeded — serve a reduced tier. | `{ "status": "success", "degraded": true }  // from POST /usage/events` |
| `RECORDED` | Metered event accepted for later billing, no budget policy triggered; access continues. | `{"id":"usage_evt_9012","status":"success"}` |

> [!TIP] Best practice
>
> Never branch on the HTTP status code alone. A `403` can mean `DENY` (upsell) or the customer ran out of credits (`REQUIRE_TOP_UP`, which should route to a top-up flow, not an upgrade page). Inspect `allowed` and `reasonCode` together to select the correct verdict, and keep that mapping in one shared helper rather than duplicating it at every call site.

## Resolution layering

An entitlement's effective value is resolved by layering three sources, each capable of overriding the one before it:

| Layer | Precedence | Description |
| --- | --- | --- |
| Plan | Base | The subscription's plan version defines the default value for every feature key. |
| Add-on | Overrides Plan | Active add-ons raise or unlock entitlements on top of the plan (e.g. extra seats, priority support). |
| Override | Overrides Add-on | A per-customer override (granted manually or via a sales-negotiated contract) wins over plan and add-ons. |

> [!NOTE] Deterministic tie-breaking
>
> When two active add-ons define conflicting values for the same feature key (e.g. two seat packs), MonetizeKit takes the highest numeric value for limits and `true` for booleans — an add-on can only expand access, never restrict what the plan already grants. Overrides always win outright regardless of type.

## 1. Boolean gate: "can they use this at all?"

Use a boolean gate to hide or block an entire feature — a menu item, an API route, an export button. Check it once per request (or once per page load) and cache the result per [the caching guide](/docs/guides/caching) .

```bash
curl -s \
  -H "Authorization: Bearer $MONETIZEKIT_API_KEY" \
  "$MONETIZEKIT_BASE_URL/entitlements/cust_dev_1001/analytics_export"
# {"allowed":true,"reasonCode":"granted","reason":"Entitlement grants access"}
```

```javascript
import { MonetizeKit } from "@monetizekit/node";

const mk = new MonetizeKit({ apiKey: process.env.MONETIZEKIT_API_KEY });

const decision = await mk.entitlements.check("cust_dev_1001", "analytics_export");

if (!decision.allowed) {
  // Branch on `decision.reasonCode` — a stable machine-readable code
  // (e.g. "not_in_plan", "limit_reached"). `decision.reason` stays a
  // human-readable sentence for logs and support tickets.
  return res.status(403).json({
    code: decision.reasonCode,
    reason: decision.reason,
  });
}
```

```python
from monetizekit import MonetizeKit

mk = MonetizeKit(api_key=os.environ["MONETIZEKIT_API_KEY"])

decision = mk.entitlements.check("cust_dev_1001", "analytics_export")

if not decision.allowed:
    # Branch on decision.reason_code (stable enum, e.g. "not_in_plan");
    # decision.reason is the human-readable sentence for logs.
    raise HTTPException(status_code=403, detail=decision.reason)
```

```go
decision, err := client.Entitlements.Check(ctx, "cust_dev_1001", "analytics_export")
if err != nil {
    return err
}
if !decision.Allowed {
    // Branch on decision.ReasonCode (stable enum); Reason is for humans.
    return fmt.Errorf("feature denied (%s): %s", decision.ReasonCode, decision.Reason)
}
```

GraphQL has no single-feature equivalent of `GET /entitlements/{customerId}/{featureKey}` — fetch every effective entitlement for the customer and filter client-side for the key you need. See the [GraphQL Queries reference](/docs/graphql/queries) for the full operation shape:

```graphql
query CustomerEntitlements($customerId: String!) {
  customer(id: $customerId) {
    entitlements {
      featureKey
      allowed
      effectiveValueJson
      type
      sources
    }
  }
}
```

## 2. Limit check: "have they hit a cap?"

Use a limit check for capacity-bound resources such as seats, projects, or connected integrations — anything with a `usage` / `limit` / `remaining` triple rather than a simple on/off switch. Limit-type features are checked through the exact same endpoint as boolean gates — `GET /entitlements/{customerId}/{featureKey}` — the response just includes the extra fields when the feature is limit-typed. Treat 90%+ utilization as a soft `DEGRADE` signal for in-app nudges, and only hard-block once `remaining === 0`.

```bash
curl -s \
  -H "Authorization: Bearer $MONETIZEKIT_API_KEY" \
  "$MONETIZEKIT_BASE_URL/entitlements/cust_dev_1001/seats"
# { "allowed": true, "type": "limit", "usage": 8, "limit": 10, "remaining": 2, ... }
```

```javascript
// Limit checks use the SAME method as boolean gates — the SDK returns
// usage/limit/remaining directly on limit-type features, so there is no
// separate "usage.get()" call needed here.
const entitlement = await mk.entitlements.check("cust_dev_1001", "seats");

if (!entitlement.allowed) {
  return res.status(429).json({ reason: entitlement.reason });
}
if (
  entitlement.remaining !== undefined &&
  entitlement.limit !== undefined &&
  entitlement.remaining <= entitlement.limit * 0.1
) {
  // Soft threshold — DEGRADE: still allow, but warn the customer in-app.
  notifyApproachingLimit(entitlement);
}
```

```python
entitlement = mk.entitlements.check("cust_dev_1001", "seats")

if not entitlement.allowed:
    raise HTTPException(status_code=429, detail=entitlement.reason)

if entitlement.remaining is not None and entitlement.remaining <= entitlement.limit * 0.1:
    # Soft threshold — DEGRADE: still allow, but warn the customer in-app.
    notify_approaching_limit(entitlement)
```

> [!NOTE] Not a /usage/{customerId}/{meterId} call
>
> `GET /usage/{customerId}/{meterId}` reports raw meter consumption (`current`/`unit`/`trend`/`history`) for dashboards and analytics — it has no `limit` field and can't be used to enforce a cap. To enforce a limit, map the resource to a limit-type feature and check it via `GET /entitlements/{customerId}/{featureKey}` instead.

## 3. Metered entitlement: "record and keep serving"

Metered entitlements never block the request — they `RECORDED` the usage event for later billing while the feature keeps working. Always send a deterministic `Idempotency-Key` so retried requests (client timeouts, proxy retries) don't double-count usage; MonetizeKit deduplicates identical keys for 24 hours. The full lifecycle — batching, budget policies, and drift reconciliation — is covered in the [Usage Metering Best Practices guide](/docs/guides/metering) .

```bash
curl -s -X POST \
  -H "Authorization: Bearer $MONETIZEKIT_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: req:cust_dev_1001:req_8f2c1a" \
  -d '{"customerId":"cust_dev_1001","meterId":"meter_api_requests","value":1}' \
  "$MONETIZEKIT_BASE_URL/usage/events"
# {"id":"usage_evt_9012","customerId":"cust_dev_1001","meterId":"meter_api_requests","eventName":"api_request","value":1,"status":"success","occurredAt":"2026-06-14T00:00:00.000Z","createdAt":"2026-06-14T00:00:00.000Z","duplicate":false,"degraded":false,"requiresTopup":false}
```

```javascript
const key = `req:${customerId}:${requestId}`; // deterministic idempotency key

const result = await mk.usage.submit({
  customerId,
  meterId: "meter_api_requests",
  value: 1,
  idempotencyKey: key,
});
// result.status === "success" even though billing settles asynchronously —
// this is the RECORDED verdict: access is not gated on ingestion. Watch
// result.degraded / result.requiresTopup for soft/hard budget signals.
```

> [!WARNING] Don't gate on RECORDED
>
> A successful response from `POST /usage/events` only means the event was durably recorded — it says nothing about whether the customer is still within budget. Check `degraded` / `requiresTopup` on that same response (or subscribe to `usage.threshold` webhooks) if the caller also needs a `REQUIRE_TOP_UP` decision, e.g. before rendering a "low balance" banner.

> [!TIP] TypeScript caveat: the SDK's declared type lags the REST response
>
> `mk.entitlements.check()` and `mk.entitlements.getAll()` return the real fields shown above at runtime, but the installed `@monetizekit/node`'s declared return type doesn't yet include
> 
> `allowed` ,
> 
> `reason` ,
> 
> `usage` ,
> 
> `limit` ,
> 
> `remaining` ,
> 
> `planName` ,
> 
> `planVersion` ,
> 
> `latencyMs`
> 
> . Plain JavaScript call sites are unaffected; TypeScript call sites should widen the result type (e.g. `as EntitlementResult & FeatureEntitlementCheckResult`) or read these fields from the REST response directly until the SDK's types catch up.

## Caching and recheck triggers

Entitlement checks are read-heavy and safe to cache briefly — but a stale `ALLOW` served after a downgrade or cancellation is a real revenue leak. Invalidate any cached decision for a customer whenever one of these webhook events fires (verified against the raw payload as described in the [Webhooks guide](/docs/guides/webhooks) ):

entitlement.changed

subscription.updated

subscription.canceled

plan.published

credit.granted

credit.depleted

See the [Caching and Edge Evaluation guide](/docs/guides/caching) for TTL and invalidation strategy, and the [Webhooks guide](/docs/guides/webhooks) for verifying and consuming these events reliably.

## Common pitfalls

### Branching on HTTP status

A `403` from a boolean gate and a `403` from a depleted credit balance require different UI. Always read `reasonCode`, not just the status code.

### Caching past a webhook event

A TTL cache alone is not enough. Explicitly bust the cache key for a customer on any of the recheck-trigger events above, not just on your own mutation flows.

### Gating on metered writes

`POST /usage/events` is fire-and-forget by design (`RECORDED`). Don't block the feature on its response — check the counter or subscribe to `usage.threshold` instead.

### Skipping idempotency keys

Client retries on `POST /usage/events` without an `Idempotency-Key` will double-bill. Derive the key deterministically from the source event, not `Math.random()`.

## FAQ

### Can I check multiple entitlements in one call?

Yes — `GET /entitlements/{customerId}` (without a feature key) returns every effective entitlement for the customer in one response, which is cheaper than N individual gate checks when rendering a full pricing/settings page.

### What happens if a plan and an override disagree?

The override always wins, regardless of whether it is more or less permissive than the plan default. Overrides are intended for explicit, auditable exceptions (see the [Audit and Trace guide](/docs/guides/audit-trace) ) — they are not merged with plan values the way add-ons are.

### Should I evaluate entitlements client-side or server-side?

Always enforce server-side. Client-side checks (via `@monetizekit/react`) are for UI responsiveness only — hiding a button before the network round-trip completes — and must never be the sole gate in front of a paid feature.

## Related guides

### Caching and Edge Evaluation

Cache entitlement decisions safely without serving stale access.

[Open guide](/docs/guides/caching)

### Usage Metering Best Practices

Publish accurate usage with idempotency and anomaly detection.

[Open guide](/docs/guides/metering)

### Webhook Verification and Retries

Consume recheck-trigger events reliably and idempotently.

[Open guide](/docs/guides/webhooks)

### Audit and Trace

Correlate entitlement decisions with the audit log for compliance.

[Open guide](/docs/guides/audit-trace)