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

Caching and Edge Evaluation

Cache entitlement lookups aggressively without ever serving a stale access decision.

Entitlement checks sit on the hot path of nearly every request — a paywalled route, a feature-flagged UI element, a rate-limited API call. Read-heavy and latency-sensitive, they are an ideal caching target, but caching an access decision is not the same as caching a product listing: serving ALLOW for a few seconds after a cancellation is a real revenue and security leak. This guide covers what the API marks as cacheable and why it stays on your side of the connection, what you should cache yourself, and the invalidation triggers that keep both layers correct — see the Entitlement Evaluation Patterns guide first if you haven't settled on which check shape you're caching.

What the API already caches

The single-feature entitlement endpoint marks its response as cacheable for 60 seconds by the caller's own cache — a browser, an HTTP client with a cache, or a private caching proxy you run. It is deliberately private: an entitlement decision is scoped to the API key that asked for it, so it is never stored by a shared cache such as a CDN, where it could be served to a request that carries no key at all. It sets exactly three cache-related headers —

Cache-Control ,

Cache-Tag ,

Surrogate-Key

— no vendor-specific CDN-Cache-Control or similar.

bash
HTTP/1.1 200 OKContent-Type: application/jsonX-API-Version: v1X-Request-Id: 7c2f6e1a-0e3b-4b9a-9a9f-8e2c9e6f0d21Cache-Control: private, max-age=60, stale-while-revalidate=10Cache-Tag: entitlements-customer-cust_dev_1001,entitlements-customer-cust_dev_1001-feature-analytics_exportSurrogate-Key: entitlements-customer-cust_dev_1001 entitlements-customer-cust_dev_1001-feature-analytics_export{"customerId":"cust_dev_1001","featureKey":"analytics_export","allowed":true,"effectiveValue":true,"type":"boolean","sources":["Plan"],"reason":"Entitlement grants access","reasonCode":"granted","planName":"Pro","planVersion":3,"latencyMs":4}
  • Cache-Control: private, max-age=60, stale-while-revalidate=10 lets the caller's own cache serve the response for up to 60s, and up to 10s more while revalidating in the background. private forbids shared caches from storing it.
  • Cache-Tag carries the same two comma-separated tags as Surrogate-Key below — one scoped to the whole customer ( entitlements-customer-cust_dev_1001) and one scoped to this specific feature ( entitlements-customer-cust_dev_1001-feature-analytics_export) — so a tag-aware private cache can invalidate either granularity.
  • Surrogate-Key: entitlements-customer-cust_dev_1001 entitlements-customer-cust_dev_1001-feature-analytics_export gives a surrogate-key-aware private caching layer the same two keys, so it can purge exactly the affected customer/feature pair on mutation instead of a blanket flush.

Cache policy by endpoint

EndpointCacheableWhy
GET /entitlements/{customerId}/{featureKey}Yes — private, 60sSingle-feature checks are the hottest read path; a 60s TTL in the caller's own cache absorbs repeat checks without ever sharing a decision across callers.
GET /entitlements/{customerId}No (no-store)The full batch response changes more frequently and is cheaper to recompute than to keep coherent.
GET /usage/{customerId}/{meterId}Client-side only, short TTLCounters mutate on every metered event; the API does not set edge cache headers on this route.
POST /usage/eventsNoMutating endpoint. Deduplicated via Idempotency-Key, not cached.
POST /usage/events/batchNoMutating endpoint. Deduplicated per item via each event's idempotencyKey, not cached.
Never cache the batch endpoint

GET /entitlements/{customerId} intentionally sends Cache-Control: no-store. It returns the customer's full entitlement set, which changes on any plan, add-on, or override mutation — caching it trades a small latency win for a much larger correctness risk. Prefer single-feature checks on hot paths and reserve the batch call for pages that render many gates at once and can tolerate a fresh read each time.

Adding your own application-layer cache

Most server-side HTTP clients do not cache at all, so the private directive above only helps browsers and clients you have configured with a cache. For server-rendered pages that check several gates per request, layer a short-TTL cache in front of the SDK call, keyed identically to the API's surrogate key so invalidation stays consistent across both layers.

