Help improve docs quality by sharing anonymous interaction telemetry (no PII or token data).

Entitlement Evaluation Patterns

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

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

ShapeAnswersEndpointPossible verdicts
Boolean gateCan this customer use the feature at all?GET /entitlements/{customerId}/{featureKey}ALLOWDENY``
Limit checkHas the customer reached a hard cap on a resource?GET /entitlements/{customerId}/{featureKey}ALLOWDENY``
Metered entitlementShould this usage event be billed, and is the customer still within budget?POST /usage/eventsRECORDEDDEGRADEREQUIRE_TOP_UPDENY``

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.

MethodUsed 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)
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.

VerdictMeaningTypical response shape
ALLOWEntitled — serve the feature.{"allowed":true,"reasonCode":"granted","reason":"Entitlement grants access"}
DENYNot entitled — block and surface an upgrade path.{ "allowed": false, "reasonCode": "not_in_plan", "reason": "Feature is not included in the Pro plan" }
REQUIRE_TOP_UPA usage event was still recorded, but a require_topup budget was exceeded — prompt a top-up.{ "status": "success", "requiresTopup": true } // from POST /usage/events
DEGRADEA usage event was still recorded, but a degrade budget was exceeded — serve a reduced tier.{ "status": "success", "degraded": true } // from POST /usage/events
RECORDEDMetered event accepted for later billing, no budget policy triggered; access continues.{"id":"usage_evt_9012","status":"success"}
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:

LayerPrecedenceDescription
PlanBaseThe subscription's plan version defines the default value for every feature key.
Add-onOverrides PlanActive add-ons raise or unlock entitlements on top of the plan (e.g. extra seats, priority support).
OverrideOverrides Add-onA per-customer override (granted manually or via a sales-negotiated contract) wins over plan and add-ons.
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 .

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 MonetizeKitmk = 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 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)
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 .

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

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 ):

entitlement.changed

subscription.updated

subscription.canceled

plan.published

credit.granted

credit.depleted

See the Caching and Edge Evaluation guide for TTL and invalidation strategy, and the Webhooks guide 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 ) — 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

Usage Metering Best Practices

Publish accurate usage with idempotency and anomaly detection.

Open guide

Webhook Verification and Retries

Consume recheck-trigger events reliably and idempotently.

Open guide

Audit and Trace

Correlate entitlement decisions with the audit log for compliance.

Open guide

Was this page helpful?