Multi-tenant API quotas: where the limit actually belongs
A customer on a 1,000-credit plan had somehow used 4,300, and nothing was broken. Where a multi-tenant API quota lives (the key, the user, or the account) decides what you can bill and what a key rotation breaks.

Sorower
Co-founder

In this article
- Rate limiting has two decisions. Everyone writes about one.
- The four places a multi-tenant API quota can live
- The multiplication leak, with real numbers
- Three questions that pick the level for you
- 1. What do you invoice?
- 2. What can the tenant create more of without asking you?
- 3. What has to survive a key rotation?
- Questions you're actually asking
- If all my tenant's keys share a pool, can't one key drain it?
- If enforcement is at the account level, how do I know which key spent what?
- What status code should an exhausted quota return?
- What this looks like on a real API
- The move you get for free
- Changing the plan later, which is where the maths gets rude
- Watch someone find the noisy neighbour
- Where the account-level model falls short
- Key takeaways
The bug report was one line long: "Customer is on the 1,000-credit plan but the dashboard says they've used 4,300."
Nothing was broken. The counter was doing exactly what it had been told to do. The customer had five API keys, one per service in their backend, and every key had been handed its own 1,000-credit pool at creation time. Five keys, one plan, five times the quota. Ask me how I know.
That is not a rate-limiting bug. It's a schema decision: a multi-tenant API quota has to live somewhere, and whoever wrote the first migration picked the key. Where it lives (on the key, on the user, or on the account) decides what you can bill, what a rotation breaks, and how many entities can quietly multiply your plan. Almost every article about rate limiting skips that choice entirely and goes straight to arguing about algorithms.
Rate limiting has two decisions. Everyone writes about one.
Pick up any rate-limiting guide and you'll get the algorithm tour: fixed window, sliding window, token bucket, leaky bucket, with a diagram of buckets filling up. Useful stuff. We wrote one of those ourselves, on building an atomic sliding-window limiter in Redis and Lua.
But the algorithm only answers when to reject a request. There's a second decision hiding in the code, and it's the one in the counter key:
rate_limit:{ ??? }:{window}
^
this is the actual design decision
Whatever you substitute for ??? is the thing your quota belongs to. Get the algorithm wrong and you over-limit some bursts. Get this wrong and you sell a 1,000-credit plan and deliver 5,000.
Here's the uncomfortable part. You can swap sliding window for token bucket in an afternoon; you cannot move a quota from key-level to account-level in an afternoon. One is a function. The other is a migration across every existing customer's live usage state, and there is no correct answer for what to do with the balances you already sold.
The four places a multi-tenant API quota can live
| Quota lives on | What it costs you | Where it breaks | Right when |
|---|---|---|---|
| The key | Cheapest to build. One counter per credential, no joins on the hot path. | Multiplies. Every new key mints new quota. Dies on rotation. Never matches an invoice. | Internal tooling, or a hard per-credential cap layered on top of a real quota. |
| The user | One counter per human, plus a lookup from credential to user. | Machine clients have no user. Service accounts end up owned by whoever ran the setup script, and their quota leaves with them. | Human-facing APIs where a seat is the unit you sell. |
| The account | One counter per paying entity, plus a credential-to-account lookup. | No isolation inside the tenant. One runaway cron job can drain the pool the whole company shares. | Almost always. This is the default you should argue yourself out of, not into. |
| The account and the route | Counters multiply by cardinality. More state, more reads per request. | Complexity, and quota rules nobody on support can explain to a customer. | One or two genuinely expensive endpoints (bulk export, model inference) need their own ceiling. |
Amazon lands in the same place, and says so plainly. In the AWS Builders' Library essay on fairness in multi-tenant systems, the framing is that rate limiting shapes unplanned traffic while quotas are enforced at per-tenant or per-workload granularity, and that for AWS services a client is typically an AWS account. Sometimes something more fine-grained gets its own quota, like an individual DynamoDB table. Account first, resource second when a resource earns it. Notice what isn't on that list: the credential.
The multiplication leak, with real numbers
ReqKey shipped per-key credit pools first. It was the obvious model: a key is what you hand out, so a key is what you meter. Then customers started doing the sensible thing and issuing one key per service, and the arithmetic stopped being funny. A consumer on a 1,000-credit plan with five keys held 5,000 credits. Our own docs describe the old behaviour as a billing leak, which is the polite word for it.
So the credit pool moved from the key to the consumer, and keys became what they should have been from the start: authentication tokens with a scope, and no balance of their own. Every key under a consumer now draws from one pool.
Two things worth stealing from that mistake:
- The leak is silent and it favours the customer. Nobody opens a support ticket to report that they got more quota than they paid for. You find it in a revenue reconciliation, months late.
- The multiplier is set by your customers, not by you. The moment you let a tenant self-serve key creation, a per-key quota is a quota the tenant controls. That's not a limit. That's a suggestion.
Three questions that pick the level for you
1. What do you invoice?
The quota belongs on whatever the bill is addressed to. If you invoice Acme Corp, the counter lives on Acme Corp. Any other choice means your enforcement boundary and your billing boundary can disagree, and when they disagree the customer is always right and you always eat the difference.
2. What can the tenant create more of without asking you?
If a tenant can create it freely, it cannot hold the budget. Keys are the obvious case. Sub-users are the sneaky one: give a customer self-serve seat management and per-user quotas become self-serve quota expansion.
3. What has to survive a key rotation?
This is the question that catches teams late. If the quota is a column on the key row, then rotating a key means either migrating the balance or resetting it, and both options are bad. Migrating balances is a bespoke script you'll run at 2 AM; resetting means a customer who rotates on schedule gets free credits every quarter. Put the quota on the account and rotation becomes what it should be: issue a second credential, let both work during the overlap window, retire the first. We walked through that pattern in API key rotation without downtime, and the whole thing only works because the balance doesn't live on the credential.
Questions you're actually asking
If all my tenant's keys share a pool, can't one key drain it?
Yes. That's the real cost of account-level quotas and you should not pretend otherwise. A nightly batch job and a latency-sensitive dashboard sharing one pool is the noisy-neighbour problem moved inside the tenant boundary.
The fix is not to move the quota back down to the key. It's to add a second, smaller dimension on top: the account owns the budget, and a specific credential or route gets a ceiling that stops it eating everything. Amazon composes token buckets exactly this way, a low-rate high-burst bucket checked against a high-rate low-burst one, to allow bursting without letting burst capacity defeat the limit. Layer the cheap protection over the real quota. Don't relocate the real quota.
If enforcement is at the account level, how do I know which key spent what?
By keeping attribution and enforcement separate, which is the single most useful idea in this post. Enforcement needs one authoritative counter; attribution needs a log. The counter answers "may this request proceed." The log answers "who spent it, on what, and how fast was it." Different systems, different consistency requirements, different storage.
ReqKey does this with a validation call that decrements the shared pool and returns a requestId, and a separate ingest endpoint that records the request with its apiKey, status code, latency and endpoint. Per-key reporting comes from that traffic log, not from per-key counters. Honest limitation while we're here: there is no per-key lastUsed field on the API, so "has this key gone quiet" is a question you answer from ingested traffic, not from the key record.
What status code should an exhausted quota return?
Depends what ran out. If the tenant is going too fast, that's 429, defined in RFC 6585, and it means "retry later" with a Retry-After that you should actually calculate. If the tenant is out of prepaid quota for the month, retrying is pointless until a refill or a top-up, and 402 Payment Required says that much more honestly (formally it's still reserved for future use in the HTTP spec, and plenty of billing APIs use it anyway). We picked that hill in your 429 response is probably wrong.
What this looks like on a real API
Concretely, with ReqKey. The tenant entity here is called a consumer, and it owns the credits. Create the tenant with its budget:
curl -X POST "https://api.reqkey.com/consumer/create" \
-H "Authorization: Bearer reqkey_xxx..." \
-H "Content-Type: application/json" \
-d '{
"name": "Acme Corporation",
"credits": {
"limit": 5000,
"shadowLimit": 500,
"overage": {"enabled": true, "limit": 200}
},
"externalId": "acme_12345"
}'
Response:
{
"consumerId": "cons_A1B2C3D4",
"projectKey": "reqkey_xxx...",
"status": "active",
"name": "Acme Corporation",
"externalId": "acme_12345"
}
Three fields there earn their keep. shadowLimit is a warning threshold that fires before the hard limit, so you can email a customer at 4,500 instead of paging yourself at 5,000. overage is a bounded, deliberate amount of credit past the limit, which beats the two usual options of "hard stop mid-migration" and "unbounded free usage". And externalId is the seam back to your own billing table, which is the field people forget and then bolt on with a metadata hack.
Now hand out two credentials for the same tenant:
curl -X POST "https://api.reqkey.com/key/create" \
-H "Authorization: Bearer reqkey_xxx..." \
-H "Content-Type: application/json" \
-d '{
"consumerId": "cons_A1B2C3D4",
"allowedApis": ["api_payment"],
"prefix": "prod",
"tag": "checkout-service"
}'
{
"key": "prod_A1B2C3D4E5F6G7H8I9J0K1L2",
"keyId": "key_X1Y2Z3A4",
"status": "active"
}
One note that has bitten people: allowedApis defaults to ["*"] when you leave it out. If you're scripting key creation, pass it explicitly, or your narrow key quietly becomes a wide one.
Validate from the first key, then from the second, and watch the shared pool move:
# first key
curl -X POST "https://api.reqkey.com/key/validate" \
-H "Authorization: Bearer reqkey_xxx..." \
-H "Content-Type: application/json" \
-d '{"key": "prod_A1B2...", "apiId": "api_payment", "credits": 1}'
# => {"valid":true,"creditsRemaining":4999,"creditsLimit":5000, ...}
# second key, same consumer
curl -X POST "https://api.reqkey.com/key/validate" \
-H "Authorization: Bearer reqkey_xxx..." \
-H "Content-Type: application/json" \
-d '{"key": "prod_Z9Y8...", "apiId": "api_payment", "credits": 1}'
# => {"valid":true,"creditsRemaining":4998,"creditsLimit":5000, ...}
4,999 then 4,998. Two credentials, one budget. That's the whole point of the model, and it's the number the invoice agrees with.
Note the credits field takes a number, not a boolean. A cheap lookup can cost 1 and an expensive report can cost 25, which is how you stop pretending all requests are equal. Amazon's essay describes a harder version of the same idea: treat every request as the cheapest one, then go back after the call and debit the true cost, even pushing the quota negative until it settles.
In application code, the branches matter more than the happy path:
async function checkQuota(apiKey, apiId, cost) {
const res = await fetch("https://api.reqkey.com/key/validate", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.REQKEY_ROOT_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ key: apiKey, apiId, credits: cost }),
});
const body = await res.json().catch(() => ({}));
// Out of credits. Retrying does not help until a refill or top-up,
// so do not dress this up as a 429 with a Retry-After.
if (res.status === 402) {
return { ok: false, reason: "out_of_credits", remaining: body.creditsRemaining };
}
// Disabled key, disabled tenant, or a key with no access to this apiId.
if (res.status === 403) {
return { ok: false, reason: "forbidden", message: body.message };
}
// An unknown key returns 200 with valid:false, not an error status.
if (res.ok && body.valid !== true) {
return { ok: false, reason: "invalid_key", message: body.message };
}
if (!res.ok) {
// The quota check itself failed. Fail open or fail closed here,
// but make it a decision you wrote down, not an unhandled throw.
throw new Error(`quota check failed: ${res.status}`);
}
return { ok: true, remaining: body.creditsRemaining, limit: body.creditsLimit };
}
The 200-with-valid:false case for an unknown key is the one people miss, because res.ok is true and the naive check waves it through.
The move you get for free
Once the quota is on the tenant, the tenant becomes a switch. Set a consumer's status to disabled and every key under it stops validating, without touching any individual key's own status. Set it back to active and they all resume. That's a payment-failure workflow in one call, and it's only possible because there's a single entity above the credentials that means something to your business.
curl -X POST "https://api.reqkey.com/consumer/update" \
-H "Authorization: Bearer reqkey_xxx..." \
-H "Content-Type: application/json" \
-d '{"consumerId": "cons_A1B2C3D4", "status": "disabled"}'
# every key under this consumer now fails validation with:
# 403 {"valid":false,"message":"Consumer is disabled"}
Try building that when the quota and the on/off switch both live on twelve separate key rows.
Changing the plan later, which is where the maths gets rude
Here's an edge case almost no article covers, and every metered API hits it in month three: a mid-cycle downgrade.
A tenant is on 50,000 credits and has used 25,000. They downgrade to a 20,000 plan. Naively set the new limit and you get remaining = 20,000 - 25,000 = -5,000. Your customer just downgraded into an immediate hard block, on a plan they're paying for, and your support inbox finds out before your monitoring does.
There is no universally correct answer, only three defensible ones: give them a fresh pool, prorate what's left, or hold them at zero until the next refill. The important part is that you choose, and that your API lets you express the choice. ReqKey exposes used on the update call for exactly this:
curl -X POST "https://api.reqkey.com/consumer/update" \
-H "Authorization: Bearer reqkey_xxx..." \
-H "Content-Type: application/json" \
-d '{
"consumerId": "cons_A1B2C3D4",
"credits": {"limit": 20000, "used": 0}
}'
That's the "fresh pool" choice, written down explicitly. If your quota is per-key, this whole problem multiplies by the number of keys the tenant happens to hold, and no amount of clever refill logic saves you.
Refills belong at the same level for the same reason. A monthly reset on a shared account pool is one operation. A monthly reset on twelve key-level pools is twelve chances to drift out of sync, and drift here reads as either free credits or a false block.
Watch someone find the noisy neighbour
If you want the enforcement side of this in depth, and specifically how to spot which tenant is eating your capacity, this AWS re:Invent session on per-customer performance at scale is worth your time:
Where the account-level model falls short
Three honest ones, including two about our own product.
No sub-limits inside the pool. A shared consumer pool means any one key can spend the tenant's entire budget. ReqKey has no per-key ceiling today, so if you need "the batch job may use at most 20% of the account's credits", you enforce that in your own service before the validation call. That's a real gap, not a philosophical position.
Rerolling a key is a hard cutover. Regenerating a key value keeps its ID, prefix and scope, but the old value stops working immediately. There is no grace window. That makes reroll the right tool for a leaked credential and the wrong tool for planned rotation, which you do by issuing a second key on the same consumer instead.
Account-level counters can be too coarse for expensive routes. If one endpoint costs a hundred times what the others do, a single pool lets a tenant spend their whole month on it in an afternoon. Variable credit costs per call help; a dedicated per-route ceiling helps more. Reach for the second dimension when a specific route earns it, not by default.
Key takeaways
- The entity that owns the quota is a schema decision, not a config value. Algorithms are swappable in an afternoon; the quota's owner is a migration over live customer balances. Decide it before your first paying customer, not after.
- Anything a tenant can create for free cannot hold their budget. Per-key quotas multiply by however many keys the customer feels like minting, and that arithmetic always runs in their favour. Put the counter on the thing you invoice.
- Enforce in one place, attribute in another. One authoritative counter per account decides admission; a request log with the credential on it answers who spent what. Trying to make per-key counters do both jobs is how you end up with neither.
- Layer smaller limits on top, never underneath. When a batch job threatens the shared pool, add a per-credential or per-route ceiling above the account quota. Moving the real quota down to the key trades one problem for a billing leak.
- Write down what a downgrade does before you sell an upgrade. Limit minus used goes negative in month three, and the tenant discovers it as a hard block. Pick fresh pool, prorate, or hold at zero, and make your API able to say which.
If you'd rather not build the counter, the lookup, the refill job and the downgrade maths yourself: ReqKey puts the credit pool on the tenant, keys on top of it as scoped credentials, and shadowLimit, overage and refills as configuration rather than code you maintain. The free tier is 100,000 requests a month, which is enough to wire up a real tenant, issue two keys, and watch one pool drain from both. That's the experiment worth running before you commit a migration to it.



