Skip to content

Admin REST API

Continuum Router mounts its administrative API under /admin when the binary is built with the admin feature. The default feature set includes it.

The Admin API is intended for a trusted management network. Authentication is not enabled by default. Configure admin.auth before exposing these routes beyond localhost.

Authentication

Bearer token

admin:
  auth:
    method: bearer
    bearer_token: "${ADMIN_TOKEN}"
    allowed_ips: []
curl -H "Authorization: Bearer $ADMIN_TOKEN" \
  http://127.0.0.1:8080/admin/health

bearer_token must be at least 16 characters. allowed_ips is optional and accepts individual IPv4/IPv6 addresses or CIDR ranges.

HTTP Basic

admin:
  auth:
    method: basic
    basic_auth:
      username: admin
      password: "${ADMIN_PASSWORD}"
curl -u "admin:$ADMIN_PASSWORD" http://127.0.0.1:8080/admin/health

IP allowlist

admin:
  auth:
    method: ip_whitelist
    allowed_ips:
      - 127.0.0.1
      - 10.0.0.0/8

allowed_ips can also be combined with method: bearer or method: basic. IP checks are bypassed for Unix-socket connections because they have no peer IP address.

API key

admin:
  auth:
    method: api_key
    required_scope: admin
    allowed_ips: []
# Authorization: Bearer and X-API-Key are both accepted
curl -H "Authorization: Bearer $ADMIN_API_KEY" http://127.0.0.1:8080/admin/health
curl -H "X-API-Key: $ADMIN_API_KEY" http://127.0.0.1:8080/admin/health

method: api_key validates the presented key against the same API key store used by ordinary /v1 authentication and Admin key CRUD (/admin/api-keys). The key must be enabled, unexpired, and carry the required_scope (default admin). Because the store is shared, disabling, rotating, expiring, or deleting a key stops it from authenticating against the Admin API immediately, with no restart. Missing or invalid keys return 401; a valid key without the required scope, or a request from a non-whitelisted IP, returns 403. allowed_ips can be combined with method: api_key for layered security.

Authentication configuration is captured while the Admin router is built. Restart the router after changing admin.auth itself (for example switching method or required_scope); individual key changes take effect immediately through the shared store.

Audit logging

admin:
  audit:
    enabled: true
    log_level: info
    include_headers: false
    include_body: false
    excluded_headers:
      - authorization
      - x-api-key
      - cookie

Audit logging is enabled by default. Avoid request-body logging where requests may contain secrets or user data.

Endpoint inventory

All paths below are relative to the router origin.

Capabilities and health

Method Path Purpose
GET /admin/capabilities Compile-time capabilities and runtime-enabled subsystems
GET /admin/health Router service and backend health summary
GET /admin/models Aggregated model catalog under Admin authentication
POST /admin/models/refresh Refresh the aggregated model catalog

Backend management

Method Path Purpose
GET /admin/backends List backends and health information
POST /admin/backends Create a backend
GET /admin/backends/{name} Read one backend
PUT /admin/backends/{name} Replace one backend
DELETE /admin/backends/{name} Delete one backend
PUT /admin/backends/{name}/weight Update backend weight
PUT /admin/backends/{name}/models Update advertised models
POST /admin/backends/probe Validate and discover a transient backend candidate without registration
POST /admin/backends/{name}/models/discover Discover one backend's live model catalog without changing its selected models
POST /admin/backends/{name}/check Run an on-demand connectivity check without mutating health state

Backend mutations publish a new configuration snapshot through the configuration-update channel. Whether every field takes effect immediately depends on the subsystem; check /admin/config/hot-reload-status and restart for sections marked requires_restart.

Runtime backend durability is opt-in. Set the root backends_persistence_file key and restart the router to persist backends created by POST /admin/backends (including WebUI creation) in an owner-only YAML sidecar. With the key unset, those backends remain memory-only and disappear on restart. File-declared and Continuum Hub-managed backends are never written to the sidecar; a file declaration wins a same-name collision, while ordinary file hot reload preserves every unshadowed runtime backend. Hub overlay mode preserves local runtime backends, whereas an adopted authoritative snapshot may suppress them from the effective pool without deleting their sidecar records. A hot reload validates the file combined with the runtime backends and keeps the previously published configuration when that combination is invalid, for example when a reloaded tracing.headers name is already used by a runtime backend's request_extensions.headers.

