# 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 Python SDK: pip install tokengate (https://pypi.org/project/tokengate/) MCP endpoint: https://tokengate.rodmena.co.uk/mcp/ (this guide, served as tools) Operated by: RODMENA LIMITED (https://rodmena.co.uk/) — company no. 16472788 ## Authentication Send a tenant-scoped API key as a bearer token on every request: Authorization: Bearer tg__ 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. ### Scopes Ask for the narrowest scope that covers what you do. A scope implies the ones listed under it, so you do not request them separately. consume data plane: /v1/consume, /v1/check, /v1/refund, reserve/commit/release, and a subject's own usage catalog:read list resources, policies, plans catalog:write create/patch/delete them (implies catalog:read) assignments:read read a subject's plan assignment assignments:write set/remove it (implies assignments:read, catalog:read) overrides:read read per-subject limit overrides overrides:write set them (implies overrides:read) alerts:read alert rules and events alerts:write manage alert rules (implies alerts:read) webhooks:read webhook endpoints and deliveries webhooks:write manage endpoints (implies webhooks:read) keys:read list API keys keys:write mint/rotate/revoke keys (implies keys:read) reporting:read /v1/ledger, /v1/usage/summary, /v1/audit tenant:read /v1/tenant ops:jobs reserved; /v1/ops/jobs/{job} is operator-only (root key) admin:read every :read scope above admin:write EVERYTHING above — a tenant superuser **`admin:write` is not "write access", it is tenant root.** It can mint and revoke API keys and rewrite or disable every policy in the tenant, including other services'. Do not accept it for a service integration: if you only assign plans, ask for `assignments:write`. A key can never mint a scope it does not itself hold, and minting a key for a different `actor` requires `admin:write` — so a leaked scoped credential cannot escalate or outlive its own revocation. Every minted key records `created_by_actor` alongside the `actor` it acts as. Keys may also carry constraints, which bound *what a key may act on* rather than what it may do: "constraints": {"plan_ids": ["..."], "subject_prefix": "mail:cert:", "catalog_name_prefix": "mail"} `plan_ids` restricts which plans an `assignments:write` key may assign; assigning any other plan returns 403 `plan_not_permitted` and changes nothing. `subject_prefix` restricts which subjects the key may touch at all — on the admin plane *and* the data plane. A subject outside it returns 403 `subject_not_permitted` and changes nothing. Use it when a staging or certification instance shares a tenant with production: scopes alone give both the same power over the same customers. `limit_amount` in an override applies to QUOTA policies only. A `rate_limit` policy is governed by `bucket_capacity` and `refill_rate_per_sec` on the policy itself, so sending `limit_amount` for one now returns 422 `validation_error` rather than storing a number that could never take effect. Attaching, detaching or disabling a rate_limit policy per subject still works — omit `limit_amount`. `catalog_name_prefix` bounds catalog authority by object NAME (literal startswith): a `catalog:write` key so constrained can create, patch, rename and delete only resources/policies/plans whose names match the prefix, and can only *reference* in-prefix objects when linking (a policy's resource, a plan's policies, an override's policy). Anything else returns 403 `catalog_not_permitted` and changes nothing. It also widens `assignments:write` by name: plans whose name matches the prefix are assignable in UNION with `plan_ids`, so a key that may create a plan may assign it without an operator round trip. Use it when services share a tenant and each should govern only its own catalogue. All are optional; an omitted constraint is unconstrained on that axis, and the `constraints` object in a key response shows only what is actually set. A key can never mint or patch a key less constrained than itself, and widening any existing key's constraints via PATCH requires `admin:write`. A key can never issue or grant a credential **less** constrained than itself — attempting it returns 403 `constraint_escalation` — and no key may edit its own constraints (403 `self_modification`). Without those rules a bounded key could mint an unbounded one and act through it. PATCH /v1/api-keys/{id} {"constraints": {"subject_prefix": "mail:"}} tightens a live key **without rotating its secret**, so a credential already deployed in a running service can be bounded without an outage. It takes effect immediately and is recorded in the audit trail as `api_key.patch`. ## 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`. **Do not sum `used` across a subject's policies** — the first rate_limit policy in the plan makes the total `None`/`NaN`, and that is a reporting bug, not a limit breach. 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. | | 429 | ledger_budget_exceeded | Tenant's daily ledger-row budget is spent. | | | | Batch your consumes; retry after reset_at. | | 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. ## Reading a subject's usage — the envelope is frozen `GET /v1/subjects/{subject}/usage` returns **exactly** these two keys, and this shape is a supported contract, not an implementation detail: {"subject": "user_42", "policies": [{"policy_id": "...", "policy_name": "monthly-budget", "kind": "quota", "limit": 100000, "used": 240, "remaining": 99760, "window": {...}}]} The list key is `policies` — not `usage`, not `results`. There is no need to probe for alternatives; a change here would be a breaking change and is pinned by a contract test. ## SDK exception contract (supported, stable) `QuotaExceeded` and `RateLimited` both expose these as **guaranteed attributes** — read them directly, not via `getattr`: | Attribute | Type | Meaning | |---|---|---| | `retry_after` | `int \| None` | Seconds to wait; mirrors the `Retry-After` header | | `reset_at` | `str \| None` | ISO-8601 instant the window resets | | `blocking_policy` | `dict` | The policy that tripped; `["policy_name"]` is always present | `blocking_policy` returns `{}` rather than `None` when absent, so `exc.blocking_policy.get("policy_name")` is always safe. `ServiceUnavailable` (503) also carries `retry_after`. These names will not be renamed without a major version bump. try: tg.consume(subject, {"api.calls": 1}) except RateLimited as exc: raise HTTPException(429, headers={"Retry-After": str(exc.retry_after or 1)}, detail=f"limit {exc.blocking_policy['policy_name']} reached") ## Admin plane (least-privilege scope shown per call) catalog:write 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": ["...", "..."]} assignments:write PUT /v1/subjects/{s}/assignment {"plan_id": "..."} DELETE /v1/subjects/{s}/assignment overrides:write PUT /v1/subjects/{s}/overrides {"overrides": [{"policy_id": "...", "limit_amount": 250000}]} keys:write POST /v1/api-keys {"scopes": ["consume"], "actor": "svc@you"} POST /v1/api-keys {"scopes": ["assignments:write"], "actor": "mail@you", "constraints": {"plan_ids": ["...", "..."]}} reporting:read GET /v1/ledger append-only usage, keyset paginated GET /v1/usage/summary rollup aggregates — LAGS, see below GET /v1/audit admin audit trail, keyset paginated ### Audit trail Every admin mutation is recorded and readable. `GET /v1/audit` returns the same paged envelope as the ledger, newest first, and supports `actor=`, `action=`, `from=`, `to=`, `limit=` and `cursor=`: {"entries": [{"id": "...", "seq": 412, "actor": "mail@you", "action": "assignment.put", "entity_type": "assignment", "entity_id": "cust_42", "before": {"plan_id": "..."}, "after": {"plan_id": "..."}, "at": "2026-07-25T17:40:00Z"}], "next_cursor": null} The log is append-only: there is no endpoint that edits or deletes an entry. Page with `seq`, not `at` — timestamps can collide, `seq` cannot. ### 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. ## Ledger rows, aggregation and the per-tenant row budget - A consume is a durable ledger write, not a free pre-check. Do not call /v1/consume once per event to enforce a token bucket: batch (`amount=N`), lease with /v1/reserve + commit, or run a local bucket and consume the tokens you actually used. /v1/check writes nothing. - A consume that touches ONLY `rate_limit` policies writes NO usage_ledger row. Its amounts are aggregated per minute and land in `/v1/usage/summary` (hour/day buckets) within ~3 minutes; `/v1/ledger` will not list those events. Quota consumes, refunds and reservation commits are ledgered as before. Token buckets are never reconciled from the ledger, so enforcement is unchanged. - Every tenant has a daily usage_ledger row budget (default 500,000 rows/UTC day; the operator can raise it per tenant). Past it, requests that would write rows are refused `429 ledger_budget_exceeded` with `budget`, `used`, `reset_at` and `Retry-After` (seconds to the next UTC midnight), and nothing is charged. An ops alert fires at 80%. If you hit this, you are metering per event; fix the call shape rather than asking for a bigger budget. ## 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 The official typed Python SDK (sync + async, automatic idempotency keys, a reserve/commit metering context manager) is on PyPI: pip install tokengate 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`. A success status always means permission, and the client enforces it: if a 2xx `consume` body ever carried `allowed: false` (impossible under the current server contract), the SDK raises `ProtocolViolation` rather than returning — a denial must never be readable as a grant. `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. ## MCP (Model Context Protocol) This guide is also served over MCP for agents that prefer tools to scraping: endpoint https://tokengate.rodmena.co.uk/mcp/ (streamable HTTP, stateless, no auth) tools list_sections, get_section(name), get_llms_txt Register it with an MCP client, e.g.: claude mcp add --transport http tokengate https://tokengate.rodmena.co.uk/mcp/ It is documentation only — the same content as this file, no tenant data and no way to call the metered API. API calls always go through the endpoints above with your own bearer key.