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} | ALLOWDENY`` |
| Limit check | Has the customer reached a hard cap on a resource? | GET /entitlements/{customerId}/{featureKey} | ALLOWDENY`` |
| Metered entitlement | Should this usage event be billed, and is the customer still within budget? | POST /usage/events | RECORDEDDEGRADEREQUIRE_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.
| 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) |
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"} |
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. |
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 .
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"}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, });}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)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:
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.
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, ... }// 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);}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)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 .
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}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.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.
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.
Usage Metering Best Practices
Publish accurate usage with idempotency and anomaly detection.
Webhook Verification and Retries
Consume recheck-trigger events reliably and idempotently.
Audit and Trace
Correlate entitlement decisions with the audit log for compliance.