GET /admin/backends reports runtime_persistence.enabled and a durability value on every backend: configured, memory, or persistent. Single-backend reads and mutation responses also report durability. Sidecar credentials are deliberately unmasked so they can be restored; protect the path like the main configuration. On Unix the router creates and replaces the file atomically with mode 0600. There is no source-config writeback.

POST /admin/backends/{name}/models/discover is read-only. It resolves exactly one configured backend by name, reuses the same backend-specific model fetcher used by normal aggregation, and returns that backend's live catalog before the configured backends[].models allowlist is applied. Use it when a management UI needs an available-model picker while keeping the selected routing allowlist unchanged.

curl -X POST http://127.0.0.1:8080/admin/backends/provider-openai/models/discover \
  -H "Authorization: Bearer $ADMIN_TOKEN"
{
  "backend": "provider-openai",
  "object": "list",
  "data": [
    {
      "id": "gpt-5.5",
      "object": "model",
      "created": 1760000000,
      "owned_by": "openai"
    }
  ]
}

The discovery response never writes configuration, invalidates the aggregated model cache, or changes backend health or circuit-breaker state. Codex OAuth backends use the loaded OAuth strategy and the account-plan filter from the normal Codex discovery path, including token refresh and chatgpt-account-id handling. Non-Codex OAuth backends, native Anthropic, and Bedrock return 501 with error.code: "model_discovery_unsupported" because the router has no provider-native catalog to query. Unknown backend names return 404 with error.code: "backend_not_found". Upstream failures are machine-readable as backend_authentication_failed, backend_discovery_timeout, backend_discovery_network_error, backend_discovery_parse_error, backend_discovery_http_error, or backend_discovery_response_too_large; response bodies do not echo access tokens, refresh tokens, token-store paths, or raw auth headers.

POST /admin/backends/probe accepts the same candidate backend fields as backend creation plus operations: ["health", "models"]. It is admin-authenticated on every Admin transport, including TCP and Unix sockets, and is advertised by GET /admin/capabilities as transient_backend_probe_v1. The candidate is validated in memory and is never added to active configuration, config history, backend pools, health state, circuit breakers, aggregation caches, environment files, or token stores.

The response separates health from catalog. health.credential_status is one of valid, invalid, unknown, or not_required; an unauthenticated healthy endpoint reports unknown when a credential was supplied because liveness alone does not prove the credential. Transient OAuth candidates report health.status: "unknown" with backend_probe_health_unsupported instead of fabricating a healthy result without their lifecycle-managed strategy. catalog.source is one of live, curated, configured, or unsupported. OpenAI-compatible, Gemini, Ollama, vLLM, llama.cpp, MLX, LM Studio, and similar model-list-capable candidates use the same URL composition, authentication headers, response-size cap, parsing, normalization, and model-count limits as registered discovery. Anthropic can return the router-curated catalog or explicit models/model_configs; Bedrock and OAuth candidates can return those bounded configured entries, while candidates without them return a typed unsupported catalog.

