This is the wire contract between the Ferra server and any client (the
ferra-agent sidecar, an operator using curl, or a service that embeds its
own watch loop). It is intentionally small.
For most service consumers, you should not implement this protocol yourself
— run ferra-agent as a sidecar and read from its localhost
HTTP API. The implementation requirements in §8 are subtle; the agent
already gets them right.
No authentication. Every endpoint accepts any caller that can reach it over the network. The trust boundary is the network — see the project README for the security model.
GET /healthz # 200 ok always
GET /readyz # 200 ok / 503 if db is down
GET /v1/kv/{key} # get one key
PUT /v1/kv/{key} { "value": <json> } # set one key
DELETE /v1/kv/{key} # delete one key
GET /v1/kv?prefix=... # list (and snapshot) by prefix
GET /v1/events?since=N&prefix=...&limit=K # paginated event log
GET /v1/watch?prefix=...&since=N # SSE watch (long-lived)
No headers required. JSON request bodies use Content-Type: application/json.
- A key is a string of 1–1024 bytes, no NUL.
/is convention for directory-style organization. The server stores keys as-is; it does not parse path segments.- When a key appears in a URL path (
GET /v1/kv/{key}), percent-encode each segment but leave/as the segment separator. Example: a keyservices/payment/timeout msbecomes/v1/kv/services/payment/timeout%20ms. - When a key or prefix appears as a query parameter (
?prefix=...), percent-encode the whole value including/.
- Values are arbitrary JSON: numbers, strings, booleans, null, arrays,
objects. They are stored as
jsonbin Postgres. - Default max serialized size: 256 KiB (
FERRA_MAX_VALUE_BYTES). Servers may be configured lower; over-limit writes return413 payload_too_large. - A client SHOULD store values as opaque JSON in its cache and decode lazily
in typed getters. Re-encoding on every
getis wasted work.
All errors are JSON:
{ "error": "bad_request", "message": "key too long (max 1024 chars)" }| HTTP | error |
When |
|---|---|---|
| 400 | bad_request |
empty key, key > 1024 bytes, NUL in key, etc. |
| 404 | not_found |
GET/DELETE on a key that does not exist |
| 413 | payload_too_large |
value over FERRA_MAX_VALUE_BYTES |
| 500 | internal_error |
DB or serialization failure |
| 503 | (no body) | /readyz only, when DB is unreachable |
Treat 4xx as "stop, the request is malformed" and 5xx as "transient, retry with backoff."
GET /v1/kv/services/payment/timeout_ms{
"key": "services/payment/timeout_ms",
"value": 3000,
"event_id": 43,
"updated_at": "2026-04-29T12:00:00Z"
}PUT /v1/kv/services/payment/timeout_ms
Content-Type: application/json
{ "value": 3000 }{
"key": "services/payment/timeout_ms",
"event_id": 43,
"operation": "set"
}event_id is monotonically increasing across the whole instance — it comes
from the kv_events Postgres sequence.
DELETE /v1/kv/services/payment/timeout_ms{
"key": "services/payment/timeout_ms",
"event_id": 44,
"operation": "delete"
}Deleting a non-existent key returns 404, not 204.
GET /v1/kv?prefix=services/payment/{
"prefix": "services/payment/",
"latest_event_id": 43,
"items": [
{ "key": "services/payment/timeout_ms", "value": 3000, "event_id": 43 }
]
}This is the snapshot endpoint a client uses on startup and after a reload.
latest_event_idis global, not prefix-scoped. It isMAX(id) FROM kv_eventsacross the whole instance, not just events underprefix. This is intentional: clients use it as thesince=cursor for/v1/watch, and the watch must be ordered against the same global event log. Do not assumelatest_event_idcorresponds to a key initems.
An empty prefix matches all keys.
GET /v1/events?since=42&prefix=services/payment/&limit=1000{
"from_event_id": 42,
"to_event_id": 44,
"events": [
{ "event_id": 43, "key": "services/payment/timeout_ms", "operation": "set" },
{ "event_id": 44, "key": "services/payment/feature_x", "operation": "delete" }
]
}sinceis exclusive (you get events withevent_id > since).prefixis optional.limitdefaults to 1000, clamped to[1, 5000].to_event_idequals the last returned event's id, orsinceif the page is empty.
Events do not carry
value. They tell you that key K was set or deleted. To learn the new value of asetyou mustGET /v1/kv/{key}. This keeps the events table small and avoids storing every historical revision.
A client typically does not need this endpoint — /v1/watch covers both
catch-up and live updates. It exists for ad-hoc tooling and tests.
GET /v1/watch?prefix=services/payment/&since=42
Accept: text/event-streamThe server replies 200 OK with Content-Type: text/event-stream and then
streams events forever (until the client disconnects, the server is shut down,
or the subscriber lags too far behind the in-memory ring buffer).
Three event types are emitted:
Sent for every set / delete that matches prefix and has event_id > since.
event: kv_changed
id: 43
data: {"event_id":43,"key":"services/payment/timeout_ms","operation":"set"}
operation is "set" or "delete". As with /v1/events, the value is not
included; a client that needs the new value must GET /v1/kv/{key}.
Sent every FERRA_WATCH_HEARTBEAT_SECONDS (default 30s) so idle clients and
load balancers don't close the socket.
event: heartbeat
data: {}
A client SHOULD use heartbeats to detect a dead connection: if no bytes arrive for ~2× the heartbeat interval, treat it as a transport error and reconnect.
Sent when the server's in-memory event ring buffer has rotated past the
client's since, i.e. the client lagged so far it cannot be caught up
incrementally.
event: reload
data: {"reason":"lagged"}
After reload the server closes the stream. The client MUST drop its cache,
re-fetch a full snapshot via GET /v1/kv?prefix=..., and reconnect with
since=<snapshot.latest_event_id>.
A best-effort signal that catch-up failed. Treat it as a transport error: disconnect, back off, reconnect.
event: error
data: {"message":"catch-up failed"}
These apply to service-embedded clients (anything that maintains a
cache and a watch). One-shot curl users can ignore them.
If you write a client by hand or generate one, verify each item below. These are bugs we have seen in the wild.
-
Reads come from the in-memory cache, not from HTTP. This is the architectural premise. If your
getInt(key)does an HTTP call toferra-serverper request, you've defeated the purpose — every config read pays a network round-trip and every Ferra hiccup cascades into your service. -
Re-fetch on
set. Watch payloads carrykey+operationonly. Onset,GET /v1/kv/{key}and update your cache with the returnedvalue. Ondelete, just remove the key from your cache — no GET needed. -
Tolerate 404 on the follow-up GET. Between receiving a
setevent and issuing the GET, another writer may have deleted the key. A 404 is not an error; ignore it and let the next event catch you up. -
Use the global
latest_event_idas yoursincecursor. The snapshot'slatest_event_idis global, not prefix-scoped. Use it verbatim. Don't try to derive it from the items in the snapshot. -
Advance
sinceafter applying. Update your in-memorylatest_event_idonly after the cache mutation succeeds. If you advance it first and then crash mid-apply, your next reconnect will skip the event. -
Heartbeat-driven socket timeout. Set the read timeout on the watch connection to ~2× the server heartbeat interval (default 30s → use 60–90s). Without this you will silently die on idle disconnects from NATs and load balancers.
-
reloadmeans drop the cache. When you receivereload, do NOT keep your existing cache and continue. Issue a freshGET /v1/kv?prefix=..., replace the cache wholesale, setsinceto the newlatest_event_id, then reconnect. Failing this, you'll happily run on stale data. -
Reconnect with backoff. On any transport error (TCP reset, idle timeout, 5xx), reconnect with exponential backoff (e.g. 500ms → 30s). Reset the backoff to the floor on a successful reconnect (any byte received, including a heartbeat).
-
Last-known-good while disconnected. Reads against the cache MUST NOT block on or fail because the watch is disconnected. The whole point of the cache is that services keep working while Ferra is briefly unreachable. If your service's
getIntthrows or hangs because the watch loop is in reconnect backoff, you've defeated the purpose. -
Empty prefix means everything. A client with
prefix=""will receive a snapshot and watch covering every key in the instance. Usually you want to scope to a service. -
No SSE retry hint. Ferra does not emit
retry:lines. Do not parse them; if your SSE library auto-reconnects on aretry:value, that's fine, but don't depend on it. -
URL-encode the key path correctly.
/separates path segments, everything else is percent-encoded. A naiveURLEncoder.encode(key)that encodes/as%2Fwill breakGET /v1/kv/{key}. See §2.
GET /healthz — always returns 200 {"status":"ok"}. Use for liveness.
GET /readyz — 200 {"status":"ok"} when the database round-trips, else
503 {"status":"unavailable"}. Use for readiness probes.
The path prefix /v1/ is the version. Any breaking change to request or
response shape will live under a new prefix (/v2/). Additive fields on
existing endpoints are not breaking; clients MUST ignore unknown fields.