Config API
Reference for the config.svc HTTP surface. For concepts (hives, sources, resolution, SSE) see the Config block docs; for config files see Configuration Files.
Canonical HTTP API for config.svc. Same surface for every caller — DevSecOps smoke-testing a fresh deploy, an application developer building against the config service, or a kis.ai platform service using the Go libraries (which call this exact API under the hood).
Base URL — http[s]://<host>:7123/. One port serves both subsystems; non-overlapping route prefixes.
Format — JSON in/out unless noted (SSE endpoints emit text/event-stream). Times are RFC 3339 UTC. Durations in JSON are nanosecond integers (Go time.Duration convention); duration fields configured in YAML accept 30s, 5m, etc.
Auth — every authenticated request needs one of:
| Method | Header / mechanism |
|---|---|
| API key | X-Api-Key: <key> |
| JWT | Authorization: Bearer <jwt> (validated against the configured JWKS URL) |
| mTLS | Presented client cert validated against the configured CA |
Unauthenticated paths (always public): /health, /ready, /version.
Conventions
Section titled “Conventions”Status codes
Section titled “Status codes”| Code | Meaning |
|---|---|
| 200 | OK. Response body present. |
| 201 | Created. Returned on initial PUT to a path that previously had no snapshot. |
| 204 | No Content. Returned on successful DELETE. |
| 304 | Not Modified. Returned when If-None-Match matches the current snapshot version. |
| 400 | Bad Request. Malformed JSON, malformed path, etc. The body has details. |
| 401 | Unauthenticated. No credentials, or credentials didn’t validate. |
| 403 | Forbidden. Authenticated but the policy denies this action on this path. |
| 404 | Not Found. Hive doesn’t exist, no snapshot at that path, etc. |
| 405 | Method Not Allowed. e.g. PUT under /_meta/. |
| 409 | Conflict. Optimistic concurrency: the If-Match header didn’t match the current version. |
| 422 | Unprocessable Entity. Body failed CUE schema validation. The body has the field-level errors. |
| 5xx | Server-side failure. Retry with backoff. |
Versioning & ETags
Section titled “Versioning & ETags”Every snapshot has a per-hive monotonic version (uint64). It doubles as the snapshot’s ETag for HTTP concurrency:
- Response headers include
ETag: "<version>"on every snapshot read. - Send
If-Match: "<version>"on PUT/PATCH/DELETE to refuse the write unless the current version matches (optimistic concurrency). - Send
If-None-Match: "<version>"on GET to short-circuit with 304 when unchanged.
Error body shape
Section titled “Error body shape”{ "code": "STRING_TAG", // e.g. "VALIDATION_FAILED", "POLICY_DENIED" "message": "human-readable summary", "details": { "field": "value", ... } // optional, structured}code is stable across releases; message is for humans; details varies per error kind.
Authentication and authorization
Section titled “Authentication and authorization”Three credentials are accepted. They are tried in order, first match wins:
| Order | Method | Presented as |
|---|---|---|
| 1 | API key | X-Api-Key: <key> |
| 2 | Bearer token | Authorization: Bearer <token>, validated against the configured JWKS |
| 3 | mTLS | A client certificate on the TLS connection |
A method that does not apply — no header, no client cert — falls through to the next. Any other failure rejects immediately rather than falling through, so a malformed API key is an error, not an invitation to try the bearer token.
GET /health, GET /ready and GET /version bypass authentication entirely. Every other route
requires a subject; a request that produces none is denied.
Authorization is per hive, not per route
Section titled “Authorization is per hive, not per route”Once authenticated, access is decided by an expression policy attached to the hive you are
addressing. The policy receives the subject, the action (read or write), the hive name and the
config path, and decides from those.
That is why there is no per-route permission table: GET /hives/cluster/a/b and
PUT /hives/tenant/x/y are the same two inputs — a hive and a path — evaluated by different rules.
Tenancy
Section titled “Tenancy”Config does not take the four CPET headers. Its partition is the hive: cluster for
cluster-wide configuration, tenant for per-tenant configuration, and whatever else is declared.
The tenant a request acts on is part of the path inside the tenant hive, and the policy engine sees
it as the path it authorizes.
GET /hives lists the hives this deployment declares, which is the place to start if you do not
know what you can address.
Health & readiness (unauthenticated)
Section titled “Health & readiness (unauthenticated)”GET /health — liveness probe
Section titled “GET /health — liveness probe”GET /health{ "status": "ok" }Returns 200 if the process is alive. Suitable for k8s liveness probes / LB up-checks. Does not check downstream dependencies.
GET /ready — readiness probe
Section titled “GET /ready — readiness probe”GET /ready{ "status": "ready" }Returns 200 once the reconcilers have completed their initial pass and the binary is ready to serve traffic. Returns 503 before that (during boot, or after a fatal subsystem failure). Suitable for k8s readiness probes / LB rotation.
GET /version — build identity
Section titled “GET /version — build identity”GET /version{ "name": "config", "version": "2.0.0", "commit": "a1b2c3d", "build_timestamp": "2026-05-20T18:00:00Z"}Config subsystem
Section titled “Config subsystem”GET /hives — list declared hives
Section titled “GET /hives — list declared hives”GET /hivesX-Api-Key: $KEY{ "hives": ["cluster", "tenant"] }GET /hives/<hive>/_meta/schema — CUE schema
Section titled “GET /hives/<hive>/_meta/schema — CUE schema”Returns the CUE source for a hive’s schema as text/plain. Useful for clients that want to validate writes locally before posting.
GET /hives/<hive>/_meta/example — example value
Section titled “GET /hives/<hive>/_meta/example — example value”{ "value": { ... }}Returns an example value document that conforms to the hive’s schema — useful as a starting point when authoring a new node’s config.
GET /hives/<hive>/_meta/tree — list paths
Section titled “GET /hives/<hive>/_meta/tree — list paths”GET /hives/tenant/_meta/tree?prefix=acme/forgeX-Api-Key: $KEY{ "hive": "tenant", "paths": [ { "path": ["acme","forge","prod","acme-prod","forge-meta"], "version": 14 }, { "path": ["acme","forge","prod","other-tenant","forge-meta"], "version": 9 } ]}Query params:
prefix— optional, restrict to paths under this prefix (slash-separated).
GET /hives/<hive>/<path> — resolved snapshot
Section titled “GET /hives/<hive>/<path> — resolved snapshot”The resolved, layered, validated snapshot at a (hive, path) tuple.
GET /hives/tenant/acme/forge/prod/acme-prod/forge-metaX-Api-Key: $KEY{ "hive": "tenant", "path": ["acme","forge","prod","acme-prod","forge-meta"], "value": { ... merged config ... }, "version": 14, "provenance": { "log_level": "git-main@a1b2c3d", "features.fast_path": "pg-overrides@rowid:42" }, "schema_version": "tenant-v1", "registry_version": 1, "resolved_at": "2026-05-21T13:00:00Z"}Response headers: ETag: "14".
Path is required. Reading the root snapshot uses GET /hives/<hive>/ (trailing slash); the value is what every leaf inherits.
PUT /hives/<hive>/<path> — replace value
Section titled “PUT /hives/<hive>/<path> — replace value”Replace the entire value document at a path. Routes to whichever source in the hive’s list is writable: true with the highest priority.
PUT /hives/tenant/acme/forge/prod/acme-prod/forge-metaX-Api-Key: $KEYContent-Type: application/jsonIf-Match: "14" # optional optimistic concurrency
{ "log_level": "debug", "features": { "fast_path": true } }Response: 200 OK or 201 Created:
{ "version": 15, "resolved_at": "2026-05-21T13:01:00Z"}If-Match: "<version>" rejects with 409 Conflict if another write raced in. Re-GET, re-merge, retry.
Body validation: the merged result is run through CUE; failures return 422 with field-level details.
PATCH /hives/<hive>/<path> — deep merge
Section titled “PATCH /hives/<hive>/<path> — deep merge”Identical contract to PUT, but the body is deep-merged into the existing value rather than replacing it. Maps merge key-by-key (nested maps recurse); arrays and scalars replace.
PATCH /hives/tenant/acme/forge/prod/acme-prod/forge-metaX-Api-Key: $KEYContent-Type: application/jsonIf-Match: "14"
{ "features": { "new_dashboard": true } }DELETE /hives/<hive>/<path> — remove value
Section titled “DELETE /hives/<hive>/<path> — remove value”DELETE /hives/tenant/acme/forge/prod/acme-prod/forge-metaX-Api-Key: $KEYIf-Match: "15"Response: 204 No Content. The leaf is removed from the writable source; consumers reading this path now inherit from the parent prefix (or get {} if the root is empty).
GET /sources — declared sources per hive
Section titled “GET /sources — declared sources per hive”{ "sources": { "cluster": [ { "name": "platform-cluster-defaults", "backend": "git", "owns": ["**"], "priority": 0, "writable": false }, { "name": "cluster-overrides", "backend": "postgres", "owns": ["**"], "priority": 20, "writable": true } ], "tenant": [ ... ] }}GET /subscribers — active SSE subscribers
Section titled “GET /subscribers — active SSE subscribers”{ "subscribers": [ { "subject": "user:alice", "remote_addr": "10.0.1.5:48732", "user_agent": "lib-chassis/2.0", "hives": ["tenant"], "connected_at":"2026-05-21T13:00:00Z", "last_event": 15 } ]}Config SSE stream
Section titled “Config SSE stream”GET /stream/config — subscribe to snapshot changes
Section titled “GET /stream/config — subscribe to snapshot changes”GET /stream/config?hives=tenantX-Api-Key: $KEYAccept: text/event-streamLast-Event-Id: 42 # optional resume cursorQuery params:
hives— comma-separated list of hives to subscribe to. Omit for every hive the subject is authorized for.
Server response is text/event-stream with the following event types:
:connected
event: snapshotid: 14data: {"hive":"tenant","path":["acme","forge","prod","acme-prod","forge-meta"],"value":{...},"version":14,"provenance":{...},"schema_version":"tenant-v1","registry_version":1,"resolved_at":"..."}
event: deleteid: 15data: {"hive":"tenant","path":["acme","forge","prod","acme-prod","forge-meta"]}
event: heartbeatid:data: {"ts":"2026-05-21T13:00:00Z"}
event: full_resyncid: 16data: {"reason":"server-rebuild"}| Event | When | Payload |
|---|---|---|
:connected (comment) | Right after the SSE handshake completes. Bare comment line, no payload. | |
snapshot | A new or updated snapshot for a subscribed hive. | Same shape as GET /hives/<hive>/<path>. |
delete | A snapshot at a path was removed. | {hive, path}. |
heartbeat | Every 20s. Keep-alive only — no id, no state change. | {ts} |
full_resync | Server is asking you to drop your cache and accept the burst of snapshot events that immediately follow. | {reason} |
Resume after disconnect: cache the most recent id: you saw; on reconnect, send Last-Event-Id: <that-id>. The server backfills every snapshot with version > <that-id> then continues live streaming. Cursor 0 (or absent header) requests the current full state.
POST /stream/config/drift — subscriber drift report
Section titled “POST /stream/config/drift — subscriber drift report”POST /stream/config/driftX-Api-Key: $KEYContent-Type: application/json
{ "current_versions": { "tenant": 38, "cluster": 12 }, "hashes": { "tenant:acme/forge/prod/acme-prod/forge-meta": "sha256:..." }, "lag_seconds": { "snap-44": 1.2 }}Returns 202 Accepted. The server compares each current_versions[hive] against its own latest emitted version and emits OTel metrics on mismatches. Optional — for production observability.
Discovery subsystem
Section titled “Discovery subsystem”POST /register — register an instance (or upsert)
Section titled “POST /register — register an instance (or upsert)”POST /registerX-Api-Key: $KEYContent-Type: application/json
{ "instance_id": "swiggy-edge-1", "service": "forge-meta", "dc": "us-west", "cluster": "prod-cluster-01", "addr": "10.0.1.5:7600", "ttl_secs": 15, "tags": { "sid": "swiggy-edge-1", "version": "2.0.0", "role": "primary" }}{ "instance_id": "swiggy-edge-1", "ttl_secs": 15, "expires_at": "2026-05-21T13:00:15Z"}instance_id is optional. Pass it (typically the chassis sid) when you have a stable identity; the server uses that value as the registration handle, and a subsequent register with the same value upserts (refreshes addr, tags, TTL). Omit it and the server generates an opaque value, returned in the response. Either way, the instance_id in the response is the canonical handle for follow-up heartbeat / deregister calls.
ttl_secs must be between 5 and 600.
PUT /heartbeat/<instance-id> — extend the TTL
Section titled “PUT /heartbeat/<instance-id> — extend the TTL”PUT /heartbeat/swiggy-edge-1X-Api-Key: $KEYReturns 204 No Content. Refreshes expires_at by the originally-registered TTL. 404 if the instance ID is no longer current (expired or never registered).
DELETE /deregister/<instance-id> — remove the registration
Section titled “DELETE /deregister/<instance-id> — remove the registration”DELETE /deregister/swiggy-edge-1X-Api-Key: $KEYReturns 204 No Content. The instance disappears from query results immediately; subscribers receive an instance_down event.
GET /services — list registered services
Section titled “GET /services — list registered services”{ "services": ["config", "forge-meta", "forge-cli"] }Returns names of services with at least one live instance.
GET /services/<service>/instances — list instances
Section titled “GET /services/<service>/instances — list instances”GET /services/forge-meta/instances?dc=us-westX-Api-Key: $KEYQuery params:
dc— optional, filter by datacenter (read fromtags.dc).cluster— optional, filter by cluster (read fromtags.cluster).tag— optional,key:valuetag filter (repeatable for AND).
{ "instances": [ { "instance": "swiggy-edge-1", "service": "forge-meta", "addr": "10.0.1.5:7600", "tags": { "sid":"swiggy-edge-1", "version":"2.0.0", "dc":"us-west", "cluster":"prod-cluster-01" }, "registered_at": "2026-05-21T13:00:00Z", "ttl": 15000000000, "expires_at": "2026-05-21T13:00:15Z" } ]}The "instance" field is the stable deployment identity — the same value the caller supplied as instance_id on register (or the opaque ID the server assigned). Use it directly for heartbeat / deregister; no separate “lease ID” to track.
Discovery SSE stream
Section titled “Discovery SSE stream”GET /stream/discovery — subscribe to lease changes
Section titled “GET /stream/discovery — subscribe to lease changes”GET /stream/discovery?services=forge-meta,forge-cliX-Api-Key: $KEYAccept: text/event-streamLast-Event-Id: 102Query params:
services— comma-separated list. Omit to subscribe to every service the subject is authorized to watch.
Event types:
:connected
event: instance_upid: 103data: {"service":"forge-meta","instance":{...same shape as instances endpoint...}}
event: instance_downid: 104data: {"service":"forge-meta","instance":"8f3a2b...","reason":"revoked"}
event: instance_expiredid: 105data: {"service":"forge-meta","instance":"8f3a2b...","reason":"ttl_expired"}
event: heartbeatid:data: {"ts":"2026-05-21T13:00:00Z"}| Event | When |
|---|---|
instance_up | A new lease was granted, or an existing lease’s instance details (addr, tags) changed. |
instance_down | Registration was explicitly revoked via DELETE /deregister/<instance-id>. |
instance_expired | TTL elapsed without a heartbeat. |
heartbeat | Every 20s. Keep-alive. |
Same Last-Event-Id resume semantics as /stream/config.
Headers reference
Section titled “Headers reference”| Header | Direction | Purpose |
|---|---|---|
X-Api-Key | Request | API-key authentication. |
Authorization: Bearer <jwt> | Request | JWT authentication. |
If-Match | Request | Optimistic concurrency on writes. |
If-None-Match | Request | Conditional GET; 304 when unchanged. |
Last-Event-Id | Request | SSE resume cursor. |
Accept | Request | application/json (default) or text/event-stream for SSE. |
ETag | Response | Snapshot version (also exposed as version in the body). |
Content-Type | Response | application/json, text/event-stream, or text/plain for /_meta/schema. |
X-Request-Id | Both | Propagated end-to-end for tracing. Server generates one if absent. |
Code samples (audience-neutral)
Section titled “Code samples (audience-neutral)”Read a tenant snapshot:
curl -s -H "X-Api-Key: $KEY" \ "$BASE/hives/tenant/acme/forge/prod/acme-prod/forge-meta" | jqWrite with optimistic concurrency:
VER=$(curl -s -H "X-Api-Key: $KEY" \ "$BASE/hives/tenant/acme/forge/prod/acme-prod/forge-meta" | jq -r .version)
curl -si -X PUT \ -H "X-Api-Key: $KEY" \ -H "Content-Type: application/json" \ -H "If-Match: \"$VER\"" \ -d '{"log_level":"debug"}' \ "$BASE/hives/tenant/acme/forge/prod/acme-prod/forge-meta"Subscribe to SSE:
curl -N -H "X-Api-Key: $KEY" "$BASE/stream/config?hives=tenant"Python (requests + sseclient-py)
Section titled “Python (requests + sseclient-py)”import json, requestsfrom sseclient import SSEClient
KEY = "..."BASE = "http://localhost:7123"HDR = {"X-Api-Key": KEY}
# Readr = requests.get(f"{BASE}/hives/tenant/acme/forge/prod/acme-prod/forge-meta", headers=HDR)r.raise_for_status()snap = r.json()print("version =", snap["version"])
# Write with If-Matchrequests.put( f"{BASE}/hives/tenant/acme/forge/prod/acme-prod/forge-meta", headers={**HDR, "If-Match": f'"{snap["version"]}"', "Content-Type": "application/json"}, json={"log_level": "debug"},).raise_for_status()
# Subscribefor ev in SSEClient(f"{BASE}/stream/config?hives=tenant", headers=HDR): if ev.event == "snapshot": snap = json.loads(ev.data) print(snap["path"], snap["version"])JavaScript / TypeScript (fetch + EventSource)
Section titled “JavaScript / TypeScript (fetch + EventSource)”const KEY = '...';const BASE = 'http://localhost:7123';const HDR = { 'X-Api-Key': KEY };
// Readconst snap = await fetch( `${BASE}/hives/tenant/acme/forge/prod/acme-prod/forge-meta`, { headers: HDR }).then(r => r.json());
// Write with If-Matchawait fetch(`${BASE}/hives/tenant/acme/forge/prod/acme-prod/forge-meta`, { method: 'PUT', headers: { ...HDR, 'If-Match': `"${snap.version}"`, 'Content-Type': 'application/json' }, body: JSON.stringify({ log_level: 'debug' }),});
// Subscribe — note: native EventSource doesn't support custom headers;// for the API key, either use a polyfill (eventsource-polyfill) or// authenticate via a cookie/query param (server-supported in some// deployments via a per-environment shim).const es = new EventSource(`${BASE}/stream/config?hives=tenant`);es.addEventListener('snapshot', e => { const snap = JSON.parse(e.data); console.log(snap.path, snap.version);});Go (stdlib net/http, no external libs)
Section titled “Go (stdlib net/http, no external libs)”package main
import ( "encoding/json" "fmt" "io" "net/http")
const ( base = "http://localhost:7123" key = "...")
type Snapshot struct { Hive string `json:"hive"` Path []string `json:"path"` Value map[string]any `json:"value"` Version uint64 `json:"version"`}
func main() { // Read req, _ := http.NewRequest("GET", base+"/hives/tenant/acme/forge/prod/acme-prod/forge-meta", nil) req.Header.Set("X-Api-Key", key) resp, _ := http.DefaultClient.Do(req) defer resp.Body.Close() var snap Snapshot _ = json.NewDecoder(resp.Body).Decode(&snap) fmt.Printf("v=%d val=%v\n", snap.Version, snap.Value)
// Subscribe (read SSE line-by-line) sseReq, _ := http.NewRequest("GET", base+"/stream/config?hives=tenant", nil) sseReq.Header.Set("X-Api-Key", key) sseReq.Header.Set("Accept", "text/event-stream") sse, _ := http.DefaultClient.Do(sseReq) defer sse.Body.Close() buf := make([]byte, 4096) for { n, err := sse.Body.Read(buf) if err == io.EOF || n == 0 { break } fmt.Print(string(buf[:n])) }}The stdlib path shown here is the portable one: it depends on nothing but an HTTP client, so it works from any language and any runtime.
Errors you’ll actually hit
Section titled “Errors you’ll actually hit”| Code | Status | When |
|---|---|---|
INVALID_PATH | 400 | The URL path doesn’t conform to the hive’s level shape. |
BAD_JSON | 400 | Request body wasn’t valid JSON. |
UNAUTHENTICATED | 401 | Missing / invalid credentials. |
POLICY_DENIED | 403 | Authenticated but the hive’s access policy refuses this subject + action. |
SNAPSHOT_NOT_FOUND | 404 | The path has no snapshot yet. |
HIVE_NOT_FOUND | 404 | The hive doesn’t exist in the embedded registry. |
INSTANCE_NOT_FOUND | 404 | The instance ID isn’t current (expired, revoked, or never registered). |
IF_MATCH_MISMATCH | 409 | Optimistic concurrency: another write raced. |
VALIDATION_FAILED | 422 | Body failed CUE schema. details lists field-level errors. |
INTERNAL_ERROR | 500 | Server-side failure. Retry with backoff; check server logs. |
- All snapshot reads and SSE streams are eventually consistent against the source of truth — for the strongest guarantee, write with
If-Match, then re-read withIf-None-Match: "<old-version>"until you see version bump. - Idle SSE connections receive a
heartbeatevery 20s. If you go 60s without any event (heartbeat or otherwise), reconnect. - Server enforces a per-subject SSE concurrency limit (default 16). Excess connections receive 429.
X-Request-Idis propagated end-to-end. Capture it for any support-ticket you file.