quotastack Docs
Docs / Concepts / API Conventions

API Conventions

Environments, authentication, rate limits, pagination, error format, and retention — the cross-cutting details every integration needs.

Mental model — Cross-cutting rules every endpoint inherits. API key prefix picks the environment. Pagination, errors, and rate limits all follow a single standard shape — learn it once, apply it everywhere.

Quick take

  • Two environments (live / test) — fully isolated, selected by API key prefix
  • 100 req/60s per tenant default — 429 with Retry-After when throttled
  • All errors use RFC 7807 Problem Details with stable type URIs
  • Pagination is cursor-based with a standard {data, pagination} envelope
200 RESPONSE DATA The rows you asked for PAGINATION has_more · next_cursor Paginated list responses use these same two keys. Pass next_cursor back as cursor for the next page.

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:

PrefixEnvironment
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 paramDefaultMaxNotes
cursorOpaque base64 string; pass the previous response’s next_cursor to fetch the next page.
limit20100Number 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 suffixWhen
/errors/bad-requestMalformed request (invalid JSON, bad params)
/errors/unauthorizedMissing or invalid API key
/errors/forbiddenAPI key lacks required scope
/errors/not-foundResource does not exist
/errors/conflictIdempotency key mismatch, state conflict, or insufficient balance on strict operations
/errors/idempotency-key-reuseAn Idempotency-Key was reused on /v1/entitlements/consume with a different request body (see Idempotency)
/errors/insufficient-creditsBalance below required amount for the operation
/errors/rate-limitedOver the request-rate threshold
/errors/validation-errorOne or more fields failed validation (includes validation_errors)
/errors/internalServer-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.