# TokenGate > Multi-tenant quota management as a service. Define metered resources, attach > quota and rate-limit policies to subjects, and atomically check-and-consume > usage. Includes reservations (reserve an estimate, commit the actual), an > append-only usage ledger, rollup reporting, and threshold alerts. Base URL: https://tokengate.rodmena.co.uk Version: 0.1.0 OpenAPI: https://tokengate.rodmena.co.uk/openapi.json Interactive docs: https://tokengate.rodmena.co.uk/docs ## Authentication Send a tenant-scoped API key as a bearer token on every request: Authorization: Bearer tg__ Scopes: `consume` (data plane), `admin:read`, `admin:write` (catalog, keys), `ops:jobs` (operator job endpoints). Keys are hashed at rest and displayed exactly once, when minted. This service cannot issue you a key — ask the operator. `/`, `/llms.txt`, `/docs`, `/openapi.json`, `/healthz`, `/ping` and `/readyz` are the only unauthenticated endpoints. All of them answer GET and HEAD; any other method returns 405 with an `Allow` header. ## Core concepts - resource: a metered thing, e.g. `llm.tokens`, `api.calls`, `storage.bytes`. - policy: a quota (limit per window) or rate_limit (token bucket) on a resource. Quota windows are `fixed` (minute/hour/day/week/month, calendar anchored in the policy timezone), `rolling`, or `lifetime`. - plan: a bundle of policies. assignment: binds a plan to a subject. - subject: whoever you meter — a user id, org id, API key, or tenant of yours. - enforcement mode: `strict` (PostgreSQL transaction, exact and durable — use for money-like budgets) or `fast` (Redis, with exactly-once ledger write-behind — use for high-throughput counters). ## Consume (the main call) POST /v1/consume { "subject": "user_42", "items": [{"resource": "llm.tokens", "amount": 1200}], "idempotency_key": "req-8f2c1a", "metadata": {"trace_id": "..."} } 200 response: { "allowed": true, "event_id": "01KYB...", "degraded": false, "results": [{"resource": "llm.tokens", "policy_id": "...", "policy_name": "monthly-budget", "kind": "quota", "mode": "strict", "allowed": true, "limit": 100000, "used": 1200, "remaining": 98800, "overage": false, "window": {"id": "...", "start": "...", "end": "...", "reset_at": "..."}}] } Rules: - The batch is atomic: every applicable policy is evaluated, and either all items commit or none do. - `items` may hold up to 50 entries; one entry per resource (merge duplicates yourself). Amounts are positive integers. - `degraded: true` means a backend was unavailable and the policy allowed the request through uncounted. Treat it as a signal, not an error. - One `results` entry per policy that applied, NOT per item. Quota policies report integer `used`/`remaining`; **rate_limit policies report `"used": null`** (a refilling token bucket has no cumulative usage) with `remaining` = tokens left, `limit` = `bucket_capacity`, and `window: null`. Branch on `kind` before doing arithmetic on `used`. Related: - POST /v1/check — same body, no side effects. **A denial is HTTP 200 with `"allowed": false`, never 429.** Branch on the field, not the status: {"allowed": false, "degraded": false, "results": [{... "allowed": false ...}], "blocking": {"code": "quota_exceeded", "blocking_policy": {"policy_name": "monthly-budget", "limit": 100, "used": 100, "requested": 10, "remaining": 0}, "reset_at": "...", "retry_after": 601626}} `blocking` is `null` when allowed. Only POST /v1/consume (and reserve/commit) answer 429. Use check for pre-flight display, never as a gate on its own — check-then-consume races; consume is the atomic decision. - POST /v1/refund — same body plus optional `reason`; returns usage to the quota (append-only: it writes a compensating ledger entry, never a mutation). Responds `{"event_id", "degraded", "results"}` — no `allowed` field. - GET /v1/subjects/{subject}/usage — **live** usage per policy, straight from the counters. This (or /v1/ledger) is the correct source for a balance check; /v1/usage/summary is not. Same `used: null` rule for rate_limit policies. - GET /v1/subjects/{subject}/entitlements — which policies apply, with windows. URL-encode the subject if it contains reserved characters. ## Reserve and commit (unknown cost up front) Use this when you learn the true cost only after the work runs (LLM calls, uploads, jobs): POST /v1/reserve {"subject": "user_42", "items": [{"resource": "llm.tokens", "amount": 4000}], "ttl_seconds": 120, "idempotency_key": "resv-1"} -> 201 {"reservation_id": "01KYB...", "status": "held", "expires_at": "...", "results": [...]} POST /v1/reservations/{reservation_id}/commit {"actuals": [{"resource": "llm.tokens", "amount": 3271}], "idempotency_key": "commit-1"} -> 200 {"status": "committed", "event_id": "...", "adjustments": [{"resource": "llm.tokens", "held": 4000, "actual": 3271, "delta": -729, "overage": 0}]} POST /v1/reservations/{reservation_id}/release {"idempotency_key": "rel-1"} -> 200 {"status": "released", "returned": [...]} Rules: - A held reservation counts against available quota until committed, released, or expired (`ttl_seconds`, 1..3600; expiry frees it within 60s). - Commit always succeeds — actuals are reality. Committing more than you held is allowed and reported as `overage`. - Commit/release on a reservation that is not `held` returns 409 `reservation_not_held`. ## Idempotency Send `idempotency_key` on every consume, reserve, commit, and refund. Retrying with the same key returns the original outcome instead of consuming again, and the response carries the header `X-TokenGate-Replayed: true`. Reusing a key with a *different* payload returns 409 `idempotency_key_reuse`. Keys are remembered for at least 24 hours. Generate one per logical operation (a UUID is fine) and reuse it across retries of that operation. ## Errors (RFC 7807 problem+json, stable `code` field) {"type": "https://tokengate.dev/problems/quota-exceeded", "title": "Quota exceeded", "status": 429, "code": "quota_exceeded", "blocking_policy": {"policy_name": "monthly-budget", "limit": 100000, "used": 99000, "requested": 1200, "remaining": 1000}, "reset_at": "2026-08-01T00:00:00+00:00", "retry_after": 601626} | status | code | what to do | |--------|-------------------------|-------------------------------------------| | 401 | unauthenticated, | Key missing, malformed, revoked, or the | | | invalid_api_key | tenant is suspended. Do not retry. | | 403 | insufficient_scope | Key lacks the scope. Do not retry. | | 409 | idempotency_key_reuse | Same key, different payload. New key. | | 409 | idempotency_in_flight | Duplicate in flight. Retry after ~1s. | | 409 | reservation_not_held | Already committed/released/expired. | | 422 | unknown_resource | Resource not defined for this tenant. | | 422 | validation_error | Fix the payload; see `errors`. | | 429 | quota_exceeded | Wait for `reset_at` / `Retry-After`. | | 429 | rate_limited | Retry after `Retry-After` seconds. | | 503 | backend_unavailable | Backend down, policy says deny. Retry | | | | with backoff. Never treat as allowed. | | 503 | authz_unavailable | RBAC service down (admin plane only). | Always honor the `Retry-After` header rather than a fixed sleep. A 429 or 503 means the usage was NOT counted. ## Admin plane (scope admin:write unless noted) POST /v1/resources {"name": "llm.tokens", "unit": "tokens"} POST /v1/policies {"name": "monthly-budget", "resource_id": "...", "kind": "quota", "limit_amount": 100000, "window_kind": "fixed", "window_unit": "month", "window_size": 1, "enforcement_mode": "strict"} POST /v1/policies {"name": "burst", "resource_id": "...", "kind": "rate_limit", "bucket_capacity": 20, "refill_rate_per_sec": 5} POST /v1/plans {"name": "pro", "policy_ids": ["...", "..."]} PUT /v1/subjects/{s}/assignment {"plan_id": "..."} PUT /v1/subjects/{s}/overrides {"overrides": [{"policy_id": "...", "limit_amount": 250000}]} POST /v1/api-keys {"scopes": ["consume"], "actor": "svc@you"} GET /v1/ledger (admin:read) append-only usage, keyset paginated GET /v1/usage/summary (admin:read) rollup aggregates — LAGS, see below ### Reading usage back `GET /v1/ledger` returns a **paged envelope, not a bare array**: {"entries": [{"id": "...", "event_id": "...", "subject": "user_42", "resource": "llm.tokens", "amount": 1200, "entry_type": "consume", "idempotency_key": "...", "reservation_id": null, "strict_deferred": false, "occurred_at": "..."}], "next_cursor": "eyJvY2N1cnJlZF9hdCI6..."} Filters: `subject`, `resource`, `from`, `to`, `limit` (1..500, default 100). Page by passing `next_cursor` back as `cursor`; it is `null` on the last page. Refunds appear as entries with a negative `amount`. `GET /v1/usage/summary` is served from **rollup tables built by a periodic job (hourly cadence)**, so it is eventually consistent and **returns `[]` for activity that has just happened**. Never use it for a balance check, a quota gate, or a "did my consume land?" assertion — use `GET /v1/subjects/{subject}/usage` (live counters) or `GET /v1/ledger` (live append-only truth). Summary is for reporting and charts over settled periods; its rows are `{resource, subject, granularity, bucket_start, amount_sum, event_count}` with `granularity` of `hour` or `day`. Notes: **`enforcement_mode: "strict"` combined with `window_kind: "rolling"` is rejected at policy creation with 422 `validation_error`** — strict counting needs discrete windows; use `fixed`/`lifetime` for strict, or `fast` for rolling. Window shape, window kind, and enforcement mode are immutable after creation — create a new policy instead. Alert rules (`/v1/alert-rules`) fire once per (rule, subject, policy, window, threshold) over HMAC-signed webhooks and/or email. ## Operational guarantees - Every failure direction over-counts rather than under-counts: TokenGate may deny early, but it will not silently oversubscribe a quota. - The usage ledger is append-only with exactly-once insertion; corrections are new entries, never row mutations. - Under a Redis outage, strict policies stay fully enforced via PostgreSQL. Under a PostgreSQL outage, fast policies stay enforced via Redis and the ledger catches up. Each policy's `on_backend_error` decides deny vs. allow. ## Client SDK A typed Python SDK (sync + async, automatic idempotency keys, a reserve/commit metering context manager) ships in the TokenGate repository under `clients/python`: from tokengate import TokenGate tg = TokenGate("https://tokengate.rodmena.co.uk", api_key="tg_...") tg.consume("user_42", {"llm.tokens": 1200}) with tg.meter("user_42", {"llm.tokens": 4000}) as m: # reserve -> commit m.record("llm.tokens", 3271) Typed helpers (`consume`, `check`, `refund`, `reserve`, `commit`, `release`, `usage`, `entitlements`, `meter`) return parsed pydantic models. Denials raise typed exceptions — `QuotaExceeded`, `RateLimited`, `UnknownResource`, `IdempotencyKeyReuse`, `ReservationNotHeld`, `ServiceUnavailable`, `AuthenticationError`, `PermissionDenied`, `InvalidRequest`, `TransportError`, `ServerError`, all deriving from `TokenGateError`. Note `check()` does NOT raise on a denial: it mirrors the endpoint and returns `allowed=False` with a populated `blocking`. `tg.request(method, path, json=..., params=...)` is the escape hatch for admin endpoints without a typed helper. **It returns already-parsed JSON — a dict or list (and `None` for 204) — not an HTTP response object**, so there is no `.status_code` / `.json()` to call on it; failures raise the typed exceptions above: resources = tg.request("POST", "/v1/resources", json={"name": "llm.tokens"}) resources["id"] # dict, not a Response page = tg.request("GET", "/v1/ledger", params={"limit": 50}) page["entries"], page["next_cursor"] `AsyncTokenGate` mirrors the whole surface with `await` and `async with tg.meter(...)`. Plain HTTP works everywhere else — there is nothing SDK-specific about the protocol.