Skip to main content

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 endpoints
  • read_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/dashboard
  • GET /admin/keys
  • GET /admin/keys/usage
  • GET /admin/keys/{id}
  • GET /admin/logs
  • GET /admin/logs/stats
  • GET /admin/providers
  • GET /admin/providers/catalog
  • GET /admin/plugins โ€” configured plugins: [{ "name": ..., "type": ..., "enabled": ... }]
  • GET /admin/plugins/catalog โ€” plugins this build ships, see Plugin catalog
  • GET /admin/health โ€” deep component + provider + MCP health
  • GET /admin/config
  • GET /admin/config/history
  • GET /admin/sessions โ€” list live dashboard sessions
  • GET /admin/audit โ€” durable audit trail
  • DELETE /admin/session โ€” sign out the session that authenticated the request (any scope)

Write endpoints (admin scope)โ€‹

  • POST /admin/keys
  • PUT /admin/keys/{id}
  • DELETE /admin/keys/{id}
  • POST /admin/keys/{id}/revoke
  • POST /admin/keys/{id}/rotate
  • DELETE /admin/logs
  • POST /admin/config
  • PUT /admin/config
  • DELETE /admin/config
  • POST /admin/config/rollback/{version}
  • DELETE /admin/sessions โ€” sign every operator out
  • DELETE /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 (default 20, max 100)
  • offset (default 0)
  • sort (usage or last_used, default usage)
  • active (true or false)
  • since (RFC3339)

GET /admin/logs:

  • limit (default 50, max 200)
  • offset (default 0)
  • stage โ€” omitted defaults to terminal stages only (one row per request); all restores the raw per-plugin-stage stream; any other value filters to that single stage
  • model, provider
  • api_key_id โ€” the recorded credential id, or none for rows that name no credential
  • since (RFC3339)

GET /admin/logs/stats:

  • limit (top-N buckets in by_provider/by_model, max 100)
  • buckets (time-series resolution, max 120)
  • stage, model, provider
  • since (RFC3339)

DELETE /admin/logs:

  • required: before (RFC3339)
  • optional: stage, model, provider (no api_key_id โ€” a purge is scoped by stage/model/provider only)

GET /admin/audit:

  • limit (default 50, max 200)
  • offset (default 0)
  • action, actor_id
  • outcome (ok, denied, or error)
  • 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
}
warning

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"
}
  • name is required; an empty name returns 400.
  • scopes is 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_only returns 400 with code invalid_scope, naming the accepted set.
  • expires_at is optional and must be RFC3339; an invalid value returns 400.

Response (201 Created) is the full API key schema with the complete, unmasked key shown this one time.

tip

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}/revoke is a soft disable: it sets active = false and stamps revoked_at. The record is retained (still visible in GET /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_id names the key (or MASTER_KEY) the session was minted from; subject is the operator-chosen name and isn't unique on its own.
  • DELETE /admin/sessions/{id} (admin) โ€” revoke one session. Idempotent: an unknown id still returns 204, 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_provider and by_model carry per-group error counts, token totals, and cost โ€” not bare counts โ€” and limit trims each to its top-N by count.
  • latency_ms/ttft_ms are null when nothing was measured (a fresh gateway, or a filter matching only non-streaming rows for ttft_ms), not a misleading 0.
  • series.truncated and the start/end window 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_usd is a floor, not a total: unpriced_requests counts 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/config returns the current config, scrubbed (see below).
  • PUT /admin/config replaces the running config (200, body { "status": "updated" }).
  • POST /admin/config also replaces it (201, body { "status": "created" }).
  • DELETE /admin/config resets 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.

warning

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.

note

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.