A customer downgrades their subscription at 10:00. Their access token was issued at 9:00, and by default, it remains valid for another 24 hours.

The token still proves who the customer is. What it doesn't tell you is whether their new plan allows them to use a particular feature.

This creates a problem for your API. Every request needs to answer a question: does this customer's current subscription include the feature they're trying to access?

The answer lives in your billing provider, not in the access token.

You could ask the provider on every request, but then every API call has to wait for a network request. Alternatively, you could trust the token, but that means a downgrade might not take effect until the token expires.

Neither approach is particularly attractive.

I wanted to explore a third option: a gateway that checks entitlements on every request, keeps the check fast, and still responds to plan changes or provider outages.

To test this, I built a small gateway called Stub. It runs on Bun with Hono and retrieves subscription entitlements from Kinde, which handles authentication and billing.

An entitlement is a feature that a subscription plan grants to a customer.

I tested Stub against a real Kinde account with three test customers. Unless I explicitly mention a stand-in provider, the measurements in this article come from live API calls.

I've written two earlier articles covering related problems. One looks at receiving billing events safely, while the other explores how to enforce usage limits for long-running jobs. This article focuses on the entitlement check itself: its latency, how long its answers can become outdated, and what happens when the billing provider stops responding.

The access token tells you who the customer is, not what their plan allows

Before building the gateway, I needed to understand exactly what information Kinde makes available in an access token.

The relevant claims include:

  • sub: The user.
  • org_code: The organization.
  • permissions
  • feature_flags

The billing information available in the documented token claims is more limited. Kinde documents two trial-related claims: has_trial_period and trial_expires_on.

I couldn't find a claim containing the customer's current plan or entitlement limits.

The token's lifetime makes relying on it even more problematic.

By default, an access token remains valid for 24 hours. Kinde's documentation also explains that revoking a token, ending a session or deleting a user doesn't shorten the lifetime of a token that has already been issued.

So a customer can continue presenting a valid token long after the subscription information associated with it has changed.

Kinde provides two ways to retrieve entitlements outside the token.

The first is the Account API:

GET /account_api/v1/entitlements

This uses the signed-in user's access token.

The second is the Management API:

GET /api/v1/billing/entitlements

This requires a machine-to-machine token with the read:billing_entitlements scope. The response includes each entitlement's feature_code and entitlement_limit_max.

I chose the Management API because Stub operates as a backend gateway rather than a user-facing application.

Kinde also discusses enforcing entitlements on the backend, alongside the checks performed in the product interface, in its engineering blog.

This gives the gateway two separate responsibilities. It verifies the access token to establish the customer's identity, then retrieves their entitlements to determine what their subscription currently allows.

The remaining question is how to perform that second check without making every request unnecessarily expensive.

Verify the access token locally

I wanted the identity check to happen without an additional network request.

Kinde signs access tokens using RS256, which combines RSA signatures with SHA-256. It publishes the corresponding public keys at /.well-known/jwks on the Kinde domain.

Stub uses those public keys to verify the token signature, issuer and expiration.

Each Kinde environment has its own issuer value, so a token issued by another environment fails verification.

Hono provides built-in JWK middleware for this. I chose to use the jose library directly instead, keeping the verification logic inside a plain function.

That made testing easier. I could provide a local key set and verify tokens without making any network requests.

An invalid token receives a 401 response before the gateway attempts an entitlement lookup.

Once the token passes verification, Stub identifies the customer using the organization code when one is available, falling back to the user otherwise.

The cache key includes that identity, which prevents one organization's entitlement response from being reused for another.

With identity verification handled locally, I moved on to the actual billing lookup.

The entitlements endpoint needs a billing customer ID

I ran into an unexpected detail when connecting Stub to the Kinde Management API.

The access token contains an organization code, something like org_8c27….

However, the entitlements endpoint expects a billing customer ID, which looks like customer_01a1….

The API reference I was working from didn't make it clear which identifier the endpoint expected, so I tested both against my three organizations.

Using the organization code, all three requests returned HTTP 500. The response body contained only an error ID.

Using the billing customer ID, all three requests returned HTTP 200 with the expected entitlements.

The Free plan returned reports.

Pro returned reports and exports.

Business returned all three features.

The same endpoint asked with an organization code and with a billing customer ID

