Imagine a customer using an uptime monitoring service on the Pro plan. They have 10 monitors running, and their subscription allows up to 25.
They downgrade to Free, which only allows 3 monitors.
What happens to the other 7?
The billing platform knows about the downgrade. The application knows about it too. But there's another part of the system that needs to know: the background job responsible for running those monitors.
Here's how the sequence plays out:
- Kinde, the billing platform, stores the new plan. Its API reports the updated limit within three seconds.
- Kinde sends the application a
customer.plan_changedevent. - Separately, a cron trigger starts the monitoring job once a minute. Before doing anything, the job needs to know how many monitors the customer's plan allows. It gets this information from a stored copy.
- If that copy is older than 30 seconds, the job fetches the latest limit from Kinde and pauses the 7 newest monitors. If the copy is younger, it trusts the stored value and all 10 monitors continue running.
The problem is in that final step.
The billing platform has already processed the downgrade, but the background job is still working with information from the previous plan. For a short period, the customer continues receiving a service their new subscription no longer covers.
The reverse happens when someone upgrades. A customer who has just paid for Pro might still have their monitors restricted by the Free plan.
This is an easy problem to overlook because both parts of the system are doing what they're supposed to do. The billing platform updates the subscription, and the background job follows the rules it was given. The issue is that they're working with different versions of the same information.
I wanted to understand exactly how long this gap could last, what happens when a plan changes during an active session, and how to keep background jobs in sync with billing changes.
I built a small uptime monitoring application called Siteward to investigate.
Siteward runs on a Cloudflare Worker, uses a cron trigger for scheduled checks, and stores its data in D1, Cloudflare's SQLite database. Kinde handles authentication and billing.
Every measurement in this article comes from experiments against a real Kinde account.
Setting up plan limits in Siteward
Siteward has two features controlled by subscription limits:
- The number of uptime monitors a customer can run.
- The number of alert channels they can connect.
In Kinde, these are called entitlements. An entitlement represents a feature granted by a plan, and a metered entitlement can have a maximum allowed value.
I configured two plans with the following limits:
| Plan | Monitors | Alert channels |
|---|---|---|
| Free | 3 | 1 |
| Pro | 25 | 3 |
Siteward retrieves these limits through the Kinde Management API:
GET /api/v1/billing/entitlements
The request uses a machine-to-machine token and returns the customer's entitlements, including their feature_code and entitlement_limit_max.
There's one detail worth understanding here: the API request needs the billing customer ID, which looks something like customer_01a1….
An access token from a signed-in user provides an organization code instead. To connect the two, Siteward maintains a small mapping between organization codes and billing customer IDs.
For the experiments, I created three organizations, each assigned to a different Siteward plan. The organization used for most of the tests was on Pro and named Dabbit Pro.
I also wrote a script that moves the organization between Pro and Free through the Management API. It does this by cancelling the existing agreement and creating a new one.
With the billing setup in place, I could start looking at what the application actually knows when a customer's plan changes.
What does an existing session know about the customer's plan?
When someone signs in to Siteward, Kinde issues an access token containing claims such as:
suborg_codepermissionsfeature_flags
However, Kinde's documented access token claims don't include the customer's current plan or entitlement limits. The billing-related claims are has_trial_period and trial_expires_on.
The token's lifetime makes this particularly important.
By default, an access token lasts 24 hours. According to Kinde's documentation, revoking a token, ending a session or deleting a user does not shorten the lifetime of a token that has already been issued.
That means a customer can have a perfectly valid access token created under a previous subscription.
For Siteward, this means the access token can establish who is making a request, but it cannot tell the application what their subscription currently allows.
I handle those as two separate checks. Siteward verifies the token signature on its server, then retrieves the customer's plan limits independently.
I wanted to see what happened when the application relied on a stored copy of those limits instead.
Measuring the problem
The first version of the monitoring job used a straightforward caching strategy.
Siteward stored each organization's plan limits in D1 alongside a timestamp. Whenever the job needed the limits, it checked the age of the stored value.
If the value was less than 30 seconds old, it used it. Otherwise, it fetched the latest information from Kinde.
To test this, I configured Dabbit Pro with 10 monitors, all due to run, and moved the organization between Pro and Free five times in each direction.
I ran the entire sequence twice.
In the first run, I cleared the stored plan before every cron execution. This forced the job to fetch the latest limits from Kinde.
In the second run, I left the stored plan untouched, simulating what happens during normal operation between updates.
Here are the results:
| Change | Monitors allowed to run | Stored plan cleared first | Stored plan kept |
|---|---|---|---|
| Pro to Free, 5 times | 3 of 10 | 3 running, 5 of 5 | 10 running, 5 of 5 |
| Free to Pro, 5 times | 10 of 10 (limit 25) | 10 running, 5 of 5 | 3 running, 5 of 5 |
When the stored plan was cleared, the job enforced the correct limit in all 10 changes.
When I left the stored plan in place, it got every change wrong.
After a downgrade, all 10 monitors continued running even though the new plan allowed only 3.
After an upgrade, only 3 monitors continued running even though the customer had paid for a plan that allowed 25.
The interesting part is that the stored values were only a few seconds old during these tests. They weren't obviously stale. They were simply older than the billing change.
This also gives us two separate sources of delay.
The 30-second cache allows the application to keep using an old value until it expires. After that, the cron schedule can introduce another delay of up to one minute before the next job execution fetches the new value.
Those figures come from the application's cache configuration and cron schedule. I did not measure them in a deployed Worker.
The results made one thing clear: having a cache expiry policy doesn't guarantee that a background job will act on the latest subscription information.
Making the background job check the plan before running
My first change was to make the background job retrieve the plan limits before processing any monitors.
The job groups due monitors by organization. For each organization, it retrieves the entitlement limits and determines which monitors should remain active.
It then pauses monitors that exceed the limit and runs the rest.
The stored copy still helps avoid unnecessary API calls. If it's fresh, the job uses it. If it has expired, the job fetches the latest value from Kinde.
I repeated the test with the stored plan cleared before execution.
The job correctly handled both upgrades and downgrades in a single run.
However, this only proved that the reconciliation logic worked. It didn't solve the original timing problem.
During normal operation, the stored value could still be less than 30 seconds old, and the cron trigger might not run again for almost another minute.
I considered reducing the cache duration, but that creates another problem.
Every organization with due monitors could trigger a fresh Kinde API request on every cron execution. In my tests, a fresh read took approximately 1.5 seconds.
That would increase API traffic and add latency to the background job without actually addressing why the information became stale in the first place.
Kinde's billing FAQ also recommends keeping entitlement cache durations short and configuring webhooks to keep application access synchronized with subscription changes.
That pointed towards a better approach: instead of making the background job check more frequently, let the application know when something changes.
Deciding what happens to monitors that exceed the new limit
Before implementing the webhook, I needed to decide what Siteward should do with monitors that were already running when a customer downgraded.
Deleting them would be a poor experience. If the customer upgraded again a week later, those monitors would be gone.
I decided to pause them instead.
Each monitor has one of three states:
-
active: The monitor is running. -
paused_plan: The monitor was stopped because the subscription limit was reached. -
paused_user: The customer explicitly paused it.
The reconciliation logic determines which monitors should be active based on the current entitlement limit.
The oldest monitors retain their active status. When the limit decreases, the newest monitors are paused. When the limit increases, previously paused monitors are resumed, starting with the oldest.
There's another important detail: a monitor paused by the customer must never be resumed automatically because of a plan change.
I implemented this as a pure function, which means the logic doesn't depend on a database or network request.
export function reconcile(rows: MonitorRow[], max: number | null) {
const pause: string[] = [];
const resume: string[] = [];
const candidates = rows
.filter((r) => r.status !== "paused_user")
.sort(
(a, b) =>
a.created_at - b.created_at ||
a.id.localeCompare(b.id)
);
candidates.forEach((r, i) => {
const allowed = max === null || i < max;
if (allowed && r.status === "paused_plan") {
resume.push(r.id);
}
if (!allowed && r.status === "active") {
pause.push(r.id);
}
});
return { pause, resume };
}
Sorting by creation time gives the function a predictable result. The same set of monitors and the same plan limit will always produce the same outcome.
It also means customers retain the monitors they created first.
I wrote six unit tests for this function, including a case where a customer had manually paused a monitor before changing plans.
Using Kinde webhooks to keep the application in sync
The second change was to introduce a webhook.
Kinde signs its webhook events as JWTs and sends them to an HTTPS endpoint exposed by Siteward.
Whenever a customer's plan changes, Kinde sends a customer.plan_changed event.
I configured Siteward's webhook handler to perform four operations:
- Verify the event signature against Kinde's public keys. Kinde's event tokens don't contain an issuer claim, so the issuer check is skipped for these events only.
- Record the
webhook-idheader. Kinde keeps this ID consistent across retries, allowing Siteward to identify and ignore duplicate deliveries. - Clear the stored plan for the affected organization.
- Fetch the latest entitlements from Kinde and reconcile the customer's monitors against the updated limits.
Notice that the webhook doesn't need to contain the new entitlement values.
It simply tells Siteward that something has changed. Siteward then retrieves the authoritative information directly from Kinde.
I preferred this approach because it keeps the webhook handler relatively small and avoids having to treat the event payload as the source of truth for the customer's current subscription.
It also means that a delayed event won't overwrite the stored plan with an older value. The handler always fetches the current entitlements when it processes the event.
With that implemented, I ran the experiment again.
I changed Dabbit Pro's plan 10 times, five times from Pro to Free and five times from Free to Pro.
This time, I didn't run the cron job at all. I wanted to see whether the webhook could handle the changes independently.
It did.
All 10 plan changes triggered a customer.plan_changed event, and Siteward applied the new entitlement limits without needing a background job execution.
I also received no duplicate events during these runs. All 10 webhook requests returned HTTP 200.
The Kinde API reported the new limit between 0.5 and 2.0 seconds after each plan change.
Siteward finished applying the updated limit between 2.8 and 4.8 seconds after the change, with a median of 4.3 seconds.
The webhook handler itself took approximately 1.5 seconds. Almost all of that time was spent fetching the updated entitlements from Kinde.
The remaining delay came from Kinde delivering the event to my local development environment through a tunnel.
I also tested a plan change through the Kinde dashboard rather than the Management API. I used the Change plan action on the organization's billing page, and Siteward received the same event.
The difference was significant.
With the original caching approach, the application could continue enforcing an outdated plan until the stored value expired and the next cron execution ran.
With the webhook in place, all 10 changes were reflected in Siteward in under five seconds.
What happens when someone creates a monitor during a plan change?
There was still another part of the application to consider.
A customer doesn't have to wait for a background job to create a monitor. They can submit a request at any time.
That means a monitor could be created while a subscription is changing, even if the webhook is already configured.
The create endpoint needs to enforce the plan limit independently.
There's a common database problem here: checking the current count and inserting a new record as separate operations.
Imagine a Free customer with two monitors. Their limit is three.
If 10 requests arrive simultaneously, each request could read the database, see two monitors and conclude that another one is allowed.
By the time those requests finish, the customer could have 12 monitors.
To prevent this, I put the count check and insert into a single SQL statement:
INSERT INTO monitors (
id,
org_code,
name,
url,
interval_s,
status,
next_run_at,
created_at
)
SELECT
?1, ?2, ?3, ?4, ?5, 'active', ?6, ?6
WHERE
?7 IS NULL
OR (
SELECT COUNT(*)
FROM monitors
WHERE org_code = ?2
) < ?7;
The database evaluates the condition as part of the insert operation, rather than leaving a gap between checking the count and creating the record.
I tested this on the Free plan, where the limit is three monitors.
I sent 10 create requests at the same time.
Three returned HTTP 201, the remaining seven returned HTTP 402, and the database contained exactly three monitors.
When an insert doesn't create a row because the limit has been reached, the endpoint returns a 402 response with the applicable limit in the message.
That handled simultaneous requests correctly. The next experiment was more interesting.
I wanted to see what would happen if someone started creating monitors at almost exactly the same moment their subscription was downgraded.
I started Dabbit Pro on the Pro plan with eight existing monitors.
I made a request to populate the stored plan, which now indicated that the customer could have up to 25 monitors.
Then I started sending one create request every 300 milliseconds.
The first request went out at the moment I triggered the downgrade.
The first five requests succeeded.
They were sent within 1.2 seconds of the plan change, while the stored entitlement still allowed 25 monitors.
Between the fifth and sixth requests, the webhook arrived and cleared the stored plan.
The remaining five requests fetched the updated limit and returned HTTP 402.
After eight seconds, the database contained 13 monitors, but only three were active.
The five monitors created during the stale-plan window had been paused, and the reconciliation logic retained the three oldest monitors as active.
This experiment showed that the webhook and the create endpoint were handling different parts of the same problem.
For approximately 1.5 seconds, Siteward accepted monitor creation requests under the previous plan. Once the webhook processed the change, subsequent requests were rejected, and the monitors created during that window were paused.
Nothing was deleted.
All 10 requests used the same access token. It had been issued before the downgrade and remained valid throughout the experiment.
The first five requests succeeded, while the remaining five failed, even though the token never changed.
That's an important detail: the session wasn't carrying the subscription limit. Siteward was retrieving that information separately.
An unexpected detail about editing published plans
While testing subscription changes, I came across another behaviour worth documenting.
Kinde plans have versions.
I edited Siteward Pro and reduced the monitor entitlement from 25 to 15. After saving the change, the plan became a draft.
When I went to publish it, the confirmation dialog explained that existing subscribers would remain on their current plan version.
I wanted to see what this meant for an existing customer.
Dabbit Pro was already subscribed to Pro, so I checked its entitlements after publishing the updated plan.
It still reported a limit of 25 monitors.
I then moved the organization to Free and back to Pro.
This time, the entitlement limit was 15.
This meant two customers could have the same plan key, siteward_pro, but different entitlement limits.
Publishing the updated plan also didn't trigger a plan-change event because no customer's subscription had changed.
Siteward handles this by retrieving entitlement limits for each billing customer rather than assuming that every customer with the same plan name has identical limits.
An application that stores plan limits in its own database and looks them up using only the plan name would have treated both customers identically.
In this experiment, that would have produced an incorrect result for one of them.
After completing the test, I restored the Pro monitor limit to 25 and published the change again.
What these experiments don't tell us
The results are useful, but there are a few things worth keeping in mind when interpreting them.
The experiments ran locally. I used
wrangler devon a laptop in Lagos and a quick tunnel to receive webhook events. A deployed Cloudflare Worker, running closer to Kinde, will have different delivery and processing times. The measured 2.8 to 4.8 seconds includes the tunnel and local database.I triggered the cron job manually. The one-minute interval comes from my configured schedule, not a measurement of deployed cron execution. Cloudflare documents that cron triggers can run on underutilized machines and doesn't guarantee exact execution timing.
The timing measurements came from one organization. Ten plan changes show the range observed for Dabbit Pro. They don't establish how the system behaves when hundreds or thousands of customers change plans simultaneously.
I didn't simulate failed webhook deliveries. Kinde documents retries after 5 seconds, 30 seconds, 2 minutes and subsequent intervals. Siteward uses
webhook-idto ignore repeated deliveries, but I didn't verify the retry behaviour through an intentionally failed delivery in these experiments.The plan-version experiment involved one edit. I didn't test what happens when an existing subscriber is moved to a plan whose limit is lower than the version they currently have.
These distinctions matter because the timings are measurements from a particular test environment, while some of the retry and execution behaviour comes from the respective platform documentation.
What I learned
The most interesting part of this exercise was seeing how easily a billing change can leave different parts of an application working with different information.
A request can retrieve the current entitlement when the customer makes an action. A background job is different. It might execute minutes after the original request, using information that was accurate when it was first retrieved.
Siteward now handles this through two mechanisms.
First, the background job checks the entitlement limits before processing monitors. It uses the stored copy when it's fresh and retrieves the latest value from Kinde when it isn't.
Second, Kinde's customer.plan_changed webhook tells Siteward when a subscription changes. The application clears its stored value, fetches the current entitlements and reconciles the monitors immediately.
Both mechanisms matter.
The background job provides a way to retrieve the latest information independently of webhook delivery. The webhook means Siteward doesn't have to wait for the next scheduled execution to respond to a subscription change.
The monitor reconciliation logic also ensures that customers don't lose their existing monitors when they downgrade, and that monitors paused by the customer remain paused when they upgrade.
There is still a short window in which a request can arrive before the webhook has been processed. The experiments showed that this window can result in a few monitors being accepted under the previous plan. The reconciliation process subsequently pauses them.
For this application, that was a useful result: the subscription change is enforced, and the customer's existing data is preserved.
Run the experiments yourself
The complete implementation and experiment scripts are available in the Siteward repository.
You'll need a Cloudflare account for D1 and a Kinde account with billing enabled.
Clone the repository and install the dependencies:
npm install
Create the D1 database:
npx wrangler d1 create siteward
Copy the resulting database ID into wrangler.toml, then initialise the database:
npm run db:init
Set up your local environment:
cp .dev.vars.example .dev.vars
Add your Kinde machine-to-machine credentials and the customer mapping, then start the application:
npm run dev
npm test
You'll also need a machine-to-machine application in Kinde with permission to read billing entitlements and create billing agreements.
Create three plans with monitors and alert_channels features to reproduce the experiments.
The repository includes several scripts for running the tests.
1. Compare plan changes with and without a cleared cache
node scripts/plan-flip.mjs 5
This changes the plan five times in each direction and runs the cron job after each change.
To repeat the experiment while keeping the stored plan:
KEEP_CACHE=1 node scripts/plan-flip.mjs 5
2. Test webhook-based reconciliation
WEBHOOK=1 node scripts/plan-flip.mjs 5
This runs without the cron job and waits for the webhook to process each change. You'll need a tunnel and a Kinde webhook pointing to /webhooks/kinde.
3. Test simultaneous monitor creation
SITEWARD_TOKEN=… node scripts/parallel-create.mjs
Use an access token from the dashboard. The dashboard stores it in session storage.
4. Inspect entitlement limits
node scripts/limit.mjs [plan_code]
This prints the limits Kinde reports and moves the organization to the plan you specify.
FAQ
Why not include the plan in the access token?
Kinde's documented access token claims don't include plan or entitlement information. Access tokens also last 24 hours by default, and revoking a token doesn't shorten the lifetime of one that has already been issued.
Does a downgrade in Kinde take effect immediately?
In my experiments, the API reported the new entitlement limit within approximately two seconds. This was true for changes made through both the Management API and the Kinde dashboard.
Kinde's billing policies also cover refunds and credits for unused days. The documentation I reviewed didn't specify a setting for delaying a downgrade.
Why pause excess monitors instead of deleting them?
Customers who upgrade again should be able to use their existing monitors. Pausing preserves their data, and the reconciliation logic determines which monitors should resume when their limits increase.
Is the stored plan still necessary if webhooks handle subscription changes?
Yes. Webhooks can arrive late, be delivered more than once or fail to arrive. Kinde also documents that a webhook that continues failing can be disabled after 72 hours.
The stored plan, combined with its 30-second age limit, provides a fallback when webhook delivery fails.
What prevents simultaneous requests from exceeding the plan limit?
Siteward performs the count check and insert in one SQL statement. In my test, 10 simultaneous creation requests against a limit of three resulted in exactly three monitors being created.
What happens when a customer manually pauses a monitor and then upgrades?
It stays paused. The reconciliation function only resumes monitors that were paused because of a plan limit.
Does editing a published plan change the limits for existing subscribers?
In my experiment, it didn't. Existing subscribers retained their previous plan version, while customers who moved away from the plan and subscribed again received the updated entitlement limit.
Sources
- Siteward source code
- About access tokens, Kinde docs
- Configure token and session expiry, Kinde docs
- About webhooks, Kinde docs
- Respond to customer activity using webhooks, Kinde docs
- Subscription upgrade and downgrade methods, Kinde docs
- Upgrade and downgrade policies, Kinde docs
- Kinde billing FAQs, Kinde docs
- API rate limits, Kinde docs
- Cron Triggers, Cloudflare docs
- D1 Database API, Cloudflare docs




Top comments (1)
Pausing the 7 newest instead of deleting them is the kind of detail that decides whether a downgrade feels fair, since an upgrade later can just resume them. The 30 second staleness rule on the stored copy is a nice bound with a once a minute cron. Newest is a reasonable default, but the monitor a customer cares about most might be a recent one. Does the customer get a chance to choose which 3 stay active?