The probe implementation lives in the always-compiled src/backend_probe/ module, shared with the outbound Continuum Hub backend task executor (backend_tasks_v1, issue #1262), which runs Hub-authored candidate probes from the Router's own network. Both callers go through the same admission bounds, so a Hub task and an Admin request draw from one process-wide concurrency and rate ceiling rather than two, and the supported backend types, endpoint rule, unsupported-auth behavior, response-size limits, and credential classification cannot drift between the two surfaces. Nothing about the Admin request or response shape changed. The Hub path never gets an inbound endpoint on the Router, and it maps the result into the protocol's typed, bounded shape rather than forwarding this response.

GET /admin/capabilities also reports backend_preference_header_v1 in its capabilities array, next to transient_backend_probe_v1 above. The token names the x-backend request header (see Choosing a Backend with x-backend) and is present on any router build that carries it, regardless of compile-time features or the current config. Its meaning is a single one: the header is honored wherever the router selects a backend among interchangeable candidates, except the exceptions documented in that section; the token carries no per-surface list, so a client cannot use it to ask which individual endpoint honors the header. A client that reads the token needs no version literal to decide whether to send x-backend: send it once the token is present, and fall back to not sending it while the token is absent. Sending the header to a router old enough to omit the token costs nothing either way, because that router simply ignores it. A client that holds only an inference API key cannot reach /admin/capabilities; it can still detect the same behavior from the inference surface, as described in Confirming which backend answered.

Circuit-breaker state

Method Path Purpose
GET /admin/circuit/all List all circuit states
GET /admin/circuit/{backend}/status Read a backend circuit state
POST /admin/circuit/{backend}/open Force the circuit open
POST /admin/circuit/{backend}/close Force the circuit closed
POST /admin/circuit/{backend}/reset Reset the circuit

These endpoints control the same circuit-breaker state machine that ordinary LLM proxy requests drive and consult, so they report and adjust the state used by normal proxy routing (for example, open forces a backend out of selection until it recovers).

Configuration queries

Method Path Purpose
GET /admin/config Compact configuration summary
GET /admin/config/full Full current configuration with sensitive values masked
GET /admin/config/sections Supported Admin Config API sections and reload capability
GET /admin/config/schema JSON schema for supported Admin Config API sections
GET /admin/config/hot-reload-status Runtime hot-reload status and capability lists
GET /admin/config/{section} One supported section with sensitive values masked

GET /admin/config/full returns this envelope:

{
  "config": {},
  "hot_reload": {
    "enabled": true,
    "capabilities": {}
  },
  "metadata": {
    "retrieved_at": "2026-07-19T00:00:00Z",
    "version": 1
  }
}

Optional sections that are not configured are omitted from config rather than present with an explicit null value. Reading one of these sections directly is unaffected: GET /admin/config/{section} for an unconfigured but recognized section (for example metrics or fallback) still answers 200 with "config": null, not 404.

The section API accepts these names:

  • server
  • backends
  • health_checks
  • logging
  • retry
  • timeouts
  • rate_limiting
  • circuit_breaker
  • global_prompts
  • request_params
  • admin
  • fallback
  • model_aliases
  • files
  • api_keys
  • metrics
  • routing

This is a management subset, not a list of every top-level field accepted by the main configuration schema.

Configuration validation and mutation

Method Path Purpose
PUT /admin/config/{section} Replace a supported section
PATCH /admin/config/{section} Recursively merge a partial section object
POST /admin/config/validate Validate YAML, JSON, or TOML without applying it
POST /admin/config/export Export configuration, masked by default
POST /admin/config/import Validate and optionally publish a full configuration
GET /admin/config/history Read up to 50 history entries with masked snapshots
POST /admin/config/rollback/{version} Publish a configuration snapshot from history
POST /admin/config/apply Atomically apply a full configuration candidate, or a no-op when none is supplied

PUT and PATCH take the section value itself as the JSON body, not a { "config": ... } wrapper:

curl -X PATCH http://127.0.0.1:8080/admin/config/rate_limiting \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"enabled":true}'

A successful section mutation includes version, requires_restart, and hot_reload_capability. The API publishes an in-memory snapshot; it does not write the source YAML/TOML file.

Validation body:

{
  "content": "server:\n  bind_address: 127.0.0.1:8080\nbackends: []\n",
  "format": "yaml",
  "sections": []
}

With an empty sections array, content must be a complete configuration. For section-scoped validation, include the named top-level sections in content and list those names in sections.

Export body:

{
  "format": "yaml",
  "include_sensitive": false,
  "sections": []
}

format is yaml, json, or toml. An empty sections array exports the full configuration. include_sensitive: true returns plaintext secrets and produces a security audit log entry.

format: "toml" now succeeds even when many optional sections are unset. Those sections are omitted from the serialized configuration rather than written as null, and TOML has no way to represent a null value: previously, any unset optional section made every TOML export fail with a 500 SERIALIZATION_ERROR.

Import body:

{
  "content": "server:\n  bind_address: 127.0.0.1:8080\nbackends: []\n",
  "format": "yaml",
  "apply": false,
  "dry_run": true
}

Imports are limited to 1 MiB and a maximum structural nesting depth of 32. dry_run: true validates only. apply: true publishes the full candidate through the configuration-update channel and records a history version; restart-only sections still require a restart.

Masked secrets on the export to import round trip

Every masked value the Admin API returns, whatever the endpoint, carries the same fixed sentinel: ***CONTINUUM-MASKED:<hint>***. The hint discloses only a length: a value longer than four characters shows a two-character prefix plus the count (sk...(21 chars)), a value of four characters or fewer shows just the count ((4 chars)), and a masked boolean or number reads ***CONTINUUM-MASKED:hidden***. Earlier router builds returned the bare ***MASKED*** for any value that short, disclosing no length at all; after this change a short secret's length is visible where it previously was not, though its content never is. This is the same masking GET /admin/config/full, GET /admin/config/{section}, GET /admin/backends, and the GET /admin/config/history snapshots use, not a format specific to export.

Export masks every credential unless you pass include_sensitive: true, so the document you edit and send back normally carries these placeholders rather than secrets. Import treats each placeholder as "keep the value that is already live", splices the running secret back in, and reports every path it preserved:

{
  "success": true,
  "applied": true,
  "version": 4,
  "preserved_secret_paths": ["backends[0].api_key", "admin.auth.bearer_token"],
  "validation": {
    "valid": true,
    "warnings": [
      {
        "section": "backends",
        "path": "backends[0].api_key",
        "message": "'backends[0].api_key' carried a masked placeholder, so the value currently in effect was kept. The submitted document did not change this secret.",
        "code": "MASKED_SECRET_PRESERVED"
      }
    ]
  }
}

POST /admin/config/validate emits the same MASKED_SECRET_PRESERVED warnings, so the WebUI pre-save dry run shows what an import would leave untouched before anything is committed.

The same resolution runs on every endpoint that writes a configuration document, so what the dry run promises is what the save does: POST /admin/config/import, POST /admin/config/apply with a config candidate, and PUT/PATCH /admin/config/{section}. A section write that cannot resolve a placeholder is refused with 400 Bad Request, and the body still carries success: false and the failing path in path/code.

A placeholder is only resolved when the running configuration still holds the value it was made from. Array entries are matched by id or name rather than by position, so reordering backends resolves correctly, while a rename does not. Entries that carry neither, such as the plain string lists guardrails.bypass_api_keys and rate_limiting.bypass_keys, have only their position to match on, so adding or removing one entry is refused rather than guessed: two keys from the same provider share a prefix and a length, and a shifted index would otherwise resolve to the neighbouring key. Edit those lists through a document exported with include_sensitive: true, or set the real values. When a placeholder cannot be matched, the whole import is refused and the failing paths are named, rather than a neighbouring secret being attached to the wrong field:

Code Meaning
MASKED_SECRET_PRESERVED Warning. The live value at this path was kept; the submitted document did not change it.
MASKED_SECRET_UNRESOLVED Error. The placeholder at this path matches nothing in the running configuration, usually because an entry or section was renamed, moved between documents, or removed, or because an entry was added to or removed from a list whose entries carry no id or name. Set the real value, or re-export and edit that document.
LEGACY_MASK_PLACEHOLDER Error. The value has one of the placeholder shapes (***MASKED***, xy...(N chars), ${***VAR***}) emitted before the sentinel existed, typically because the document was exported before an upgrade. Those shapes are derived from the secret itself and cannot be told apart from a real credential, so they are never used to restore one. Re-export the current configuration and edit that document, or replace the flagged value with the real secret.
MASKED_SECRET_REPORT_TRUNCATED Reported at most once, as a warning or an error depending on which list it summarises. The per-path lists are capped at 500 entries so a document full of placeholders cannot inflate the response; the entry states how many further paths were preserved or refused without being listed.

A value that already equals the running one is left alone, whatever it looks like, so a configuration damaged by an older build (whose live credentials are themselves stale placeholder shapes) can still be edited section by section without retyping every secret first.

Values that are not placeholders are written through unchanged, so rotating a credential still works: replace the placeholder with the new secret and import as usual. An environment-variable reference round-trips as a reference: ${OPENAI_API_KEY} exports as ***CONTINUUM-MASKED:${OPENAI_API_KEY}*** and is restored to ${OPENAI_API_KEY}, never to the resolved value.

Apply body:

{
  "hot_reload": true,
  "description": "optional audit note",
  "config": { "...": "a full configuration object" }
}

POST /admin/config/apply only does work when you supply a full config candidate. Section edits (PUT/PATCH), import, and rollback already publish immediately, so:

  • No config (or null): explicit no-op. Nothing is published, no history version is recorded, and the response reports hot_reload_triggered: false with an empty updated_sections. Repeated calls never create empty history versions.
  • config present, hot_reload: true: the candidate is validated, diffed against the running configuration, and, if it differs, published atomically through the configuration-update channel. Exactly one history version is recorded with the sections that changed. hot_reload_triggered is true only after a successful publish; updated_sections and requires_restart reflect the real diff. A candidate refused here answers 400 Bad Request with the refusal in message.
  • config present, hot_reload: false: preview only. The response reports the updated_sections/requires_restart diff, but nothing is published and no history version is recorded. A refused candidate keeps 200 in this mode, because the request asked what would happen rather than for it to happen.

A candidate identical to the running configuration is treated as a no-op.

API keys

Method Path Purpose
GET, POST /admin/api-keys List or create keys
GET, PUT, DELETE /admin/api-keys/{id} Read, update, or delete a key
POST /admin/api-keys/{id}/rotate Rotate key material
POST /admin/api-keys/{id}/enable Enable a key
POST /admin/api-keys/{id}/disable Disable a key

API-key material is returned only when created or rotated. Store it immediately.

Prompts and statistics

Method Path Purpose
GET /admin/config/prompts List prompt files
POST /admin/config/prompts/reload Reload prompt files
GET, PUT /admin/config/prompts/{path} Read or update one prompt path
GET /admin/stats Global statistics snapshot
GET /admin/stats/series Global time series
GET /admin/stats/models Per-model statistics
GET /admin/stats/backends Per-backend statistics
GET /admin/stats/api-keys API-key statistics
GET /admin/stats/api-keys/{id} One key's statistics
GET /admin/stats/api-keys/{id}/models One key's model statistics
GET /admin/stats/api-keys/{id}/series One key's time series
GET /admin/stats/users User statistics
GET /admin/stats/users/{user_id} One user's statistics
GET /admin/stats/users/{user_id}/models One user's model statistics
GET /admin/stats/users/{user_id}/series One user's time series
POST /admin/stats/reset Reset in-memory statistics
GET /admin/metrics/history Persistent metric history; 404 when persistence is unavailable

Routing, cache, and guardrail controls

Method Path Purpose
GET /admin/prefix-routing/stats Prefix-routing statistics
GET /admin/response-cache/stats Response-cache statistics
POST /admin/response-cache/invalidate Invalidate response-cache entries
GET /admin/kv-index/stats KV index statistics
GET /admin/kv-index/backends Per-backend KV index state
POST /admin/kv-index/clear Clear the KV index
GET /admin/gemini-context-cache/stats Gemini context-cache statistics
POST /admin/gemini-context-cache/clear Clear Gemini context-cache state
GET, PUT /admin/smart-routing/model-profiles List or replace model profiles
GET /admin/smart-routing/model-profiles/{model} Read one model profile
GET /admin/smart-routing/status Smart-routing status
GET /admin/smart-routing/stats Smart-routing statistics
POST /admin/smart-routing/classify Classify a request for inspection
POST /admin/smart-routing/simulate Simulate routing
GET, PUT /admin/smart-routing/policies Read or replace policies
GET /admin/smart-routing/load-state Backend load state
GET /admin/smart-routing/cache/stats Classifier-cache statistics
POST /admin/smart-routing/cache/clear Clear the classifier cache
GET, PATCH /admin/guardrails Read or patch guardrail policy
PUT /admin/guardrails/providers/{name} Update a guardrail provider
PUT, DELETE /admin/guardrails/routes/{route} Set or remove a route override
POST /admin/guardrails/test Dry-run text through configured guardrails

Files, ACP, and control plane

Method Path Purpose
GET, POST /admin/files List or upload files; 404 when Files is disabled
GET, DELETE /admin/files/{file_id} Read metadata or delete a file
GET /admin/files/{file_id}/content Download file content
GET /admin/acp/status ACP service status
GET /admin/acp/sessions Active ACP sessions
GET /admin/acp/agent.json ACP agent descriptor
GET /admin/control-plane/status Control-plane agent status; route exists only in a control-plane build
GET /admin/control-plane/policy Hub policy the Router is executing, without key hashes or secrets; route exists only in a control-plane build
POST /admin/control-plane/enroll Privileged router-side Hub enrollment; route exists only in a control-plane build

Hub policy view

GET /admin/control-plane/policy reports the Hub policy the Router is executing, under the same Admin authentication as the other endpoints. It answers {"enabled": false} while the agent is disabled and {"enabled": true, "policy_sync_enabled": false} while control_plane.policy.enabled is off. Otherwise synced says whether an envelope has been applied, and a Router that never enrolled reports synced: false with empty lists.

Field Content
revision, issued_at_ms, last_sync_ms Cursor of the applied envelope, when the Hub issued it, and when this Router applied it
status The capabilities and the active and rejected revision identities the Router reports to the Hub; status.rejected names a refused revision with its error code
tiers Each tier's limits as the Hub stated them, with key_count and revoked_key_count
keys total, revoked, and unresolved_tier (keys whose tier the envelope does not define)
substitution_rules, equivalence_classes, routing_windows The effective sets: an envelope that omits a set keeps the previous one
optimization The org optimization policy; null before the first sync
model_budgets The model-budget unit in force, with tracked_keys, exhausted_keys, and unknown_keys per pool; null when the Router enforces none

Key hashes and provider credentials are never included; keys appear only as counts. The Router keeps enforcing the last applied policy while the Hub is unreachable, so read heartbeat from GET /admin/control-plane/status beside this view to tell a current policy from the last known one.

Router-initiated Hub enrollment

POST /admin/control-plane/enroll uses the same Admin authentication and audit middleware as every other Admin endpoint. Its JSON body accepts a required token, optional hub_url and router_name, and optional replace boolean:

{"token":"single-use-token","hub_url":"https://hub.example.com","router_name":"edge-a","replace":false}

The Router exchanges the token over HTTPS (loopback HTTP is accepted for development), persists only the Hub-issued tenant_id, router_id, and router_credential through the existing owner-only credential store, and returns state: "pending_restart". The enrollment token is never persisted, echoed, or logged and the WebUI clears it after every attempt. Restart the Router to activate the new identity.

A healthy persisted credential returns 409 ALREADY_ENROLLED unless replace: true is explicit. A credential that the running agent has observed as rejected is reported as rejected by GET /admin/control-plane/status and may be replaced without deleting the state file or setting replace. The endpoint serializes its exchange with startup enrollment so concurrent attempts cannot overwrite one another.

The control_plane section is startup-owned: it remains readable through GET /admin/config/control_plane, but is omitted from GET /admin/config/sections, and PUT/PATCH /admin/config/control_plane return 409 STARTUP_OWNED_SECTION with guidance. Continue using the source YAML/TOML and a restart for enabled, policy, cadence, state-file, and other agent settings. Existing config-file token enrollment remains supported.

Refused configuration mutations

A request that asked to change running state and was refused because of the value it submitted answers 400 Bad Request. A request that asked for a verdict rather than a change keeps 200, with the outcome in the body. The dividing line is the request, not the endpoint:

Request Refusal status
PUT/PATCH /admin/config/{section} 400
POST /admin/config/import with apply: true and dry_run: false 400
POST /admin/config/import with dry_run: true, or with apply: false 200, verdict in success and validation
POST /admin/config/apply with a config candidate and hot_reload: true 400
POST /admin/config/apply with no candidate, an unchanged candidate, or hot_reload: false 200, verdict in success and message
POST /admin/config/validate 200 for every parseable input, verdict in valid and errors

The response body is byte for byte what it was before, "success": false and the descriptive error/validation.errors included, so a client that already inspects the body keeps working unchanged. What changes is that automation keying on the status code, which is the default for curl -f and for most HTTP client libraries, no longer reads a refused configuration as an applied one.

Two refusal classes deliberately keep 200. "Hot reload not available" reports a missing server capability rather than a bad request, and the import size and nesting guards carry their own SIZE_LIMIT_EXCEEDED and NESTING_LIMIT_EXCEEDED codes.

Status codes

  • 200: request processed. A verdict endpoint reports its verdict here, so POST /admin/config/validate, a POST /admin/config/import dry run, and a POST /admin/config/apply preview can still carry "valid": false or "success": false in the body.
  • 400: malformed request, or a configuration mutation the router refused because of the submitted value. The body is the same JSON document these endpoints have always returned, "success": false included, so a client that reads the body needs no change.
  • 401: missing or invalid Bearer/Basic credentials.
  • 403: IP not allowed or insufficient authorization.
  • 404: unknown resource, feature-disabled runtime service, or feature-gated route absent.
  • 409: existing credential requires explicit replacement, router name conflict, or startup-owned runtime section.
  • 422: the Hub rejected the supplied enrollment token.
  • 413: upload exceeds the route body limit.
  • 502: the outbound Hub enrollment exchange failed.
  • 500: internal failure or an authentication method missing required runtime state.