I then tried retrieving the billing customer ID through Kinde's organization endpoint. All three organizations returned billing: null.

For this implementation, I created a small file mapping organization codes to their corresponding billing customer IDs.

There's a more automated approach available. A live plan-change event from Kinde includes both org_code and customer_id, so a webhook handler could maintain this mapping as events arrive. I didn't implement that part in Stub.

An organization missing from the mapping also needs special treatment.

Stub classifies it as unknown rather than treating it as a billing provider failure. Otherwise, a missing mapping for one customer could count towards the circuit breaker threshold and potentially prevent other customers from retrieving their entitlements.

One other detail about my test plans: all their features are unmetered. Kinde reports their entitlement limit as 2,147,483,647, the maximum signed 32-bit integer.

Consequently, Stub checks whether a feature exists in the customer's entitlements. It doesn't enforce usage counts.

What happens when you call Kinde on every request?

The simplest implementation would be to call Kinde's Management API whenever a request needs an entitlement check.

I started by measuring what that actually costs.

I made 30 consecutive entitlement lookups from my laptop in Lagos.

Measurement Result
Median latency 477 ms
Fastest request 434 ms
Slowest request 857 ms

There was another cost worth accounting for: obtaining the machine-to-machine access token needed for the lookup.

The first token request took 4,598 ms.

Stub handles this separately. It retrieves the token at startup and refreshes it 30 seconds before expiration, so individual customer requests don't have to wait for token acquisition.

I then sent 40 requests through Stub, with four running concurrently and a fresh entitlement lookup for each request.

The gateway handled 6.0 requests per second, with a median request latency of 499 ms.

For comparison, I ran another test with the entitlement cache already populated. This time, I sent 2,000 requests with 50 running concurrently.

The gateway handled approximately 16,000 requests per second, with a median latency of 1.8 ms.

The two tests used different load patterns, so throughput isn't directly comparable. The median latency gives us a more useful comparison: moving from a live lookup to a warm cache reduced the median from 499 ms to 1.8 ms, roughly 280 times.

I also wanted to understand what happened when multiple requests needed Kinde simultaneously.

I sent 16 entitlement lookups at different concurrency levels: 1, 4, 8 and 16 concurrent requests.

As concurrency increased, the median lookup latency rose from 472 ms to 1,328 ms. The slowest request at eight concurrent lookups took 1,640 ms.

Live lookup time at four levels of overlap, against Stub's 1,500 ms timeout

Kinde's API rate-limit documentation describes both a rate limiter and a concurrency limiter. Exceeding either can produce a 429 response, although the documentation doesn't publish the actual limits.

None of my requests received a 429, so I couldn't establish where those limits sit.

What I could establish was that a gateway making one billing request per application request inherits the billing provider's latency and concurrency constraints.

That makes the approach difficult to scale.

Two cache windows: fresh and stale

A cache isn't simply a performance optimisation. It's also a decision about how long the application is willing to use information that might have changed.

I wanted Stub to handle two different situations:

  1. The cached entitlement is recent enough to trust.
  2. The cached entitlement might be outdated, but retrieving a new one would slow down the current request.

Instead of using one expiration time, Stub has two windows.

An entitlement response younger than 30 seconds is considered fresh. The gateway returns it immediately.

Between 30 seconds and 5 minutes, the response is considered stale. Stub still returns the existing value immediately, but starts a background refresh at the same time.

The next request can then use the refreshed value.

Once the cached response is older than five minutes, or no response exists at all, the request has to wait for Kinde.

Here's the relevant part of the implementation:

