Companion to PROTOCOL.md. Requests and responses use application/cbor with deterministic CBOR (RFC 8949 §4.2.1) unless noted. Authentication is per-request via signed headers; no sessions, no cookies.
- Authentication header
- Endpoints —
/v1/users,/v1/sync,/health,/version. - Status codes
Translog endpoints (/v1/server-info, /v1/chains, /v1/sth/...,
/v1/proof/inclusion, /v1/proof/consistency) are documented in
TRANSLOG.md §5. Witness endpoints (/v1/...) are in
TRANSLOG.md §8.3.
Authorization: fd0-sig v1
pk=<base64(super_pub)>,
nonce=<base64(16 random bytes)>,
ts=<unix_seconds>,
sig=<base64(signature)>
signed_input = "fd0-http-request-v1" || cbor({
method : <uppercase HTTP method>,
path : <URL path, no query>,
query : { * tstr => tstr }, ; canonical: keys sorted lexicographically;
; multi-value keys forbidden in v1
ts : ts,
nonce : nonce,
body_sha : SHA-256(request body, or SHA-256("") if empty),
server_pub : <recipient server's translog pubkey>,
})
signature = Ed25519(super_priv, signed_input)
server_pub binds the signature to a specific server (T21) — clients use the pinned translog pubkey from /v1/server-info.
Server checks (in verifyHTTPSig, in order):
1. per-IP pre-auth rate limit → else 429
2. header well-formed (scheme, pk, nonce, ts, sig sizes) → else 401 bad scheme/pk/nonce/ts/sig
3. |now - ts| ≤ 300 s → else 401 stale_ts
4. read body; reject multi-value query keys → else 400
5. build signed_input; signature verifies → else 401 bad_sig
6. pk is a registered user → else 401 unregistered_pk
7. (pk, nonce) inserted into nonce table (ts stored) → else 401 replay
8. per-pubkey post-auth rate limit → else 429
Per-endpoint authorization runs after verifyHTTPSig. Examples: GET /v1/users/<shortId>/events requires pk == user_super_pub (else 403); /v1/sync push items return bad_author per item when author ≠ pk; /v1/sync pull of a non-member scope returns 200 with denied: true (not 403).
POST /v1/users is unauthenticated; the embedded event signature provides binding to the new identity. All other authenticated user-chain endpoints additionally require the signing pubkey to equal the chain's user_super_pub.
Request:
{ event : UserEvent } ; kind = "auth.set", seq = 0
201 Created:
{
shortId : tstr, ; server-assigned, 8 chars
event_id : tstr,
}
Errors:
400 bad_event schema, kind, or signature invariant (incl. signature does not verify)
409 super_pub_taken user_super_pub already registered
Authenticated. pk MUST equal the chain's user_super_pub.
Query modes:
?since=<seq>: events with seq ≥ since, ascending.?latest=true: only the latestauth.setevent.
200 OK (since mode):
{
user_super_pub : bstr .size 32,
events : [* UserEvent],
chain_tip_seq : uint,
chain_tip_hash : bstr .size 32,
}
200 OK (latest mode):
{
user_super_pub : bstr .size 32,
event : UserEvent,
chain_tip_seq : uint,
chain_tip_hash : bstr .size 32,
}
404 not_found
Authenticated. pk MUST equal the chain's user_super_pub.
Request:
{ event : UserEvent } ; kind = "auth.set", seq > 0
200 OK:
{ event_id : tstr, seq : uint }
Errors:
400 bad_event schema, kind, or signature invariant (e.g., empty active set,
signature does not verify)
409 divergence prev_hash does not match chain tip
(response includes current_tip_seq, current_tip_hash)
409 dup event_id already exists
Authenticated.
Request:
{
pull : {
scopes : { * tstr => { cursor: { seq: uint, hash: bstr .size 32 / nil } } },
limit_per_scope : uint, ; default 100, max 1000
discover_memberships : bool, ; optional, default false
membership_after : tstr, ; optional pagination cursor
membership_limit : uint, ; optional, default/max 256
},
push : [* { scope: tstr, event: ScopeEvent }],
}
200 OK:
{
pull : { * tstr => {
tip : { seq: uint, hash: bstr .size 32 },
oek_version_max : uint,
events : [* ScopeEvent], ; contiguous, ascending from cursor.seq + 1
} },
memberships : [* { ; present iff request.pull.discover_memberships
scope_id : tstr,
admit_event : tstr, ; event_id of the member.change op="add" of self
oek_version : uint,
}],
memberships_next_after : tstr, ; present when another page exists
push : [* PushResult],
}
PushResult =
{ accepted: true, event_id: tstr, seq: uint, scope_id: tstr } /
{ accepted: false, reason: tstr, ... }
pull returns events contiguous from cursor.seq + 1. Clients verify the chain link to their stored cursor.hash before advancing the cursor.
A sync request accepts at most 256 pull scopes and 64 push items. Pull
responses return at most 1024 events and 48 MiB of encoded event bytes in
aggregate; when several scopes are requested, the server lowers the effective
per-scope page size and serves lower cursors first. Clients repeat membership
discovery with memberships_next_after, bound pages per invocation, and persist
the continuation cursor per server so later syncs retain complete coverage.
Push rate accounting charges every submitted item, not only the enclosing
request.
Pagination-capable clients send a non-zero membership_limit (fd0 uses 32).
For compatibility, a request that omits the field still receives a complete
single page when the user has at most 256 memberships. If more rows exist, the
server returns 426 membership_pagination_required instead of silently hiding
later scopes; upgrading the client resumes discovery without changing stored
scope data.
Push cardinality invariant. For every /v1/sync request, len(response.push) MUST equal len(request.push) — exactly one PushResult per submitted push item, in order, on a 200 response (including push-only requests with an empty pull). If the server cannot process a push item it MUST return a PushResult with accepted: false and a reason, never omit it. Clients rely on this 1:1 mapping to disposition each pushed event; a count mismatch on a 200 is a protocol violation and clients reject it. Non-2xx responses (e.g. 429 rate-limit) carry no push array at all — clients MUST check the HTTP status before decoding rather than treating a missing array as zero results.
Push reasons (always with accepted: false):
| reason | meaning |
|---|---|
bad_sig |
event signature does not verify |
bad_author |
event author ≠ HTTP auth pk |
bad_kind |
unrecognised kind or generic schema invariant |
divergence |
prev_hash or seq mismatch |
stale_oek_version |
event's oek_version < server's current |
future_oek_version |
event's oek_version > server's current |
invalid_key_deliveries |
recipient set doesn't match post-mutation member set |
scope_mismatch |
signed_prefix.scope ≠ outer push frame scope |
not_found |
scope unknown |
out_of_range |
translog index/size invalid for this chain |
internal |
server-side error |
dup |
event_id already stored; idempotent — reply still carries event_id, seq, scope_id, STH, inclusion proof, and (if requested) consistency proof |
Scope creation has no dedicated endpoint: a scope is created by pushing a member.change with prev_hash=nil, op="add", member == author, and one KeyDelivery to the author. The server derives scope_id = "s_" + base32(truncate_128(SHA-256(event_id))) and assigns it in the PushResult.
Liveness probe. Unauthenticated, version-neutral.
200 OK
Content-Type: application/json
{
"status" : "ok",
"service" : "fd0-server",
"version" : "x.y.z"
}
200 OK
Content-Type: application/json
{
"service" : "fd0-server",
"server_version" : "x.y.z",
"api_version" : "v1"
}
Prometheus exposition. RED metrics (requests, errors, duration, in-flight, response bytes) plus the standard process + Go runtime collectors. Guarded by a bearer token when FD0_METRICS_TOKEN is set; otherwise serves openly.
200 OK
Content-Type: text/plain; version=0.0.4
# HELP fd0_http_requests_total HTTP requests processed, partitioned by service, operation and status class.
# TYPE fd0_http_requests_total counter
fd0_http_requests_total{service="fd0-server",op="POST /v1/sync",status_class="2xx"} 1247
...
Set Authorization: Bearer <token> when the env is configured. Unauthorised requests return 404 Not Found — the endpoint never confirms its own existence to anonymous scrapers.
Unauthenticated, bounded witness discovery.
GET /v1/chains?after=<chain_id>&limit=256
200 OK:
{
"chains": [* tstr],
"next_after": tstr ; absent on the final page
}
limit defaults to 1024 and is capped at 1024. Results are ordered by
chain_id. Witnesses continue with next_after; explicit configured chains
remain active independently of discovery pagination.
| Code | Meaning |
|---|---|
| 200 | OK |
| 201 | Created |
| 400 | Malformed body, invariant violation |
| 401 | Auth header invalid, missing, or replayed |
| 403 | Authenticated but not authorized (e.g. user-chain owner mismatch) |
| 404 | User or scope not found |
| 409 | Divergence, duplicate, or version conflict |
| 413 | Payload exceeds limit (STORAGE.md §9) |
| 426 | Client upgrade required for paginated membership discovery |
| 429 | Rate limit exceeded (STORAGE.md §9) |
| 500 | Server error |