API Conventions
Environments, authentication, rate limits, pagination, error format, and retention — the cross-cutting details every integration needs.
Quick take
- Two environments (live / test) — fully isolated, selected by API key prefix
- 100 req/60s per tenant default — 429 with
Retry-Afterwhen throttled - All errors use RFC 7807 Problem Details with stable
typeURIs - Pagination is cursor-based with a standard
{data, pagination}envelope
API Conventions
Cross-cutting rules that apply across every QuotaStack endpoint. Read this once; everything else builds on it.
Environments
Every tenant has two environments: live and sandbox. Use sandbox to test. Use live for real customers.
Your customer data is separated by environment. A customer you create in sandbox does not exist in live. The same is true for balances, subscriptions, ledger entries, reservations, topups, and webhook events. Each of those rows is stored with the environment it belongs to.
Your catalog is not separated by environment. Plans, plan variants, billable metrics, metering rules, and topup packages are shared. If you retire a plan while testing in sandbox, you retire it in live too. Read Environments & the shared catalog before you edit a plan or a price. This surprises people, and it can change what live customers are charged.
You choose the environment with your API key. The key prefix decides where the request lands:
| Prefix | Environment |
|---|---|
qs_live_... | live — real customer data |
qs_test_... | sandbox — test data |
The sandbox key prefix says test, and the environment is named sandbox. They mean the same thing. The API, the webhook payloads, and the dashboard all use the word sandbox.
Both keys are issued when a tenant is created. The admin dashboard shows data for whichever environment you have selected.
Authentication
All requests require an X-API-Key header:
curl https://api.quotastack.io/v1/billable-metrics \
-H "X-API-Key: qs_live_..."
API keys support optional scopes (credits:read, credits:write, subscriptions:write, etc.) set at key creation. A key with no scopes has full access within its environment.
Rotation: Create a new key (POST /v1/admin/api-keys), deploy your application with the new key, then revoke the old one (POST /v1/admin/api-keys/{id}/revoke). No downtime.
Rate limits
Default: 100 requests per 60 seconds per tenant. Applied as a shared bucket across all endpoints — no per-endpoint sub-limits today.
Throttled responses return 429 Too Many Requests with a Retry-After header indicating the number of seconds to wait before retrying.
Contact support if your workload routinely exceeds this — limits are generous defaults, not hard ceilings.
Pagination
List endpoints (ledger history, topups, etc.) use cursor-based pagination with a standard envelope:
{
"data": [],
"pagination": {
"has_more": true,
"next_cursor": "MDE5ZDZhZGUtOTE4ZC03ZjQ0LTgzYWEtMDAw..."
}
}
data holds the array of resources for the current page.
| Query param | Default | Max | Notes |
|---|---|---|---|
cursor | — | — | Opaque base64 string; pass the previous response’s next_cursor to fetch the next page. |
limit | 20 | 100 | Number of items per page. Minimum 1. |
When has_more is false, next_cursor is null and you’ve reached the end.
Error format (RFC 7807)
Every 4xx and 5xx response uses the RFC 7807 Problem Details format with Content-Type: application/problem+json:
{
"status": 402,
"type": "https://api.quotastack.io/errors/insufficient-credits",
"title": "Insufficient Credits",
"detail": "Customer balance is 0 mc, requested 1000 mc"
}
The type URI is a stable identifier — your code should switch on type, not the human-readable title.
Validation errors (422) add a validation_errors array. Each entry carries field, message, and code:
{
"status": 422,
"type": "https://api.quotastack.io/errors/validation-error",
"title": "Validation Error",
"detail": "One or more fields failed validation",
"validation_errors": [
{ "field": "credits", "message": "must be positive", "code": "invalid" }
]
}
field is a dotted path to the offending field, so nested failures are addressable:
{
"status": 422,
"type": "https://api.quotastack.io/errors/validation-error",
"title": "Validation Error",
"detail": "One or more fields failed validation",
"validation_errors": [
{ "field": "external_id", "message": "external_id is required", "code": "required" },
{ "field": "metadata.region", "message": "must be a string", "code": "invalid" }
]
}
Customer create and update return 422 for validation failures, not 500. This changed on 2026-07-25 — malformed customer payloads previously surfaced as 500, which made a permanent client error look like a transient server one. Retry logic that treats 5xx as retryable and 4xx as terminal now behaves correctly against these endpoints. See the changelog.
Common error types
type URI suffix | When |
|---|---|
/errors/bad-request | Malformed request (invalid JSON, bad params) |
/errors/unauthorized | Missing or invalid API key |
/errors/forbidden | API key lacks required scope |
/errors/not-found | Resource does not exist |
/errors/conflict | Idempotency key mismatch, state conflict, or insufficient balance on strict operations |
/errors/idempotency-key-reuse | An Idempotency-Key was reused on /v1/entitlements/consume with a different request body (see Idempotency) |
/errors/insufficient-credits | Balance below required amount for the operation |
/errors/rate-limited | Over the request-rate threshold |
/errors/validation-error | One or more fields failed validation (includes validation_errors) |
/errors/internal | Server-side failure (safe to retry) |
Session-specific (/errors/session-expired, /errors/invalid-token, /errors/account-locked, etc.) appear for admin-dashboard flows, not tenant API traffic.
Finding your tenant ID
Tenant IDs appear in the admin dashboard (Settings → Tenant) and in every JWT login response. Most integrations never need it — the API key identifies your tenant implicitly. Webhook configuration and a few tenant-level config endpoints take the tenant ID in the path; reach for the dashboard in that case.
Ledger and audit retention
Ledger entries are retained indefinitely. There is no cold-storage tier today. If you approach ~10M ledger entries per tenant, reach out — we’ll work through a retention strategy before you hit practical limits.
Timestamps
All timestamps are ISO 8601 in UTC (2026-04-14T10:30:00Z). The server rejects timestamps in the future for fields like occurred_at on usage events.
Common mistakes
Don't hardcode the environment in your URLs
The environment is picked by the API key, not the URL. Swapping qs_live_* for qs_test_* is the only switch your code should need.
Don't switch on title in error responses
The title is a human-readable string and may change. Switch on the type URI — it's the stable identifier (e.g. /errors/insufficient-credits).
Don't poll list endpoints without pagination
Without a cursor you'll always get the first page. Read pagination.next_cursor and pass it back as ?cursor=... until has_more is false.
Loading…