Admin API
Admin endpoints are mounted under /admin and protected with a bearer token.
Authenticate with the MASTER_KEY (the bootstrap/break-glass admin
credential, generated by ferrogw init) or an API key issued via
POST /admin/keys. POST /admin/session additionally accepts either
credential and exchanges it for a short-lived dashboard session token โ the
one admin route that itself needs no prior auth, since the credential it is
given is the auth.
Authorization: Bearer <master-key-or-admin-key-or-session-token>
Scopesโ
admin- full access, including all write endpointsread_only- read endpoints only
Every read endpoint accepts either scope; every write endpoint requires
admin. A key request or update naming a scope outside this set is refused
with 400 invalid_scope before anything is stored โ see
Create a key.
Read endpointsโ
GET /admin/dashboardGET /admin/keysGET /admin/keys/usageGET /admin/keys/{id}GET /admin/logsGET /admin/logs/statsGET /admin/providersGET /admin/providers/catalogGET /admin/pluginsโ configured plugins:[{ "name": ..., "type": ..., "enabled": ... }]GET /admin/plugins/catalogโ plugins this build ships, see Plugin catalogGET /admin/healthโ deep component + provider + MCP healthGET /admin/configGET /admin/config/historyGET /admin/sessionsโ list live dashboard sessionsGET /admin/auditโ durable audit trailDELETE /admin/sessionโ sign out the session that authenticated the request (any scope)
Write endpoints (admin scope)โ
POST /admin/keysPUT /admin/keys/{id}DELETE /admin/keys/{id}POST /admin/keys/{id}/revokePOST /admin/keys/{id}/rotateDELETE /admin/logsPOST /admin/configPUT /admin/configDELETE /admin/configPOST /admin/config/rollback/{version}DELETE /admin/sessionsโ sign every operator outDELETE /admin/sessions/{id}โ revoke one session
POST /admin/session is unauthenticated by design (see the intro above) and
is not gated by either scope group.
Query parametersโ
GET /admin/keys/usage:
limit(default20, max100)offset(default0)sort(usageorlast_used, defaultusage)active(trueorfalse)since(RFC3339)
GET /admin/logs:
limit(default50, max200)offset(default0)stageโ omitted defaults to terminal stages only (one row per request);allrestores the raw per-plugin-stage stream; any other value filters to that single stagemodel,providerapi_key_idโ the recorded credential id, ornonefor rows that name no credentialsince(RFC3339)
GET /admin/logs/stats:
limit(top-N buckets inby_provider/by_model, max100)buckets(time-series resolution, max120)stage,model,providersince(RFC3339)
DELETE /admin/logs:
- required:
before(RFC3339) - optional:
stage,model,provider(noapi_key_idโ a purge is scoped by stage/model/provider only)
GET /admin/audit:
limit(default50, max200)offset(default0)action,actor_idoutcome(ok,denied, orerror)since(RFC3339)
When request log storage is disabled, log endpoints return 501 not implemented.
The same applies to session, audit, and config endpoints when their backing
store isn't wired in.
API key schemaโ
Every key endpoint returns this JSON object. Timestamp fields are RFC3339 and
omitted when unset (omitempty).
{
"id": "1f2e3d4c-5b6a-7c8d-9e0f-a1b2c3d4e5f6",
"key": "fgw_3a7f...",
"name": "ci-pipeline",
"scopes": ["admin"],
"created_at": "2026-06-17T10:00:00Z",
"expires_at": "2026-12-31T23:59:59Z",
"revoked_at": null,
"rotated_at": null,
"last_used_at": "2026-06-17T11:42:00Z",
"usage_count": 128,
"active": true
}
The full fgw_... key string is returned only by POST /admin/keys (on
creation) and POST /admin/keys/{id}/rotate (on rotation). Every other endpoint
masks it to the first 8 characters plus ... (for example fgw_3a7f...). Store
the full value when it is first shown โ it cannot be retrieved again.
Create a key โ POST /admin/keysโ
Request:
{
"name": "ci-pipeline",
"scopes": ["read_only"],
"expires_at": "2026-12-31T23:59:59Z"
}
nameis required; an empty name returns400.scopesis optional โ when omitted (or[]) it defaults to["read_only"], the least-privilege choice. Send["admin"]explicitly to create an admin-scoped key.- Any scope outside
admin/read_onlyreturns400with codeinvalid_scope, naming the accepted set. expires_atis optional and must be RFC3339; an invalid value returns400.
Response (201 Created) is the full API key schema with the complete, unmasked
key shown this one time.
Give each operator their own admin-scoped key rather than sharing one โ a
shared key can't be revoked for a single person, and every audit row naming it
answers "who did this" with nothing useful. Keep MASTER_KEY for bootstrap
and break-glass recovery, not daily sign-in.
Read keys โ GET /admin/keys and GET /admin/keys/{id}โ
GET /admin/keys returns a JSON array of key objects; GET /admin/keys/{id}
returns a single object (404 if the id is unknown). In both cases the key
field is masked to key[:8] + "...". There is no way to read a full key back
after creation/rotation.
Update a key โ PUT /admin/keys/{id}โ
Request (all fields optional):
{
"name": "renamed-key",
"scopes": ["read_only"],
"expires_at": "2027-01-01T00:00:00Z",
"clear_expiration": false
}
Set clear_expiration: true to remove an existing expiry (it takes precedence
over expires_at). An omitted or empty scopes array leaves existing scopes
untouched โ it does not reset to read_only, unlike creation. A scope
outside admin/read_only returns 400 invalid_scope. The response is the
updated, masked key object.
A request that would drop the caller's own admin scope, delete/revoke the
caller's own key, or remove the last remaining admin key is refused with
409 (self_mutation_forbidden or last_admin_key) rather than locking every
operator out of the admin API.
Rotate a key โ POST /admin/keys/{id}/rotateโ
Generates a brand-new fgw_... string for the same key id, sets rotated_at,
and invalidates the previous string immediately. The response is the full key
schema with the new unmasked key โ shown once, like creation. Rotation is
never blocked by the lockout guard above: the response hands back a working
credential, so the caller can't be locked out by rotating their own key.
Revoke vs deleteโ
POST /admin/keys/{id}/revokeis a soft disable: it setsactive = falseand stampsrevoked_at. The record is retained (still visible inGET /admin/keys) but can no longer authenticate. Response:{ "status": "revoked" }DELETE /admin/keys/{id}is a hard delete: the key record is removed from the store entirely. Response:204 No Content.
Both return 404 for an unknown id, and both are subject to the same
self-mutation / last-admin-key guard as PUT.
Key usage โ GET /admin/keys/usageโ
Returns a { data, summary, filters } envelope. data is an array of (masked)
key objects sorted by sort; summary aggregates the filtered set; filters
echoes the applied query parameters.
{
"data": [ /* masked API key objects */ ],
"summary": {
"total_keys": 12,
"active_keys": 9,
"total_usage": 4096,
"returned_keys": 12
},
"filters": {
"limit": 20,
"offset": 0,
"sort": "usage",
"active": "",
"since": ""
}
}
Dashboard sessionsโ
The embedded web dashboard authenticates with short-lived session tokens rather than the raw API key, so the credential never sits in browser storage. Sessions last 24h absolute / 1h idle and carry the scopes of the credential that minted them.
Exchange a credential โ POST /admin/sessionโ
Unauthenticated route โ the presented bearer (an API key or MASTER_KEY) is
the authentication:
curl -X POST http://localhost:8080/admin/session \
-H "Authorization: Bearer $MASTER_KEY"
{
"token": "fgws_...",
"subject": "ci-pipeline",
"scopes": ["admin"],
"expires_at": "2026-06-18T10:00:00Z"
}
An invalid or revoked credential returns 401 invalid_api_key and is recorded
in the audit trail as a denied session.create
โ the highest-value row in the trail for spotting brute-force attempts.
Sign out โ DELETE /admin/sessionโ
Deletes the session row that authenticated this request, so the token stops
validating immediately. Available to read_only and admin alike, since any
signed-in session can sign itself out. 400 not_a_session if the request was
authenticated with an API key instead of a session token.
List and revoke sessionsโ
GET /admin/sessionsโ every currently live session:{ "data": [ { "id", "credential_id", "subject", "scopes", "created_at", "last_seen_at", "expires_at" } ] }.credential_idnames the key (orMASTER_KEY) the session was minted from;subjectis the operator-chosen name and isn't unique on its own.DELETE /admin/sessions/{id}(admin) โ revoke one session. Idempotent: an unknown id still returns204, so this can't be used to probe which session ids exist.DELETE /admin/sessions(admin) โ sign every operator out at once; returns{ "revoked": <n> }. The nuclear option when a session secret may be compromised โ there's no secret to rotate, so this is the equivalent.
Request logs โ GET /admin/logsโ
Returns a { data, summary, filters } envelope where data holds request-log
entries. Each row carries duration_ms, ttft_ms (streaming only), and
cost_usd โ all nullable, since a row written before persistence started, or
one the catalog can't price, legitimately carries none of them.
By default the response is one row per request (only the terminal
stage โ after_request on success, on_error on failure โ is returned), even
though the logger writes one row per plugin stage internally. Pass
stage=all to get the raw per-stage event stream, or stage=<name> to filter
to one stage.
{
"data": [ /* request log entries */ ],
"summary": {
"total_entries": 1500,
"returned_entries": 50
},
"filters": {
"limit": 50,
"offset": 0,
"stage": "",
"stages": ["after_request", "on_error"],
"model": "",
"api_key_id": "",
"provider": "",
"since": ""
}
}
api_key_id=<id> narrows to rows served by that credential (matched exactly
against the recorded id, never re-validated against the key store โ a deleted
key still has rows). api_key_id=none selects rows that name no credential at
all: unauthenticated requests, and rows logged before the column existed.
Log statistics โ GET /admin/logs/statsโ
Aggregates scanned entries into counts, a time series, token/cost totals, and latency percentiles.
{
"summary": {
"total_entries": 5000,
"error_entries": 12,
"total_tokens": 1284000,
"prompt_tokens": 812000,
"completion_tokens": 472000,
"cost_usd": 41.27,
"unpriced_requests": 6
},
"latency_ms": { "p50": 420, "p95": 1380, "p99": 2510, "max": 4102, "mean": 610, "count": 4988 },
"ttft_ms": { "p50": 180, "p95": 640, "p99": 990, "max": 1500, "mean": 240, "count": 3102 },
"by_stage": { "after_request": 4988, "on_error": 12 },
"by_provider": {
"openai": { "count": 4200, "errors": 8, "tokens": 980000, "cost_usd": 32.10, "unpriced": 0 }
},
"by_model": {
"gpt-4o": { "count": 3100, "errors": 5, "tokens": 720000, "cost_usd": 24.55, "unpriced": 0 }
},
"top_errors": [ { "message": "upstream_unavailable", "count": 4 } ],
"series": {
"points": [
{ "start": "2026-06-17T10:00:00Z", "requests": 210, "errors": 1, "prompt_tokens": 34000, "completion_tokens": 12000 }
],
"truncated": false,
"start": "2026-06-17T00:00:00Z",
"end": "2026-06-17T23:00:00Z"
},
"filters": { "limit": 0, "buckets": 24, "stage": "", "model": "", "provider": "", "since": "" }
}
by_providerandby_modelcarry per-group error counts, token totals, and cost โ not bare counts โ andlimittrims each to its top-N by count.latency_ms/ttft_msarenullwhen nothing was measured (a fresh gateway, or a filter matching only non-streaming rows forttft_ms), not a misleading0.series.truncatedand thestart/endwindow tell you whether the series covers the full requested range โ a series that stopped short looks identical to a gateway that went quiet unless this is checked.cost_usdis a floor, not a total:unpriced_requestscounts requests whose model the catalog doesn't price, which contribute nothing to the sum.
Installed plugins โ GET /admin/pluginsโ
Returns a JSON array describing the plugins configured on this instance, read live from the running config:
[
{ "name": "word-filter", "type": "guardrail", "enabled": true },
{ "name": "max-token", "type": "guardrail", "enabled": false }
]
Plugin catalog โ GET /admin/plugins/catalogโ
Returns the plugins this build ships, independent of what's configured โ fixed for the process lifetime:
{
"data": [
{
"name": "budget",
"type": "ratelimit",
"summary": "Tracks estimated spend per API key and refuses requests once the configured budget is exhausted.",
"settings": ["spend_limit_usd", "input_per_m_tokens", "output_per_m_tokens", "cache_read_per_m_tokens", "cache_write_per_m_tokens", "max_keys", "store_id"],
"fails_open": false
}
]
}
settings lists only the config keys the plugin actually reads โ the same
list GET /admin/config uses to decide which keys
of that plugin's config block to show rather than withhold. fails_open
reflects the plugin's Type(): logging and metrics plugins fail open,
everything else fails closed (500) on an internal plugin error.
Health โ GET /admin/healthโ
A deeper diagnostic than the unauthenticated /readyz:
bearer-authenticated, so it can safely include MCP failure reasons that
/readyz withholds (a server URL, an auth header, a subprocess command line).
{
"status": "degraded",
"providers": [
{ "name": "openai", "status": "available", "models": 42 }
],
"components": [
{ "name": "API", "status": "healthy" },
{ "name": "Key store", "status": "healthy" },
{ "name": "Config store", "status": "healthy" },
{ "name": "Request logs", "status": "healthy" },
{ "name": "Audit log", "status": "unavailable" }
],
"mcp_servers": [
{ "name": "filesystem", "ready": false, "required": true, "last_error": "stdio transport: exit status 1" }
]
}
status is healthy, degraded, or no_providers. A component reports
disabled when its backing store isn't configured (e.g. no
REQUEST_LOG_STORE_BACKEND). The audit store failing never downgrades
status โ it fails open by design, so a broken audit trail doesn't take
traffic-serving out of rotation. An MCP server only downgrades status when
it is both required: true and not ready; an optional server being down costs
only its own tools.
Audit trail โ GET /admin/auditโ
Reads the durable audit trail โ key and session lifecycle events, config mutations, and log purges โ written best-effort alongside every consequential admin action. The write never blocks or fails the action it records; a persistently unreachable audit store means the structured log line is the record instead.
{
"data": [
{
"occurred_at": "2026-06-17T10:05:00Z",
"action": "key.create",
"actor": "Ops laptop (key-7f2)",
"actor_id": "master-key:a1b2c3d4",
"target_id": "1f2e3d4c-...",
"outcome": "ok",
"detail": "{\"name\":\"ci-pipeline\",\"scopes\":[\"admin\"]}",
"source_ip": "10.0.0.4",
"trace_id": "..."
}
],
"summary": { "total_entries": 1, "returned_entries": 1 },
"filters": { "limit": 50, "offset": 0 }
}
outcome is ok, denied (a guard refused the action, e.g. last_admin_key,
self_mutation_forbidden, or an invalid credential at POST /admin/session),
or error (the action itself failed). actor is the display form frozen at
write time (e.g. a key's name); actor_id is the credential id alone, for
filtering one operator's history. Nothing here echoes a credential value โ
detail is redacted before storage, as a backstop, not as license to pass
secrets to it. The store follows the same backend as the key store
(API_KEY_STORE_BACKEND); the in-memory default retains only recent entries
and is lost on restart, so a deployment that needs the full history
configures a SQL backend.
Gateway configurationโ
The config endpoints operate on the entire gateway config object โ every write is a full replace, not a partial patch. Supplying a partial body drops the omitted fields.
GET /admin/configreturns the current config, scrubbed (see below).PUT /admin/configreplaces the running config (200, body{ "status": "updated" }).POST /admin/configalso replaces it (201, body{ "status": "created" }).DELETE /admin/configresets to the startup config ({ "status": "deleted" }).
Both PUT and POST decode the body as strictly as a config file loads:
an unknown key returns 400 invalid_request naming it, rather than being
silently dropped. A body that decodes but fails schema validation (e.g. an
unroutable target_key) returns 400 invalid_config; a persistence failure
returns 500. When config management is not wired in, these return 501.
GET /admin/config scrubs the config before serving it โ it does not echo
literal secrets. Named string fields (URLs, tokens) have their secret portions
replaced with [REDACTED]; a ${VAR} reference is left as-is, since it names
a value rather than carrying one. Free-form maps โ mcp_servers[].env,
mcp_servers[].headers, observability.exporters[].config,
observability.tracing.headers, and any plugin's config keys it doesn't
declare in its catalog entry โ
are withheld key and value, each key replaced by [REDACTED_KEY_<n>]
(indexed over the sorted original names, so the response is stable across
calls but names none of them). aliases and a plugin's declared settings are
the exception and round-trip normally.
Because a withheld map's keys are gone, it can't be edited via GET โ
edit โ PUT โ edit those from the config file instead. PUT/POST also
refuse any body containing a [REDACTED...] marker anywhere, so
round-tripping an unedited GET response back can't silently overwrite a live
credential with placeholder text.
Config history โ GET /admin/config/historyโ
{
"data": [
{
"version": 1,
"updated_at": "2026-06-17T10:05:00Z",
"config": { /* full config snapshot, scrubbed */ },
"rolled_back_from": null,
"actor": "master-key:a1b2c3d4"
}
],
"summary": { "total_versions": 1 }
}
History is durable whenever a config store is configured
(CONFIG_STORE_BACKEND=sqlite|postgres): it survives a restart, numbers
versions from a single persistent counter, and a rollback can target a
version applied before the current process started. Each entry's actor
records the credential that applied it, in the durable case as well as the
fallback.
Only a deployment with no config store falls back to the in-memory list โ lost on restart, and version numbering restarts from 1 there too. The in-memory fallback is the exception, not the default: most production deployments configure a config store.
Roll back โ POST /admin/config/rollback/{version}โ
Re-applies the config snapshot identified by {version} and appends a new
history entry whose rolled_back_from records the version that was current
before the rollback. {version} must be a positive integer that exists in
history โ including a durable version from before this process started โ
otherwise 404.
{
"status": "rolled_back",
"rolled_back_to": 1,
"current_history_size": 3
}
ADMIN_BOOTSTRAP_KEY, ADMIN_BOOTSTRAP_READ_ONLY_KEY, and ADMIN_BOOTSTRAP_ENABLED were removed in v1.4.0 ; MASTER_KEY is the only bootstrap credential now. See Configure the server for the full env var reference.
Relatedโ
- Authentication โ
MASTER_KEY, virtual keys vs.fgw_...API keys - Request logging โ the plugin that feeds
/admin/logs - Monitoring โ
/health,/readyz,/metrics - Data handling & security โ credential redaction across the gateway
- API errors โ the shared error envelope
- Dashboard โ the UI built on this API