Skip to content

Tenant quotas

Requires a license with the usage-pro feature. See pricing.

A tenant quota caps what one tenant may use: requests per month, media storage and bandwidth per month. A soft quota only warns the tenant's clients. A hard quota blocks the tenant with 429 once it passes a limit. A tenant reads where it stands and asks for more, and a super admin approves or denies the request.

Setting a limit is what usage-pro adds. Reading quotas, enforcing the ones already stored, lifting a block and handling increase requests stay free on every install.

QuotaWhat happens past a limit
None, or every limit 0Nothing. The tenant is unlimited.
Soft (is_hard_limit off)Responses carry warning headers. Nothing is refused.
Hard, without block_on_exceededResponses carry X-Quota-Exceeded: true. Nothing is refused.
Hard, with block_on_exceededThe first request over a limit blocks the tenant. Every request then answers 429 until the block lifts.

A block lasts only as long as its cause. Raising or removing the limit lifts it, and so does usage falling back under the limit, such as the request count resetting at the start of a month. unblock lifts it by hand. The quota routes stay open to a blocked tenant, so its admin can still read the quota. If a quota cannot be checked within two seconds, the request goes through.

A quota is set over a tenant, so a tenant admin reads it and asks for more but never changes it. Setting, removing and reviewing are for a super admin, and a tenant admin who tries gets 403 even for their own tenant.

Run this on an install whose license carries usage-pro and multitenant-provisioning, signed in as a super admin. You need the token in TOKEN. The quickstart shows how to get one. A test tenant keeps the block away from your real traffic.

  1. Create a tenant to test on:

    Terminal window
    curl -X POST http://localhost:3001/api/admin/tenants \
    -H "Authorization: Bearer $TOKEN" \
    -H "Content-Type: application/json" \
    -d '{"slug": "quotatest", "name": "Quota test"}'
  2. Give it a hard quota of five requests a month:

    Terminal window
    curl -X PUT http://localhost:3001/api/admin/quotas/quotatest \
    -H "Authorization: Bearer $TOKEN" \
    -H "Content-Type: application/json" \
    -d '{"requests_limit": 5, "is_hard_limit": true, "block_on_exceeded": true}'

    The answer is the quota, with "requests_limit": 5 and "blocked_at": null.

  3. Call the Content API seven times as that tenant:

    Terminal window
    for i in 1 2 3 4 5 6 7; do
    curl -s -o /dev/null -w "%{http_code} " http://localhost:3002/api/v1/quotas/status \
    -H "Authorization: Bearer $TOKEN" -H "X-Tenant-ID: quotatest"
    done

    You see five 200 answers, then 429 twice. A blocked answer carries X-Quota-Blocked: true:

    {"error": "quota exceeded - tenant is blocked", "tenant": "quotatest", "blocked_at": "2026-10-03T15:03:06Z", "requests_used": 5, "requests_limit": 5}
  4. Lift the block and the limit together:

    Terminal window
    curl -X PUT http://localhost:3001/api/admin/quotas/quotatest \
    -H "Authorization: Bearer $TOKEN" \
    -H "Content-Type: application/json" \
    -d '{"unblock": true, "requests_limit": 0}'

    The tenant is served again. Both changes are free.

  5. Ask for more as the tenant, then approve it:

    Terminal window
    curl -X POST http://localhost:3002/api/v1/quota-requests \
    -H "Authorization: Bearer $TOKEN" -H "X-Tenant-ID: quotatest" \
    -H "Content-Type: application/json" \
    -d '{"requests_limit": 100000, "reason": "Launch traffic in October"}'

    The answer is 201 with "status": "pending". Copy its id into REQUEST_ID, then:

    Terminal window
    curl -X POST http://localhost:3001/api/admin/quota-requests/$REQUEST_ID/review \
    -H "Authorization: Bearer $TOKEN" \
    -H "Content-Type: application/json" \
    -d '{"status": "approved"}'

    The quota now reads "requests_limit": 100000.

  6. Delete the test tenant with DELETE /api/admin/tenants/{id}. Its quota and requests go with it. In the admin console, a super admin finds quotas under Operations > Quotas and usage.

PUT /api/admin/quotas/{tenantID} creates or changes a quota. Fields you leave out keep their value. The answer is the full quota, with id, tenant_id, every limit, blocked_at and the timestamps.