async get(key: string) { const entry = this.entries.get(key); if (entry) { const age = ageOf( entry, this.now(), this.freshMs, this.staleOkMs ); if (age === "fresh") { return { entitlements: entry.value, source: "fresh" }; } if (age === "stale") { void this.load(key); // refresh behind the answer return { entitlements: entry.value, source: "stale" }; } } const value = await this.load(key); return value === null ? { entitlements: null, source: "unknown" } : { entitlements: value, source: "fetched" }; } 

The path of one request: token check, cache lookup, and the access decision

The important behaviour is that a request with an existing answer doesn't have to wait for the background refresh.

That keeps the gateway responsive, including when the cached information is slightly outdated.

Of course, there's a trade-off. A customer who has just downgraded might still use their previous entitlements during that stale window.

I wanted to understand what happens when several requests encounter an empty cache at the same time, because that's another situation where a seemingly simple caching implementation can become expensive.

Prevent 200 requests from making 200 billing calls

Consider a customer who has never used the gateway before.

Their entitlements aren't in the cache. Now imagine 200 requests from that customer arrive simultaneously.

A basic cache implementation could send 200 separate requests to Kinde. Each incoming request checks the cache, finds nothing and starts its own lookup.

This is commonly known as a cache stampede.

Stub handles it by keeping track of entitlement lookups that are already in progress.

When a request needs an entitlement response, it first checks whether another request is already retrieving that customer's information. If so, it joins the existing lookup rather than starting another one.

I tested this with 200 simultaneous requests for a customer whose entitlements weren't cached.

Against live Kinde, all 200 requests completed between 0.83 and 0.87 seconds across two runs.

That is consistent with all 200 requests sharing the time required for one lookup.

Every response was marked as fetched.

The live test doesn't expose the number of API calls made, so I used a stand-in provider and a unit test to verify the actual call count.

With 200 simultaneous requests, the stand-in received:

Implementation Provider lookups
Without request merging 200
With request merging 1

I didn't run the unmerged implementation against live Kinde.

This behaviour is particularly useful after deployments. If a restart empties the cache and a large number of customers begin making requests, the gateway doesn't immediately turn that traffic spike into an equally large spike of billing API calls.

Use Kinde webhooks to invalidate the cache when a plan changes

The two cache windows solve the latency problem, but there's still a question of accuracy.

Without any notification mechanism, Stub can continue serving an outdated entitlement response for up to 30 seconds after a plan change.

I wanted to reduce that window.

Kinde provides billing events that tell an application when a customer's subscription changes.

The two relevant events here are:

  • customer.plan_changed, sent when a customer upgrades or downgrades.
  • customer.plan_assigned, sent when a customer is associated with a plan.

Stub exposes a webhook endpoint that receives these events, verifies their signatures and removes the affected customer from the cache.

The next request for that customer retrieves their entitlements from Kinde.

There's also a fallback in the event handler. If an event doesn't contain a customer identifier in a format Stub recognises, the handler clears the entire cache.

I'd rather trigger a few additional lookups than leave an affected customer's old entitlements in place because the application couldn't identify which cache entry to invalidate.

I've covered the webhook verification and delivery handling in more detail in my earlier article on billing events, so I won't repeat that implementation here.

What I did want to investigate was the actual event payload.

Kinde's documentation lists the available event types but doesn't specify all their fields. I inspected a live customer.plan_changed event and found the following:

  • customer_id
  • customer_type
  • org_code
  • plan.key
  • event_id
  • event_timestamp
  • timestamp

There was also an issue with signature verification.

My first two webhook events were rejected because Stub expected an issuer claim in the event token. Kinde's webhook event tokens don't include one.

I adjusted the verifier to check the signature against Kinde's public keys while skipping the issuer check for webhook events only.

I also added a test to make sure an event signed with a different key was still rejected.

With that fixed, I measured how long a plan change took to reach the gateway.

Kinde stamped the event at 22:18:04.8 UTC.

A script polling the Management API every 250 milliseconds detected the new plan at 22:18:05.8, approximately one second later.

Stub received and logged the event at 22:18:07.5, which was 2.7 seconds after Kinde's event timestamp.

The webhook then invalidated the customer's cached entitlements.

The next request had to perform a fresh lookup, which had a median latency of 477 ms in my earlier measurements.

Combining those two measurements gives us approximately 3.2 seconds from the event timestamp to the next request receiving an updated entitlement response.

These figures come from two separate runs.

A plan change from Kinde's event stamp to the gateway's next answer, against the 30-second expiry

In this particular test, the event arrived after the new plan was already available through the Management API. That meant the lookup following cache invalidation returned the updated entitlements.

I didn't test what would happen if the webhook arrived before the new plan became readable through the API.

I also wanted to compare this with what happens when the gateway relies entirely on cache expiration.

I didn't run that experiment against live Kinde, but I tested it with the stand-in provider.

Across three trials, a plan change became visible after:

  • 29.9 seconds
  • 28.6 seconds
  • 5.8 seconds

The variation is expected. The plan change can happen at any point during the 30-second fresh window, so the application waits for whatever time remains before the cached value expires.

The webhook significantly reduces that delay, but it doesn't make cache expiration unnecessary.

Events can arrive late, be delivered more than once or fail to arrive altogether.

Kinde documents a retry schedule that starts immediately and then continues after 5 seconds, 30 seconds, 2 minutes and subsequent intervals.

The webhook-id header remains consistent across retries, allowing the application to identify duplicate deliveries.

Keeping the expiration policy provides another way to recover when webhook delivery doesn't go as expected.

What should happen when the entitlement lookup fails?

So far, the gateway has been able to retrieve the customer's entitlements.

But billing providers can become unavailable, requests can time out, and a customer might make their first request while the provider is experiencing an outage.

At that point, Stub can be in one of two situations:

  • It has an older entitlement response for the customer.
  • It has never successfully retrieved the customer's entitlements.

The application needs to decide what to do in each case.

I didn't want to make this a single global decision because the consequences of granting access differ between features.

For example, allowing someone to read a report when the billing provider is unavailable might be acceptable.

Allowing someone to perform an expensive export or run an AI agent that incurs usage costs could be a different matter.

Stub divides routes into two classes:

Cheap routes: These are operations where temporarily allowing access has a relatively low cost, such as reading a report.

Sensitive routes: These are operations where granting access incorrectly could expose data or create financial costs, such as exports and agent runs.

When Stub has no usable entitlement information, it allows cheap routes and returns HTTP 503 for sensitive routes.

Here's the decision function:

export function decide( entitlements: Entitlements | null, feature: string | null, routeClass: RouteClass ): Decision { if (feature === null) { return { allow: true, reason: "granted" }; } if (entitlements === null) { if (routeClass === "cheap") { return { allow: true, reason: "unknown-allowed" }; } return { allow: false, status: 503, reason: "The plan could not be checked right now, so this route is closed until it can." }; } if (feature in entitlements) { return { allow: true, reason: "granted" }; } return { allow: false, status: 403, reason: `The plan does not include "${feature}".` }; } 

This means each route declares its own classification in the route table.

I prefer this to a single global fail-open or fail-closed setting. The consequences of a wrong access decision depend on what the endpoint actually does.

I tested the decision logic against all three live Kinde plans, checking three routes for each customer.

All nine checks matched the expected entitlements:

Plan Reports Exports Agent runs
Free 200 403 403
Pro 200 200 403
Business 200 200 200

There were two additional mechanisms I wanted in place to protect the gateway when Kinde became slow or unavailable.

The first is a timeout.

Stub stops waiting for an entitlement lookup after 1.5 seconds. This prevents a slow provider from holding application requests indefinitely.

The second is a circuit breaker.

After three consecutive lookup failures, Stub stops making further requests to Kinde for two seconds. Once that period expires, one request is allowed through to test whether the provider has recovered.

Without the circuit breaker, every new customer making a request during an outage could trigger another 1.5-second wait.

I saw both mechanisms in action during the live tests, although I hadn't deliberately caused the provider to fail.

In one run, 37 out of 40 entitlement lookups returned an unknown result.

Of the 40 application requests, 16 received HTTP 200 and 24 received HTTP 503. The 503 responses came from sensitive routes.

The 95th percentile latency was 1,512 ms, almost exactly the configured timeout.

I ran the same script again, and all 40 lookups succeeded.

I couldn't establish why the first run failed. One possible explanation is the overlap behaviour from the earlier concurrency experiment: at eight simultaneous lookups, the slowest request took 1,640 ms, exceeding Stub's 1,500 ms timeout.

That is a possible explanation, not a confirmed cause.

What the gateway does after a failed lookup

Stub also allows an older entitlement response to remain usable for up to five minutes while Kinde is unavailable.

This introduces another trade-off.

A customer who downgraded just before an outage could continue using their previous plan for up to five minutes.

On the other hand, a customer who is still paying for a feature shouldn't necessarily lose access because the billing provider is temporarily unavailable.

For this demonstration, I considered the first cost smaller than the second.

That decision will depend on the product. The five-minute window is configurable.

Benchmarking the gateway with a stand-in provider

Some of the behaviours I wanted to measure are difficult to reproduce reliably against a live billing provider.

I can't ask Kinde to fail on demand, deliberately delay every request or generate a controlled traffic spike.

For those experiments, I built a stand-in provider.

It has a plan table, a configurable lookup delay and a switch that forces entitlement requests to fail.

I set the delay to 150 milliseconds. This was a value I chose for the experiment; a real Kinde lookup took approximately three times as long.

Each benchmark starts a fresh server process, and both the server and load generator run on the same machine.

The first benchmark compares four gateway modes across 3,000 requests.

I used 20 concurrent requests across 200 customers, with 80% of requests distributed among 20% of those customers.

Latency of four gate modes, measured with the 150 ms stand-in

With a warm cache, the median latency was 0.97 ms. Without an entitlement check, it was 1.06 ms.

That difference falls within the variation between runs, so I wouldn't claim that entitlement checks have zero overhead. What I can say is that I couldn't reliably measure their additional cost at this precision.

The more interesting result was the empty-cache test.

Its 95th percentile latency was 151 ms because the first request for a customer had to wait for the provider lookup.

For reference, the 95th percentile is the latency below which 95% of requests complete.

The cache hit rates were:

Cache state Hit rate
Warm cache 99.4%
Empty start 91.8%

Even with an empty cache, the gateway made only 192 entitlement lookups across 3,000 requests.

The second benchmark focused on provider failures, traffic bursts and stale entitlement responses.

Failure, bursts and stale answers, measured with the stand-in

When the stand-in provider was unavailable, all 1,000 requests from customers whose entitlements were already cached were allowed through.

The median latency was 1.71 ms.

I also tested customers who had no cached entitlement information:

  • All 40 requests to cheap routes were allowed.
  • All 40 requests to sensitive routes received HTTP 503.

For the next experiment, I configured the provider to hang for five seconds.

The first three requests took 308 ms, 302 ms and 306 ms. These requests each waited for the 300 ms timeout configured for that particular benchmark.

The remaining 117 requests completed with median latencies between 0.4 and 0.5 ms.

Across the entire run, the gateway made only three provider lookups.

The circuit breaker remained open for the rest of the run, which finished before its two-second recovery window expired.

I also built an inspector page in the repository so these behaviours can be explored without running the benchmark scripts.

It lets you send requests as three different customers, simulate a provider outage, assign a new feature to a customer and manually invalidate a cache entry.

The page also shows where each entitlement response came from.

The inspector page after a burst, a refused export and a service outage

What I didn't test

There are several limitations worth keeping in mind when interpreting these results.

  • The live measurements came from one laptop and three customers. Every live test ran from Lagos, so the lookup timings include that network path. A server closer to Kinde will see different results. Three customers also tell us very little about cache behaviour at production scale.

  • I never received a 429 response. Kinde documents both rate and concurrency limits, but doesn't publish their numerical thresholds. I don't know where those limits sit or what an actual limit response looks like.

  • One live run failed without a confirmed cause. Thirty-seven of 40 lookups returned unknown in that run, while a repeat succeeded. The 1,500 ms timeout and the latency observed during concurrent requests are possible contributing factors, but I haven't established that connection.

  • I timed one plan change end to end. The 1.0-second and 2.7-second measurements came from one event. I didn't compare the clocks on Kinde's side and my laptop, and the earlier plan changes weren't timed against event timestamps.

  • Two events reached Stub approximately half a second apart. Both were rejected. I couldn't determine whether these were separate events or a retry.

  • I didn't test webhook delivery while Stub was unavailable. The retry schedule comes from Kinde's documentation. I also didn't test a webhook arriving before the updated plan became readable through the API.

  • The expiry-only measurements used the stand-in provider. I didn't measure that path against live Kinde.

  • The outage and timeout benchmarks used the stand-in. Those results come from a provider I controlled with a 150 ms lookup delay.

  • The benchmarks used an already-loaded public key set. Verifying a token against locally available keys is different from handling a request that needs to retrieve Kinde's public keys. I didn't measure the latter.

  • The cache lives inside one process. If Stub runs across multiple instances, each instance has its own cache. A webhook received by one instance won't automatically invalidate the others. A shared cache or broadcast mechanism would be needed, with its own latency and implementation costs.

  • The customer mapping is a file. This works for three test customers. A production implementation needs a way to populate and maintain that mapping as customers sign up.

  • Stub checks feature presence, not remaining usage. All my test plans use unmetered entitlements, so I didn't test counted limits.

  • Stale responses can temporarily grant access to a feature a customer has lost. This can happen for up to 30 seconds during normal operation and up to five minutes while Kinde is unavailable.

  • The route classifications are my own design decision. Two categories were enough for this demonstration. A production application might need more granular classifications or customer-specific rules.

Who decides how long an entitlement can be stale?

There's one decision in this implementation that deserves more attention than it usually gets: the cache duration.

It's easy to treat a cache TTL as a performance setting. Someone adds ttl: 60 to a pull request, the application gets faster, and nobody revisits the number.

But the TTL determines how long a customer might continue receiving access based on information that is no longer accurate.

A 30-second fresh window means a downgrade can take up to 30 seconds to be enforced through expiration alone. It also means an upgrade can take up to 30 seconds to become visible if no invalidation event arrives.

Those delays have consequences beyond engineering.

Finance may care about how long a downgraded customer can continue using a paid feature. Support may care about how long an upgraded customer waits before gaining access. The customer has an expectation about when their subscription change takes effect.

The engineer building the gateway shouldn't have to make that decision alone.

The acceptable stale window should be documented, its trade-offs understood, and agreed upon by the people responsible for the product and the costs associated with incorrect access decisions.

The fast path is relatively straightforward to implement. Deciding how long the application is allowed to be wrong is the more important product decision.

Run it yourself

The complete implementation is available in the Stub repository.

You'll need Bun.

Clone the repository and install the dependencies:

bun install bun run dev 

Open http://localhost:3100.

Stub uses the stand-in provider by default and generates its own test tokens, so you don't need a Kinde account to explore the implementation.

There are 29 tests, which you can run with:

bun test 

To reproduce the stand-in benchmarks, run:

bun scripts/bench.ts perf bun scripts/bench.ts burst bun scripts/bench.ts down bun scripts/bench.ts slow bun scripts/bench.ts stale 

The stale benchmark takes approximately three minutes.

To reproduce the live Kinde experiments, create a machine-to-machine application with the read:billing_entitlements scope.

Then configure .env.local and customers.json using the supplied example files.

The live scripts are:

bun run live bun run live-gate bun run live-concurrency bun run plan-watch 

The final script watches one customer for three minutes while you change their subscription plan.

FAQ

Why not put the subscription plan in the access token?

Kinde's documented access token claims don't include billing entitlements. Tokens also last 24 hours by default, and revoking one doesn't shorten the lifetime of a token that has already been issued.

Even if you added the plan to the token, you'd still need a mechanism to update tokens that were issued before a subscription change.

Which identifier does the Kinde entitlements endpoint require?

In my live tests, it required the billing customer ID.

Using the organization code returned HTTP 500 for all three test organizations. Kinde's organization endpoint also returned billing: null, so Stub maintains a mapping between organization codes and billing customer IDs.

How quickly does a plan change reach the gateway?

In my single timed experiment, Kinde's event reached Stub 2.7 seconds after its recorded event timestamp.

The next entitlement lookup took approximately another half-second, based on the median from my earlier tests.

Without webhook invalidation, the fresh cache window can delay the update by up to 30 seconds.

Why use a stale window instead of simply reducing the cache duration?

A shorter cache duration means more requests will have to wait for a fresh entitlement lookup.

The stale window allows the gateway to return an existing response immediately while refreshing it in the background. Only a request without a usable cached answer has to wait.

Is a 30-second cache window too long?

That depends on the consequences of using outdated entitlements.

The webhook reduces the delay for plan changes that Kinde successfully reports. If your product cannot tolerate 30 seconds without a notification, you can shorten the fresh window, although that will increase the number of provider lookups.

What happens when 200 requests arrive for a customer whose entitlements aren't cached?

They share one in-progress lookup.

In my stand-in test, 200 simultaneous requests resulted in one provider call instead of 200. In the live Kinde test, all 200 requests completed within approximately the time required for one lookup.

Should an entitlement gateway fail open or fail closed?

I treat this as a route-level decision.

Allowing access when the provider is unavailable makes sense when the cost of a wrong approval is low. For operations that can expose sensitive data or incur financial costs, returning an error is the more appropriate choice.

Sources