API Reference
Both planes are served by the gateway on port 8080. They are separated by the
key that reaches them, not by the port: a data key opens /v1 and nothing else,
an admin key opens /admin/v1 and nothing else. A key presented to the wrong
plane is a 401 with code wrong_plane, never a partial success.
[!TIP] Both planes are published in full as an OpenAPI specification and a Postman collection — every route, on either plane. Both are generated from the source rather than written, and the gateway's own tests fail if either drifts from what it serves.
Every failure, on either plane, is the same envelope:
{
"error": {
"message": "The requested model is not available to this tenant.",
"type": "invalid_request_error",
"code": "model_not_found",
"param": "model"
}
}
1. Data plane (/v1)
Authenticate with a tenant data key: Authorization: Bearer cg-.... Admin keys
carry the prefix cga-, so which plane a credential belongs to is legible
without trying it.
| Method | Path | Purpose |
|---|---|---|
POST | /v1/chat/completions | Route a completion, streaming or not |
GET | /v1/models | The models this tenant can reach, aliases included |
GET | /v1/models/{id} | One model's metadata |
GET | /v1/usage | Token and spend totals over a window |
GET | /v1/usage/breakdown | The same totals grouped by model, provider, key or tag |
GET | /v1/health | Per-provider reachability for this tenant |
GET | /v1/meta | What this deployment supports, and its limits |
GET /healthz needs no key at all — it is what a load balancer polls. GET /metrics serves Prometheus when metrics are enabled, guarded by its own token
when one is configured.
Route a completion
POST /v1/chat/completions
curl -X POST http://localhost:8080/v1/chat/completions \
-H "Authorization: Bearer $COGNIGATE_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "fast",
"messages": [{"role": "user", "content": "What is the capital of France?"}]
}'
model may be a concrete model id or one of the tenant's capability aliases.
Set "stream": true for server-sent events; the response is relayed chunk by
chunk and the usage record is written when the stream ends. 1 and 0 are
accepted for it as well, because some clients send those; anything that is
not a boolean in one of those spellings is a 400.
Every response carries X-CogniGate-Request-Id. A routed one also carries
X-CogniGate-Served-By — the provider and model that actually answered — and
X-CogniGate-Fallback-Depth, which is 0 when the first choice answered. When
quotas are configured, X-CogniGate-Quota-State reports the caller's position
on every response, refusals included: ok, soft-exceeded or hard-exceeded.
Send X-Client-Request-Id on the request to tag it with a correlation id of
your own. It is stored on the usage record and is what group_by=client_request_id
groups on.
Read usage back
GET /v1/usage?window=day
window is day or month, and defaults to day. The window it resolves to
is half-open — recorded_at >= since and recorded_at < until — so a record on
a boundary falls in exactly one of two adjacent windows, and the resolved bounds
come back in the response.
curl "http://localhost:8080/v1/usage?window=month" \
-H "Authorization: Bearer $COGNIGATE_KEY"
{
"object": "usage",
"window": "month",
"since": "2026-09-01T00:00:00Z",
"until": "2026-09-03T11:04:22Z",
"requests": 1420,
"cached_requests": 88,
"prompt_tokens": 384200,
"completion_tokens": 91043,
"total_tokens": 475243,
"charge_usd": 4.81,
"state": "ok",
"limits": []
}
There is no cost_usd there because billing.cost_visibility defaults to
hidden: what the provider charged the operator is not published to tenants,
and charge_usd is what this caller owes. Set it to hint or exact and the
key appears; the operator reads the exact figure on the admin plane either way.
GET /v1/usage/breakdown?group_by=model returns the same figures per
bucket under data, sorted by spend. group_by accepts model, provider,
key and client_request_id. The response is capped at the 200 costliest
buckets and sets "truncated": true when it had to cut — grouping by
client_request_id is what makes that bound necessary.
2. Admin plane (/admin/v1)
Authenticate with an admin key. The bootstrap key is set at startup; every key
after that is minted through the plane itself. An admin key carries a scope —
root reaches every tenant and can create them, a tenant-scoped key reaches
exactly one.
Reaching a tenant your key is not scoped to is a 404, not a 403. A 403
would confirm that the tenant exists, which is enough to enumerate a
deployment's customer list by guessing ids.
Deployment
| Method | Path | Purpose |
|---|---|---|
GET | /admin/v1/meta | Version, features, effective limits |
POST | /admin/v1/catalog/refresh | Re-read model catalogues from providers |
GET | /admin/v1/audit | Every mutation, with actor and result |
Admin keys
| Method | Path | Purpose |
|---|---|---|
POST | /admin/v1/admin-keys | Mint an admin key |
GET | /admin/v1/admin-keys | List them (metadata only) |
DELETE | /admin/v1/admin-keys/{id} | Revoke one |
Tenants
| Method | Path | Purpose |
|---|---|---|
POST | /admin/v1/tenants | Create — root only |
GET | /admin/v1/tenants | List |
GET | /admin/v1/tenants/{id} | Read one |
PATCH | /admin/v1/tenants/{id} | Rename, suspend, set limits, cache, capture and instruction policy |
DELETE | /admin/v1/tenants/{id} | Delete |
curl -X POST http://localhost:8080/admin/v1/tenants \
-H "Authorization: Bearer $COGNIGATE_ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "acme"}'
{
"id": "ten_7f3a…",
"name": "acme",
"status": "active",
"created_at": "2026-09-03T11:04:22Z"
}
PATCH also carries system_instruction: text this tenant's completion
requests put in front of the caller's own messages, after whatever the
deployment configures. An empty string is a value rather than an absent field,
and sets the tenant back to adding nothing. It is charged like any other prompt
text, because the provider counts its tokens.
Under a tenant
Everything below is scoped to one tenant at
/admin/v1/tenants/{tenant}/….
| Method | Path | Purpose |
|---|---|---|
POST GET | keys | Mint and list this tenant's data keys |
DELETE | keys/{id} | Revoke one |
POST GET | providers | Register an upstream and its credentials |
PATCH DELETE | providers/{id} | Update or remove one |
PUT GET DELETE | aliases/{name} | Define a capability alias (GET lists all) |
PUT GET | routing-rules | Define fallback chains |
DELETE | routing-rules/{id} | Remove one |
PUT GET DELETE | quota | Token, spend and request caps for the tenant |
PUT GET DELETE | keys/{id}/quota | The same, narrowed to one key |
POST | cache/flush | Drop this tenant's cached responses |
GET | captures | Read debug captures, when the tenant enabled them |
POST GET | webhooks | Subscribe to events |
DELETE | webhooks/{id} | Unsubscribe |
GET | events | Pull the same events a webhook would push |
GET | usage | This tenant's usage, as an administrator |
GET | usage/breakdown | Grouped, as an administrator |
Mint a data key
POST /admin/v1/tenants/{tenant}/keys
curl -X POST http://localhost:8080/admin/v1/tenants/ten_7f3a/keys \
-H "Authorization: Bearer $COGNIGATE_ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "production", "expires_at": "2027-01-01T00:00:00Z"}'
{
"key": {
"id": "key_2b91…",
"tenant_id": "ten_7f3a…",
"plane": "data",
"name": "production",
"prefix": "cg-2b91",
"created_at": "2026-09-03T11:06:10Z",
"expires_at": "2027-01-01T00:00:00Z"
},
"secret": "cg-2b91…",
"warning": "This is the only time the secret is shown. Store it now; it cannot be retrieved again."
}
The store keeps only a hash. secret is returned here and nowhere else — a
listing returns the metadata and the prefix, never the credential.
Register a provider
POST /admin/v1/tenants/{tenant}/providers
curl -X POST http://localhost:8080/admin/v1/tenants/ten_7f3a/providers \
-H "Authorization: Bearer $COGNIGATE_ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "openai",
"kind": "openai",
"base_url": "https://api.openai.com/v1",
"keys": ["<your provider key>"]
}'
kind selects the adapter. openai covers every provider that reimplements
the OpenAI wire format (Together, Groq, Fireworks, OpenRouter, Azure OpenAI,
vLLM, Ollama, LM Studio) because only the base URL differs between them. An
unrecognised kind falls back to it rather than refusing to route, since that
is what almost everything speaks.
gemini and anthropic are that same adapter aimed at each vendor's
OpenAI-compatible endpoint. What they add is the endpoint itself: neither
vendor leads with that URL in its own documentation, so these kinds carry it as
a default and base_url may be omitted for them. They also name the vendor
rather than openai in logs and in X-CogniGate-Served-By.
curl -X POST http://localhost:8080/admin/v1/tenants/ten_7f3a/providers \
-H "Authorization: Bearer $COGNIGATE_ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "gemini",
"kind": "gemini",
"keys": ["<your provider key>"]
}'
Anthropic documents its compatibility layer as a way to evaluate Claude with
OpenAI client code rather than as a production API, and it ignores rather than
rejects the request fields it does not implement: response_format, seed,
logprobs, presence_penalty, frequency_penalty. A caller relying on one of
those gets a successful answer computed without it. Nothing here translates to
Anthropic's Messages API or Google's generateContent, so features exposed
only natively are out of reach; a provider with no compatibility endpoint at
all still needs a translating proxy in front of it.
Spreading load across several keys
keys is a pool, and key_strategy decides which key a request starts from.
| Strategy | Behaviour |
|---|---|
round_robin (default) | Advances one key per request. Four keys take a quarter of the load each. |
failover | Always starts at the first key, reaching the rest only when it is rate limited. |
Under both, a 429 walks the remaining keys in the pool before the request
cascades to another provider, so no working credential is ever left untried.
Pick failover when the keys are not interchangeable (a paid key with a free
one behind it) and round_robin when they are, which is the usual reason for
registering a second key at all.
curl -X PATCH http://localhost:8080/admin/v1/tenants/ten_7f3a/providers/prv_9c21 \
-H "Authorization: Bearer $COGNIGATE_ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{
"keys": ["<key one>", "<key two>", "<key three>"],
"key_strategy": "round_robin"
}'
Provider keys are held in memory and are never serialised back out — a listing
shows key_prefixes, not keys.
Define an alias
PUT /admin/v1/tenants/{tenant}/aliases/fast
curl -X PUT http://localhost:8080/admin/v1/tenants/ten_7f3a/aliases/fast \
-H "Authorization: Bearer $COGNIGATE_ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{"capabilities": ["chat"], "cost_tier": "cheapest", "min_context_window": 16000}'
An alias either pins a model outright ("pin": "gpt-4o-mini") or describes what
it needs and lets the catalogue choose. See Routing.
Set a quota
PUT /admin/v1/tenants/{tenant}/quota
curl -X PUT http://localhost:8080/admin/v1/tenants/ten_7f3a/quota \
-H "Authorization: Bearer $COGNIGATE_ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{
"day": {
"requests": {"cap": 50000, "soft_threshold_pct": 80}
},
"month": {
"tokens": {"cap": 5000000, "soft_threshold_pct": 80},
"cost": {"cap": 250.0, "soft_threshold_pct": 80}
}
}'
Caps are set per window, day or month, in any of three units. tokens and
requests are counts, cost is what the traffic cost the operator in USD.
Omit a unit to leave it unlimited, so a tenant can be capped on spend without
also being capped on calls. At least one cap must be set.
day.requests is the per-day request allowance most vendors publish their own
limits as, and setting one under the provider's is how a deployment stops a
single tenant spending the whole account. Only served requests count: a request
refused by a rate limit, a quota or the concurrency cap writes no usage row, so
retrying against a cap that is already full does not dig the hole deeper.
Crossing the soft threshold emits an event; crossing the cap refuses the request
with quota_exceeded, or budget_exceeded for a cost cap. A key-level quota
narrows the tenant's: it is evaluated alongside it and can never widen it.