Stop an AI agent at the plan limit, not on next month's invoice
Blog GuideAn agent that retries on failure is a loop with your credit card in it. One user on a $20 plan, one tool call that keeps timing out, and by morning you have spent more on tokens for that one account than the plan brings in all year. Then comes the conversation: you either eat the cost or you send an invoice the user never agreed to, and refund it a week later. Reporting the usage afterwards does not help. The money is gone before the report lands.
What you want is a counter on your own server that says no before the expensive call, with the limit coming from the plan the user actually bought. This post is how to do that with Rekey: pick an allowance model, put the check where it stops the spend, and know exactly what the check does and does not guarantee when ten runs start at once.
Model the allowance first
Rekey has two ways to say how much a user may consume. A usage meter with a plan allowance, and a prepaid credit balance. They answer different questions.
A meter is a named counter on your Application, such as agent_runs or pages_scraped. You create it in the panel under Billing, Usage, or with POST /api/v1/tenant/applications/:id/usage-meters giving it a slug, a name and a unit. Then each plan says how many units of it are included, as a USAGE entitlement whose key is the meter slug and whose quantity is the included units. The window is the calendar month in UTC. When the user has used it up, the next record is refused.
A credit balance is a number per user or per organization that goes up when they buy a credit pack or you grant some, and down when your server consumes. There is no monthly reset and no plan needed; the balance is the limit.
| Pick a plan allowance when | Pick credits when |
|---|---|
| The pitch is a monthly number: 500 runs on Starter, 5,000 on Pro. It resets on the first of the month without you doing anything. | The pitch is a pack: buy 1,000 credits, spend them whenever. Unused credits carry over. |
| Every unit costs you about the same, so a flat allowance is fair. | Units differ in cost, so you want to charge 1 credit for a small job and 20 for a big one. |
| You want a hard stop at the limit and an upgrade prompt, nothing billed past it. | You want the user to top up mid-month instead of waiting or upgrading. |
| The subject is the plan holder, personal or an organization pool. | The same. Both a person and an organization can hold a balance. |
The two combine. Give the USAGE entitlement a creditsPerUnit and a record past the included quota is charged from the credit balance instead of refused; an empty balance then refuses it. That is the plan with a top-up path. Set quantity to 0 with a price and you charge from the first unit. Without a price, a plan quota is a hard cap: Rekey does not invoice overage, so metered pricing here means cap and refuse, or charge credits.
Put the check before the expensive call
Rekey enforces the quota at the moment a record is written, inside the same transaction that counts this month's rows. There is no separate reserve call. So if you do the work first and record after, the check only ever reports; it can refuse the record, but the tokens are already spent. To make the check a stop, record before you run.
That means picking a unit you know before the call, such as a run, a page or a render. Token counts come after the model answers, so a meter in tokens cannot stop anything; a meter in runs can. From a Node backend with the server SDK:
import { Rekey, RekeyError } from '@rekey.dev/node';
const rekey = new Rekey({
apiUrl: process.env.REKEY_URL!,
secretKey: process.env.REKEY_SECRET!,
});
export async function runAgent(endUserId: string, runId: string, task: string) {
try {
await rekey.usage.record({
meterSlug: 'agent_runs',
quantity: 1,
endUserId,
idempotencyKey: `run:${runId}`,
});
} catch (e) {
if (e instanceof RekeyError && e.code === 'USAGE_QUOTA_EXCEEDED') {
return { ok: false, code: e.code, fix: e.fix };
}
if (e instanceof RekeyError && e.code === 'USAGE_RECORD_BUSY') {
await new Promise((r) => setTimeout(r, (e.retryAfterSeconds ?? 1) * 1000));
return runAgent(endUserId, runId, task);
}
throw e;
}
const output = await callTheModel(task);
return { ok: true, output };
}Three details carry the weight. record needs a secret key with the billing:write scope; the publishable key is rejected, so this can only run on your server. quantity is a positive integer, which is why a failed run cannot be handed back by recording a negative. And the idempotencyKey is what makes the retry on line two safe: a repeat with the same key, the same quantity and the same UTC month returns the original record instead of counting again. The same key with a different quantity comes back 409 IDEMPOTENCY_KEY_REUSED and nothing new is written.
The order has a cost, and you should choose it on purpose. A run that fails after the record still used a unit. The Next.js starter kit does it the other way round for credits: check the balance, do the work, then consume, so a failure costs the user nothing. That is the right order for a demo and for work you fully control. For an agent that can loop, the unit you lose on a failed run is cheaper than the loop you cannot stop.
The same shape with credits
With credits the gate is rekey.credits.consume. It debits atomically, with a single guarded update that only succeeds when the balance covers the amount, and throws CREDITS_INSUFFICIENT with HTTP 402 when it does not. Pass the same kind of idempotencyKey; a replay returns the original entry with applied: false and the balance unchanged.
const { balance } = await rekey.credits.consume({
endUserId,
amount: 5,
idempotencyKey: `run:${runId}`,
description: 'agent run',
});For a display, read rekey.usage.getRemainingFor({ endUserId }) or rekey.credits.getBalance({ endUserId }). Those reads take no lock, so they suit a progress bar and should not be used as the gate.
What the user sees
Every refusal is a RekeyError with a stable code, a human message and a fix. At the quota, POST /api/v1/usage/record answers 402 with:
{
"success": false,
"error": {
"code": "USAGE_QUOTA_EXCEEDED",
"message": "Included quota for meter \"agent_runs\" exhausted: 500/500 units used this period, cannot add 1.",
"fix": "Upgrade the plan for a higher quota, wait for the next period, or price the meter in credits so usage past the quota can be paid for.",
"requestId": "..."
}
}The fix line is written for you, the operator, so do not paste it to the user as is. Switch on the code and show your own upgrade prompt. The numbers in the message are real, though: used and included for this month, so you can render “500 of 500 runs used, resets 1 November” from the same response or from GET /api/v1/usage/remaining, which the browser can call with the publishable key and the user's token.
On a priced meter with an empty balance the code is CREDITS_INSUFFICIENT, also 402, with the shortfall in the message. Neither of these is worth retrying; the same record will be refused again until the month turns, the plan changes or credits arrive.
Top-ups and one-off deals
A user at the limit has three ways out, and none of them needs a private plan.
- Upgrade the plan. The quota is read live from the user's active subscriptions, so the moment the new subscription is active the next record counts against the bigger number. Quotas add up across plans a subject holds.
- Grant credits from the panel, or from your server with
rekey.credits.grant. The grant route needs a key minted with thecredits:grantscope; the default*key is refused on purpose, because a scope that mints value should only sit on a key someone chose to give it to. Grants are 1 to 1,000,000 per call and theidempotencyKeyis required. - Override that one subscription.
PATCH /api/v1/tenant/applications/:id/subscriptions/:subId/entitlement-overrideswith a body like{ "USAGE:agent_runs": 2000 }raises that customer's allowance without touching the plan. The map is sparse,nullremoves an override, and aUSAGEoverride can only replace a quantity on a row the plan already carries. Usage overrides are read live, so they take effect on the next record.
The resolution order matters when you debug a number that looks wrong. For each active subscription, the plan's entitlements are loaded and the subscription's overrides are merged over them. A person with no paid subscription falls back to the Application's default free plan, so a free tier can cap usage without a $0 subscription row. Included quantities are then summed across every subscription, and where more than one plan prices the meter the cheapest rate wins. More on overrides in the concepts guide.
What the lock guarantees, and what it does not
Suppose ten runs for one user start in the same second. Each capped record runs in one database transaction. It takes a transaction-scoped advisory lock keyed on the meter and the subject (the end-user or the organization), sums the rows already written for this UTC month, compares used plus the new quantity with the included amount, and either throws the 402 or inserts the row. If the meter is priced, the credit debit joins the same transaction, so a recorded unit that was not paid for cannot exist. The lock means the ten runs for that user queue up and are checked one at a time: at most the included number of records ever get written, never 500 plus a few that slipped through the same read.
The lock is per subject. One user's burst does not block another user's record, and an app-level record with no subject is never capped at all. A waiter gives up after 2 seconds with 503 USAGE_RECORD_BUSY and a Retry-After of 1 second; nothing was recorded or charged, so retrying it is safe with or without an idempotency key. That is the one error in this flow you should retry, and the sample above does.
What it does not do
getRemainingFor or getBalance: those reads take no lock, so ten parallel runs can all see one unit left, all do the work, and only the first record succeeds. If the work is the expensive part, the record has to come first. The window is also the UTC calendar month, not the subscription's billing period, so a plan bought on the 20th gets its full allowance for what is left of the month and again on the 1st.Telling your own system
There is no webhook for a refused record. A 402 is answered to the caller and nothing else happens, which is the point: your server is the one that asked, so it already knows. What does fire is credit.consumed, once per record that was charged in credits past the included quota, and credit.granted when a top-up lands, so a ledger of spend on your side can follow the credit events. An entitlement override that changes what a subscriber holds emits subscription.entitlements_updated. Remaining quota itself is pull-only today: read it with GET /api/v1/usage/remaining/for-user when you need it, or count the 402s you handle. The event list is in the webhooks guide.
The whole surface is five routes under /api/v1/usage and five under /api/v1/credits, all in the API reference, with the SDK methods in the SDK guide. If you are starting from nothing, the starter kit already has the credits version wired to a server action.
Was this page helpful?
