Admin API guide
August 3, 2026 ยท View on GitHub
Last modified: 2026-08-03
This is the task-oriented "how do I call it" guide to the embedded admin server: enabling it, authenticating, and a curl cookbook for the routes operators reach for most. For the exhaustive per-route schema (every field, every status code), see admin-api-reference.md. For the built-in dashboard that sits on top of this API, see admin-ui.md. For enabling, TLS, and the security posture, see admin.md.
Control plane, not data plane
sbproxy runs two separate listeners:
- The data plane (
proxy.http_bind_port) serves the traffic yourorigins:route: proxying, the AI gateway, MCP, everythingsb.ymlconfigures as a handler. - The control plane (
proxy.admin.port, default9090) is a second HTTP(S) listener, off by default, that serves operator traffic: health, metrics, the request log, config read/write, reload, key and credential lifecycle, model-host and cluster status, and the built-in web UI.
They never share a port. A request to /admin/keys on the data-plane
port 404s (or hits whatever origin matches that path); the admin API
only answers on the admin port. This split means you can put the data
plane on a public load balancer and keep the admin port on loopback,
a private network, or behind a bastion, independent of how the data
plane is exposed.
Enabling it: a complete example
Every admin route in this guide assumes an admin block like this
under proxy in sb.yml:
proxy:
http_bind_port: 8080
admin:
enabled: true
port: 9090
bind: 127.0.0.1
username: admin
password: ${ADMIN_PASSWORD}
max_log_entries: 1000
allow_ips: []
cors_origins: []
operators:
- username: oncall
password_hash: ${ONCALL_PASSWORD_HASH}
role: read_only
- username: deployer
password_hash: ${DEPLOYER_PASSWORD_HASH}
role: admin
tls:
cert: /etc/sbproxy/admin-cert.pem
key: /etc/sbproxy/admin-key.pem
origins:
"api.example.com":
action:
type: proxy
url: http://backend:3000
Passwords resolve from the environment at config load
(export ADMIN_PASSWORD=...); a bare literal also works for local
testing. Drop tls to serve plaintext on loopback while developing;
add it back before setting bind: 0.0.0.0 or listing allow_ips for
anything reachable off the local machine. Those same two fields decide
whether the default password is allowed: admin / changeme works on
loopback and is refused once either one makes the surface reachable from
another host. See admin.md,
admin.md, and
admin.md for
the full field reference.
With this config running, every example below targets
http://127.0.0.1:9090 (swap in https:// and your bind/port when
you have TLS and remote access configured):
export SB_ADMIN_URL=http://127.0.0.1:9090
export SB_ADMIN_PASSWORD='replace-me'
Authenticating: Basic vs. session + CSRF
The admin server accepts two credential shapes on every protected route:
-
HTTP Basic, using the top-level
username/passwordor anoperators[]entry. This is the right shape for curl, CI, and scripts. Send it on every request, no state to manage:curl -fsS -u "admin:${SB_ADMIN_PASSWORD}" "${SB_ADMIN_URL}/admin/keys" -
A browser session, for the UI (or any client that would rather not resend a password on every call).
POST /admin/loginverifies credentials (a Basic header, or a JSON{"username","password"}body) and responds with:-
A
Set-Cookie: sb_admin_session=...header:HttpOnly,SameSite=Strict,Securewhen TLS is on, good for 8 hours. -
A JSON body carrying the CSRF token and role:
{"role": "admin", "csrf_token": "3f9c...", "username": "admin"}
Because the cookie is
HttpOnly, JavaScript cannot read it back, so that is the point, it defeats simple cookie theft via XSS. But it also means a state-changing request authenticated by the cookie must prove it is the same client that logged in, by echoing the CSRF token in anX-CSRF-Tokenheader. That is a standard double-submit: an attacker who cannot read theHttpOnlycookie cannot forge the header either.# Log in, keep the cookie, capture the CSRF token. RESP="$(curl -fsS -c cookies.txt -X POST "${SB_ADMIN_URL}/admin/login" \ -H 'Content-Type: application/json' \ -d '{"username":"admin","password":"'"${SB_ADMIN_PASSWORD}"'"}')" CSRF="$(echo "$RESP" | jq -r .csrf_token)" # A mutation via the session must carry the cookie and the header. curl -fsS -b cookies.txt -X POST "${SB_ADMIN_URL}/admin/reload" \ -H "X-CSRF-Token: ${CSRF}" # POST /admin/logout revokes the session and clears the cookie. curl -fsS -b cookies.txt -X POST "${SB_ADMIN_URL}/admin/logout"GET /admin/sessionreports whether the current request carries a valid session ({"authenticated":true,"username":...,"role":..., "csrf_token":...}or{"authenticated":false}), which is how the UI recovers its identity and CSRF token after a page reload without forcing a fresh login. -
Basic-auth requests are CSRF-exempt: there is no cookie to forge,
so the header requirement does not apply. POST /admin/login,
POST /admin/logout, and GET /admin/session all run before the
general auth gate, so they work without an existing session (you need
somewhere to call to get a session). The signing key for sessions is
random per process: restarting the proxy invalidates every open
session, by design, since this is an admin surface, not a customer
login.
Roles: admin vs. read_only
Every operator identity, the top-level username/password and
each operators[] entry, has a role:
admin: every route, read and write.read_only: GET / read routes only. Aread_onlyoperator that attempts a mutation (POST,PUT,PATCH,DELETE) gets403before the mutation runs.
curl -i -u "oncall:${ONCALL_PASSWORD}" -X POST "${SB_ADMIN_URL}/admin/reload"
# HTTP/1.1 403 Forbidden
# {"error":"forbidden: read-only operator cannot perform this action"}
Give day-to-day operators read_only and reserve admin for accounts
that actually change state. Every mutation that passes the role gate
emits a structured event on the sbproxy::admin::audit tracing
target naming the operator, so a shared admin account still leaves
an attributable trail per request, but per-operator credentials with
the right role make that trail meaningful. A handful of routes carry
their own stricter or different rule instead of the general split;
compression content inspection is admin-only and requires handler
opt-in, and cluster enrollment authenticates a one-time token instead
of an operator at all. Those are called out where they apply in
admin-api-reference.md.
Error envelope
Every protected route that fails returns JSON:
{"error": "<reason>"}
with a conventional status: 400 bad request, 401 missing/invalid
credentials, 403 insufficient role or bad/missing CSRF, 404
unknown route or record, 405 wrong method, 409 conflict (a
revision mismatch, an in-flight reload, a terminal record), 429
rate-limited, 5xx server-side failure. Some families (keys,
credentials, model-host) add fields alongside error, for example a
revision conflict on a key returns expected_revision and
current_revision. See the per-route sections in
admin-api-reference.md for the exact shape.
Rate limiting
The admin server enforces its own in-process limiter, separate from
any rate_limits: block on the data plane: 240 requests/minute per IP
by default, and a global cap ten times that (2400/minute). Exceeding
either returns 429 and does not count against the next window. This
protects the admin port itself from a local flood. Tune it with
proxy.admin.rate_limit_per_minute in sb.yml (range 1 to 100000;
the limiter cannot be turned off).
Curl cookbook
All of these use the HTTP Basic convention above (SB_ADMIN_URL,
SB_ADMIN_PASSWORD exported). Swap in a session cookie + CSRF header
if you authenticated via /admin/login instead.
Health.
curl -fsS "${SB_ADMIN_URL}/healthz"
# {"status":"ok"}
curl -fsS "${SB_ADMIN_URL}/health" | jq '{status,version,checks}'
Extension inventory for the running generation. A read_only operator can
call this route. It reports safe bundle and hook metadata, never entry bytes,
source paths, attachment config, or secrets:
curl -fsS -u "oncall:${ONCALL_PASSWORD}" \
"${SB_ADMIN_URL}/api/extensions" \
| jq '{scope, summary, bundles, hooks, collisions}'
Look for scope.mode: "running", the expected config_revision, zero failures
and collisions, and active on hooks attached to this pipeline. Use
sbproxy doctor <config> --format json before reload for the stopped candidate
view. In that view, active means the candidate selected and wired the hook. It
does not claim traffic ran or that runtime health checks passed. Loaded hooks
without an attachment are unconsumed; not_evaluated appears when doctor
falls back to bundle loading because full candidate construction failed. The
running view marks AI hooks active with their compiled chain and payment hooks
active only after the payment dispatcher installs.
Mint a key (the plaintext token is returned once, on creation; save it now):
curl -fsS -u "admin:${SB_ADMIN_PASSWORD}" -X POST "${SB_ADMIN_URL}/admin/keys" \
-H 'Content-Type: application/json' \
-d '{
"name": "checkout-service",
"max_requests_per_minute": 600,
"allowed_models": ["gpt-4o-mini", "claude-haiku-4-5"],
"max_budget_usd": 25.0,
"tags": ["team:checkout"]
}' | jq '{token, key: .key.key_id}'
List keys (never returns secrets):
curl -fsS -u "admin:${SB_ADMIN_PASSWORD}" "${SB_ADMIN_URL}/admin/keys" \
| jq '.keys[] | {key_id, status, name, policy_revision}'
Run a chat completion through the playground (the same AI client the data plane uses, bypassing per-origin policy. See admin-api-reference.md):
# See what AI origins/models are configured.
curl -fsS -u "admin:${SB_ADMIN_PASSWORD}" \
"${SB_ADMIN_URL}/admin/api/playground/endpoints" | jq
curl -fsS -u "admin:${SB_ADMIN_PASSWORD}" -X POST \
"${SB_ADMIN_URL}/admin/api/playground/chat" \
-H 'Content-Type: application/json' \
-d '{
"origin": "ai.example.com",
"request": {"model": "gpt-4o-mini", "messages": [{"role": "user", "content": "ping"}]}
}' | jq '{status, model, usage, cost_usd, latency_ms}'
Run a chat completion through the real pipeline instead (impersonates
a chosen virtual key with a short-lived, single-use ticket and makes a
genuine loopback call into the server's own data-plane listener, so key
policy, governance, routing, and guardrails apply exactly as they would
for that key's own traffic. Plain-HTTP origins only; an origin with
force_ssl set answers 501):
curl -fsS -u "admin:${SB_ADMIN_PASSWORD}" -X POST \
"${SB_ADMIN_URL}/admin/api/playground/dispatch" \
-H 'Content-Type: application/json' \
-d '{
"key_id": "key_abc123",
"origin": "ai.example.com",
"request": {"model": "gpt-4o-mini", "messages": [{"role": "user", "content": "ping"}]}
}' | jq '{status, model, usage, cost_usd, latency_ms}'
Load or evict a model and follow the job to completion. load and
evict answer 202 with a job_id and poll_url when a durable job
store is configured, rather than blocking the request on the engine work;
with no job store configured (no production model host) they fall back
to the previous synchronous 200:
curl -fsS -u "admin:${SB_ADMIN_PASSWORD}" -X POST "${SB_ADMIN_URL}/admin/model-host/load" \
-H 'Content-Type: application/json' \
-d '{"deployment": "qwen2.5-0.5b-instruct"}'
# {"schema_version":1,"deployment":"qwen2.5-0.5b-instruct","state":"queued",
# "job_id":"01J...","poll_url":"/admin/model-host/jobs/01J..."}
# Poll it directly:
curl -fsS -u "admin:${SB_ADMIN_PASSWORD}" "${SB_ADMIN_URL}/admin/model-host/jobs/01J..." | jq
# List every retained job (active plus recent terminal history):
curl -fsS -u "admin:${SB_ADMIN_PASSWORD}" "${SB_ADMIN_URL}/admin/model-host/jobs" \
| jq '.jobs[] | {id, kind, state}'
Or tail the job instead of polling it. Each event carries an id:
line (its replay sequence number); an EventSource client echoes that
back as Last-Event-ID on reconnect, and the server replays anything
missed since that sequence before resuming the live tail. The stream
closes on its own once the job reaches a terminal state:
curl -fsS -u "admin:${SB_ADMIN_PASSWORD}" -H "Accept: text/event-stream" \
"${SB_ADMIN_URL}/admin/model-host/jobs/01J.../stream"
# Resume after the connection drops, replaying anything published since
# sequence 3:
curl -fsS -u "admin:${SB_ADMIN_PASSWORD}" \
-H "Accept: text/event-stream" -H "Last-Event-ID: 3" \
"${SB_ADMIN_URL}/admin/model-host/jobs/01J.../stream"
Spend and the recent-request log:
curl -fsS -u "admin:${SB_ADMIN_PASSWORD}" "${SB_ADMIN_URL}/api/usage/spend" | jq
# Windowed + grouped, from the durable rollups (survives restarts):
curl -fsS -u "admin:${SB_ADMIN_PASSWORD}" \
"${SB_ADMIN_URL}/api/usage/spend?window=24h&group_by=model" | jq
curl -fsS -u "admin:${SB_ADMIN_PASSWORD}" \
"${SB_ADMIN_URL}/api/requests?status=500&limit=20" | jq
Hot reload after editing sb.yml out of band:
curl -fsS -u "admin:${SB_ADMIN_PASSWORD}" -X POST "${SB_ADMIN_URL}/admin/reload" | jq
curl -fsS -u "admin:${SB_ADMIN_PASSWORD}" "${SB_ADMIN_URL}/admin/drift" | jq '.drift'
Cluster status (only meaningful with proxy.cluster configured;
returns a single-node view otherwise):
curl -fsS -u "admin:${SB_ADMIN_PASSWORD}" "${SB_ADMIN_URL}/admin/cluster/status" \
| jq '{summary, unhealthy_nodes}'
Fleet VRAM (summed across every currently eligible cluster node; a node that has dropped out of eligibility, is stale, or has never reported contributes nothing, rather than a guessed or stale value):
curl -fsS -u "admin:${SB_ADMIN_PASSWORD}" "${SB_ADMIN_URL}/admin/cluster/vram" \
| jq '{cluster, nodes}'
A key reading its own usage. Unlike everything else in this
cookbook, GET /v1/key/usage is a data-plane route: it answers on
proxy.http_bind_port (not the admin port), and it authenticates with
the caller's own virtual key bearer token, not an admin credential.
There is no key-id parameter; it always answers for whichever key
presented the bearer token:
curl -fsS -H "Authorization: Bearer ${SB_VIRTUAL_KEY}" \
"http://127.0.0.1:8080/v1/key/usage" | jq
Where to go next
- admin-api-reference.md - every route, every field, every status code.
- admin-ui.md - the built-in dashboard: build it, enable it, what each page does.
- admin.md - enabling the server, TLS, roles, and the security checklist.
- key-management.md - the full virtual-key policy model.
- audit-log.md - the tamper-evident audit trail for admin mutations.