FieldMeaningDefault
requests_limitRequests per month. 0 is unlimited.0
storage_bytes_limitMedia storage in bytes. 0 is unlimited.0
bandwidth_bytes_limitBandwidth per month in bytes. 0 is unlimited.0
is_hard_limitWhether the quota can block.false
block_on_exceededBlock the tenant once a limit is passed. Takes effect only with is_hard_limit.false
warn_at_pct_80, warn_at_pct_90Send the warning headers at 80% and 90%.true
grace_period_hoursReported with the status. It does not delay a block.24
unblockSend true to clear a block.
WriteNeeds usage-pro
Set a requests, storage or bandwidth limit to a new value above 0Yes
Turn is_hard_limit or block_on_exceeded onYes
Approve an increase request that does eitherYes
Read quotas, increase requests and a tenant's own statusNo
File an increase request, or deny oneNo
Delete a quota, clear a limit to 0, turn the hard limit or the block offNo
Lift a block with unblockNo
Change the warning thresholds or the grace periodNo
Send back the values a quota already holdsNo

A tenant with a quota gets these headers on each response:

HeaderWhen
X-Quota-Requests-Used, X-Quota-Requests-Limit, X-Quota-Requests-PctAlways.
X-Quota-Storage-Used, -Limit, -PctA storage limit is set.
X-Quota-Bandwidth-Used, -Limit, -PctA bandwidth limit is set.
X-Quota-Warning-80, X-Quota-Warning-90Any limit has reached that share.
X-Quota-ExceededA limit is passed but the tenant is not blocked.
X-Quota-BlockedThe tenant is blocked. The answer is 429.

GET /api/v1/quotas/status answers any signed-in user with their tenant's usage and state:

{
"tenant_id": "default",
"requests_used": 0,
"requests_limit": 0,
"requests_pct": 0,
"storage_bytes_used": 0,
"storage_bytes_limit": 0,
"storage_bytes_pct": 0,
"bandwidth_bytes_used": 0,
"bandwidth_bytes_limit": 0,
"bandwidth_bytes_pct": 0,
"is_blocked": false,
"is_warning_80": false,
"is_warning_90": false,
"is_exceeded": false,
"is_hard_limit": false,
"grace_period_hours": 24
}

The usage.quota_check node reads where the run's tenant stands without using up any quota. It is free. Its one optional setting, dimension, is any (the default), requests, storage or bandwidth. The output carries:

  • ok: true while the tenant is not blocked and the chosen dimension is under its limit.
  • remaining: what is left in that dimension, or null when unlimited. For any, the limit closest to running out.
  • blocked, exceeded, warning_80 and warning_90.
  • used, limit, remaining, pct and unlimited for each of requests, storage and bandwidth.

If the node's id is check, branch on {{ nodes.check.output.ok }} before the work. See flows.

Every quota you stored keeps being enforced. A tenant past a hard limit still gets 429, so a lapse never lifts a limit you set. You can still clear a limit to 0, lift a block or delete the quota. Setting a new limit needs usage-pro again.

An admin who names another tenant gets 403 with cross-tenant access not allowed. A super admin may name any tenant. Deleting a tenant deletes its quota and increase requests.

Quota and increase request routes
MethodPathWhoPurpose
GET/api/admin/quotasAdminList quotas, with limit and offset. An admin sees their own tenant's.
GET/api/admin/quotas/{tenantID}AdminOne tenant's quota.
PUT/api/admin/quotas/{tenantID}Super adminCreate or change a quota.
DELETE/api/admin/quotas/{tenantID}Super adminRemove a quota, which makes the tenant unlimited.
GET/api/v1/quotas/statusSigned inThe caller's tenant's usage and state.
POST/api/v1/quota-requestsSigned inAsk for higher limits for the caller's tenant: requests_limit, storage_bytes_limit, bandwidth_bytes_limit and a required reason.
GET/api/admin/quota-requestsAdminList increase requests, optionally ?status=pending, approved or denied.
POST/api/admin/quota-requests/{id}/reviewSuper adminApprove or deny a request: {"status": "approved"} or "denied".
StatusMessageCause
400reason is requiredAn increase request with no reason.
400status must be 'approved' or 'denied'Bad review status.
402payment_required, naming feature:usage-proThe write sets a new limit or turns a block on without usage-pro. Nothing is stored.
403cross-tenant access not allowedAn admin named another tenant.
403insufficient permissions or super_admin requiredA caller without super_admin set, removed or reviewed a quota.
404tenant not found, quota not found or quota request not foundUnknown id.
429quota exceeded - tenant is blockedThe tenant is past a hard limit.