javascript
const cache = new Map(); // swap for Redis/Memcached in productionasync function checkEntitlement(customerId, featureKey) {  const cacheKey = `ent:${customerId}:${featureKey}`;  const hit = cache.get(cacheKey);  if (hit && hit.expiresAt > Date.now()) return hit.decision;  const decision = await mk.entitlements.check(customerId, featureKey);  cache.set(cacheKey, { decision, expiresAt: Date.now() + 30_000 }); // 30s TTL  return decision;}function invalidateCustomer(customerId) {  for (const key of cache.keys()) {    if (key.startsWith(`ent:${customerId}:`)) cache.delete(key);  }}
javascript
async function checkEntitlement(customerId, featureKey) {  const cacheKey = `ent:${customerId}:${featureKey}`;  const cached = await redis.get(cacheKey);  if (cached) return JSON.parse(cached);  const decision = await mk.entitlements.check(customerId, featureKey);  await redis.set(cacheKey, JSON.stringify(decision), "EX", 30);  return decision;}// Invalidate every cached key for a customer on a recheck-trigger webhook.async function invalidateCustomer(customerId) {  const keys = await redis.keys(`ent:${customerId}:*`);  if (keys.length) await redis.del(keys);}
python
def check_entitlement(customer_id: str, feature_key: str) -> dict:    cache_key = f"ent:{customer_id}:{feature_key}"    cached = redis_client.get(cache_key)    if cached:        return json.loads(cached)    decision = mk.entitlements.check(customer_id, feature_key)    redis_client.set(cache_key, json.dumps(decision), ex=30)    return decision# Invalidate every cached key for a customer on a recheck-trigger webhook.def invalidate_customer(customer_id: str) -> None:    for key in redis_client.scan_iter(f"ent:{customer_id}:*"):        redis_client.delete(key)

Invalidate on the right events

Because the API response is only ever cached on your side, MonetizeKit cannot purge it for you. Your application-layer cache does not know about a subscription, override, or add-on mutation unless you tell it — subscribe to the webhook events below and bust your cache keys in the handler:

javascript
app.post("/webhooks/monetizekit", async (req, res) => {  const event = verifyAndParse(req); // see the Webhooks guide  const INVALIDATING_EVENTS = new Set([    "entitlement.changed",    "subscription.created",    "subscription.updated",    "subscription.canceled",    "plan.published",    "credit.granted",    "credit.depleted",  ]);  if (INVALIDATING_EVENTS.has(event.type) && event.data.customerId) {    await invalidateCustomer(event.data.customerId);  }  res.sendStatus(200);});

Full event payload shapes are in the Events & Webhook Explorer , and signature verification (including replay protection and idempotent processing on your side) is covered in the Webhooks guide .

Common pitfalls

Caching the batch endpoint

It changes too often to cache safely. Cache individual feature checks instead, or shorten your own TTL to a few seconds if you must cache it client-side.

TTL longer than your invalidation guarantees

If your webhook consumer can lag (queue backlog, downstream outage), your effective staleness window is TTL + consumer lag. Keep TTLs short enough to bound worst-case staleness on their own.

One cache key per customer instead of per feature

Coarse keys force a full-customer flush on every mutation. Key by {customerId}:{featureKey} (mirroring the API's per-feature Surrogate-Key tag) so unrelated feature checks stay warm.

No negative caching

A customer who is repeatedly denied a feature (e.g. a bot probing routes) will otherwise generate one API call per request. Cache DENY results too, with the same TTL.

FAQ

Do I need a cache at all?

For a browser calling with a publishable key, usually not — the browser honours the 60s private TTL on its own. For server-side code, yes if the check is on a hot path: server HTTP clients do not cache by default, so every check is a round trip to the API until you add an application-layer cache such as the ones above.

What TTL should I use client-side?

Match or undercut the API's 60s TTL — 15 to 30 seconds is a reasonable default for most products. Shorten it further for high-stakes gates (e.g. seat limits during a live sales demo) and lengthen it for low-stakes UI toggles.

Why is the response not cached at the edge?

An entitlement decision is an authenticated response. A shared cache keys on the URL, not on who asked, so an edge-cached decision would be served to any request for that URL — including one with no API key — until the TTL expired. Keeping the cache on your side keeps the decision behind your key.

How do I force a fresh read?

If a workflow needs a guaranteed-fresh read (for example, immediately after your own mutation), bypass or invalidate your own cache entry for that customer/feature pair; the API itself always evaluates fresh.

Related guides

Entitlement Evaluation Patterns

Boolean gates, limit checks, and metered entitlements in depth.

Open guide

Webhook Verification and Retries

Consume invalidation-triggering events securely and idempotently.

Open guide

Events & Webhook Explorer

Browse every event type and payload shape.

Open guide

Was this page helpful?