---
title: API Conventions
description: Environments, authentication, rate limits, pagination, error format, and retention — the cross-cutting details every integration needs.
order: 10
---

# API Conventions

Cross-cutting rules that apply across every QuotaStack endpoint. Read this once; everything else builds on it.

> **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

## Diagram

Common API behaviors summarized: authentication, environment selection, rate limiting, pagination envelope, and error format.

| Concern | Convention |
|---|---|
| Auth | `X-API-Key: qs_live_*` (live) or `qs_test_*` (test) |
| Environment | Picked by API key prefix; data fully isolated |
| Rate limit | 100 req/60s per tenant, 429 + `Retry-After` |
| Pagination | `?cursor=&limit=` → `{data, pagination:{has_more, next_cursor}}` |
| Errors | RFC 7807 `application/problem+json`, switch on `type` URI |
| Timestamps | ISO 8601 UTC |
| Ledger retention | Indefinite |

## Environments

Every tenant has two fully isolated environments: **live** and **test**. Customers, balances, subscriptions, ledger entries, and webhooks are partitioned by `(tenant_id, environment)` — nothing crosses the boundary.

You switch environments by using a different API key. The key's prefix determines which environment your request lands in:

| Prefix | Environment |
|---|---|
| `qs_live_...` | Live (production data) |
| `qs_test_...` | Test / sandbox |

Both keys are issued when a tenant is created. The admin dashboard shows data for whichever key you authenticated with.

## Authentication

All requests require an `X-API-Key` header:

```bash
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:

```json
{
  "data": [ /* array of resources */ ],
  "pagination": {
    "has_more": true,
    "next_cursor": "MDE5ZDZhZGUtOTE4ZC03ZjQ0LTgzYWEtMDAw..."
  }
}
```

| 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](https://www.rfc-editor.org/rfc/rfc7807) format with `Content-Type: application/problem+json`:

```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:

```json
{
  "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" }
  ]
}
```

### 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/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`.
