HTTP API
Overview
Framework: axum
Swagger UI: /swagger-ui
OpenAPI JSON: /api-docs/openapi.json
Default bind: http://localhost:3000 (configurable via listen_api in TOML config)
TCP backlog: 4096 (explicit, for high-RPS scenarios)
TCP_NODELAY: enabled (disables Nagle's algorithm for low-latency responses)
Keepalive idle timeout: 30 s (header_read_timeout -- closes idle connections cleanly)
Concurrency limit: 2400 in-flight requests (tower ConcurrencyLimitLayer, derived from Little's law for 100-node deployment at 600 K peak RPS); public discovery endpoints have a separate 256-slot limit so unauthenticated calls cannot occupy the main budget
HTTP server: manual accept loop with hyper_util::server::conn::auto::Builder (not axum::serve) to expose hyper's HTTP/1.1 keepalive tuning
Two-Layer Design
The API exposes both layers of the node architecture:
Layer 1 endpoints (node-enforced):
- Message sending/reading: POST/GET /dialogs/{peer}/messages, POST/GET /groups/{chat_id}/messages
- Single message by id: GET /messages/{msg_id}; edit/delete own message: PATCH/DELETE /messages/{msg_id}
- Inbox: GET /conversations
- Group administration: POST /groups/{chat_id}/ops, DELETE /groups/{chat_id}/membership
- Group members: GET /groups/{chat_id}/members
- Read progress: POST /dialogs/{peer}/messages/read, POST /groups/{chat_id}/messages/read
- Identity: PUT /identity, GET /identity/{address}
Layer 2 fields (client-opaque, passed through):
msg_type(u8) in message send body -- node stores as-iscontrol(base64 string -> Vec) in DM/group control endpoints -- opaque blob - Identity stored in dedicated
identityCF via PUT/GET /identity endpoints (see below)
Channel semantics (normative): POST .../messages (text) is the
only transport for user content -- literal text or a client-format
envelope (E2EE ciphertext in base64 is still a string).
POST .../messages/control (msg_type + control) carries protocol /
service structures only (attachments metadata, MLS handshake). Clients
MUST NOT introduce new control msg_types for user content. See
CLIENT_GUIDE.md for the
text-prefix convention that marks client formats inside text.
A client can use Layer 1 alone for plaintext messaging. Layer 2 enables any client-side protocol (E2EE, key exchange, etc.) via opaque fields. See ARCHITECTURE.md for the full two-layer description.
Authentication
All endpoints require ECDSA signature-based authentication via headers -- except the two public discovery endpoints (GET /node/info, GET /network/nodes), which are unauthenticated: clients call them during bootstrap before they have anything to sign, and infrastructure health checks cannot produce signatures.
| Header | Description |
|---|---|
X-User | Sender's Ethereum-style address (hex, 0x-prefixed, 20 bytes) |
X-Ts | Timestamp in milliseconds (must be within +/- 30 seconds of server time) |
X-Node | Base58-encoded PeerId of the target node |
X-Sig | ECDSA signature (65 bytes hex: r[32] || s[32] || v[1]) |
X-Sig-Version | Must be "p2p-mes-v1" |
Signature Verification
-
Build canonical string-to-sign:
p2p-mes-v1 METHOD:{METHOD} PATH:{path} QUERY:{canonical_query} BODY:{canonical_body} TS:{ts_ms} NODE:{node_id} -
Canonicalization rules:
- Query params: URL-decoded, sorted by key then value, percent-encoded
- JSON body: flattened to key=value pairs (nested objects use dot notation, arrays use
[]; an object inside an array is not flattened -- it becomes one pair holding its compact JSON with alphabetically sorted keys, see CRYPTO.md), sorted, percent-encoded - Form body: parsed, sorted, percent-encoded
- Binary body:
raw={hex}
-
Compute
msg_hash = Keccak256(string_to_sign) -
Recover public key from ECDSA signature (tries both recovery IDs v=0 and v=1)
-
Derive address:
Keccak256(pubkey_uncompressed[1..])[12..32] -
Compare derived address with
X-Userclaim
Verification Checks
X-Sig-Versionmust be"p2p-mes-v1"|now - X-Ts| <= 30 secondsX-Nodemust match this node's PeerId- Recovered address must match
X-User
Endpoints
POST /dialogs/{peer}/messages
Send a direct message.
Path params:
peer-- recipient's address (hex 0x-prefixed, 20 bytes)
Request body:
{
"text": "Hello, world!" // 1-45056 bytes of UTF-8 (44 KiB), byte-counted
}
Response 200:
{
"chat_id": "0x...", // 32 bytes hex
"msg_id": "0x...", // 32 bytes hex
"ts": 1699900000000 // Originator wall-clock at send time (= MsgV1.origin_wall_ts)
}
Notes:
chat_idis computed deterministically:BLAKE3("p2p-mes:chat:dm:v1:" || min(sender, peer) || max(sender, peer))- Message is published to gossip immediately, response returns before DB commit
msg_type = 0(regular text message)textis the only transport for user content, encrypted or not; the 44 KiB cap is sized so a 32 KiB payload fits after base64 (+33%) plus a client format prefix. The cap counts bytes, not chars
POST /dialogs/{peer}/messages/control
Send a control message carrying a protocol / service structure (attachments metadata, MLS handshake).
Path params:
peer-- recipient's address (hex 0x-prefixed)
Request body:
{
"msg_type": 10, // 1-255 (0 is reserved for regular text)
"control": "pGplbmNyeXB0aW9u" // base64-encoded CBOR payload; max 32 KiB (<= 43692 base64 chars)
}
Response 200:
{
"chat_id": "0x...",
"msg_id": "0x...",
"ts": 1699900000000 // Originator wall-clock at send time
}
Notes:
textis set to empty string for control messages- The
controlpayload is opaque to the node -- passed through as-is - msg_type is client-defined opaque u8 (node does not interpret it); known conventions: 10 = attachments, 20 = MLS Welcome, 21 = MLS Commit
- Control is not a transport for user content -- encrypted user
messages ride
texton the plain endpoint (see CLIENT_GUIDE.md)
GET /conversations
List user's chats (inbox) with unread count.
Query params:
limit-- max results (1-1000, default 50, capped at 500)after-- pagination cursor (hex-encoded raw key from previous response)
Response 200:
{
"items": [
{
"chat_id": "0x...",
"kind": { "type": "dm", "peer": "0x..." },
"last_ts": 1699900000000,
"last_sender": "0x...",
"unread": 3,
"cursor": "0x..."
}
],
"next_after": "0x..." // null when the page was not full (no continuation cursor)
}
Notes:
- Results sorted by most recent first (reverse timestamp in RocksDB key)
kindis one of:{"type": "dm", "peer": "0x..."},{"type": "group", "title": "..."},{"type": "channel", "title": "..."}.channelis a reserved type: there are no channel create/post/subscribe endpoints yet, so clients currently encounter onlydmandgroupkind.titlefor groups is the immutable creation title (see POST /groups/{chat_id}/ops);nullfor unnamed groups. The node hydrates it fromchats_metawhen writing the inbox entry, so it is present regardless of which node stored the triggering message- For
dmchats,kind.peerhere is viewer-relative: the node rewrites each user's inbox entry to hold the other participant, so the caller always sees their interlocutor. This is deliberately different from thekindembedded inmsg_cbor(wire messages), whered.peeris the original recipient fixed by the sender -- see the notes on GET /dialogs/{peer}/messages unread=last_seq - last_read_seq(fromuser_read_progressCF)- The response carries no content preview by design: chat-list lines are the client's job, rendered from the local message cache (an E2EE envelope is opaque to the node anyway; see CLIENT_GUIDE.md)
GET /dialogs/{peer}/messages
Get DM message history with a specific peer.
Path params:
peer-- peer's address (hex 0x-prefixed)
Query params:
from-- start timestamp in ms (default 0)to-- end timestamp in ms (optional, default unlimited)after-- pagination cursor (hex-encoded RocksDB key)limit-- max results (1-1000, default 100)reverse-- scan direction (defaultfalse).falsewalks oldest -> newest starting atfrom;truewalks newest -> oldest starting atto, i.e. "load the chat tail, then scroll up". Keep this flag stable across one pagination sequence --afteris the same opaque cursor in both modes, only the direction of travel flips
Response 200:
{
"items": [
{
"key": "0x...", // Hex-encoded RocksDB key (for pagination)
"msg_cbor": "0x..." // Hex-encoded CBOR MsgV1
}
],
"next_after": "0x..." // null when the page was not full (no continuation cursor)
}
Notes:
chat_idis derived from(user, peer)-- no membership check needed for DMs- Client must decode CBOR
msg_cborto get message fields (sender, text,hlc,origin_wall_ts, seq, msg_type, control, kind).origin_wall_tsis the frozen sender wall-clock for UI display;hlcis the network-consistent stamp used for storage ordering and sync. Two optional fields signal a client-side edit or delete:edited_at(packed HLC, present once the message has been edited) anddeleted(trueon a deleted stub, whosetextis empty) -- both absent on untouched messages; see PATCH/DELETE /messages/{msg_id} - Inside
msg_cbor,kind.tis a CBOR text string ("0"dm /"1"group /"2"channel, not an integer), and for DMskind.d.peeris the original recipient fixed by the sender -- in an incoming message it equals the reader's own address. Interlocutor =d.peer == me ? sender : d.peer. This differs from/conversations, whosekind.peeris viewer-relative. See the decoding reference in CLIENT_GUIDE.md next_aftercontract: the cursor is emitted only when the page hitlimit; a shorter or empty page returnsnext_after: null, which means "end of this node's current view", not "end of history". Writes are async (200before commit) and nodes converge via sync, so an empty page with a null cursor is ambiguous -- keep theafteryou used and retry later instead of concluding the history is exhausted. The cursor is the storage key of the page's last row, so any item'skeyis a validaftervalue (tail-polling can resume from the newest item received). A non-null cursor only means "the page was full" -- the next call may return an empty page- Forward (
reverse=false) returns messages oldest-first (ascending HLC, which is monotonic across sends); reverse (reverse=true) returns them newest-first, withnext_afterpointing at the oldest row of the page so the next call walks further back - Under full replication the queried node serves this from its local store and responds immediately -- there is no 30 s network wait (that path only applies to the disabled sharded mode). A node that has not finished syncing returns its partial local view, and there is no completeness signal, so treat history as eventually consistent
skip_membership_check = truefor DM routes
POST /dialogs/{peer}/messages/read
Mark messages as read up to a given sequence number.
Path params:
peer-- peer's address (hex 0x-prefixed)
Request body:
{
"seq": 123 // Sequence number (>= 1)
}
Response 200: empty body
Notes:
- Marks all messages with
seq <= given_seqas read - Broadcasts
ReadProgressvia gossip to update other nodes - Read progress is monotonic: only advances, never goes backward
DELETE /groups/{chat_id}/membership
Leave a group (self-remove).
Path params:
chat_id-- group chat identifier (hex 0x-prefixed, 32 bytes)
Request body:
{
"sig": "0xabcdef..." // ECDSA sig over keccak256(chat_id || sender || 1)
}
Response 200: empty body
Error 403: {"error": "owner cannot leave group"}
Notes:
- The group owner cannot leave -- they must transfer ownership first (
op_type: "transfer"viaPOST /groups/{chat_id}/ops). Admins and participants leave freely - Signature is required for gossip propagation verification
- Inbox entry for the chat is cleared so the group disappears from
/conversations - Publishes MembershipOp(Remove, target=self) via gossip for cross-node propagation; remote nodes receive the gossip and clear their local inbox copy for this user too
Group Messages
All group message endpoints require the caller to be a current member of
the group (verified via is_member check in the MPSC handler).
POST /groups/{chat_id}/messages
Send a text message to a group.
Path params:
chat_id-- group chat identifier (hex 0x-prefixed, 32 bytes)
Request body:
{
"text": "Hello group!" // 1-45056 bytes of UTF-8 (44 KiB), byte-counted
}
Response 200:
{
"chat_id": "0x...",
"msg_id": "0x...",
"ts": 1699900000000 // Originator wall-clock at send time
}
Notes:
textis the only transport for user content, encrypted or not (e.g. MLS application messages ride here with a client prefix); the 44 KiB cap counts bytes and fits a 32 KiB payload in base64 plus a prefix- The group title is fixed at creation (
titlein POST /groups/{chat_id}/ops, part of the chat_id derivation) and reported by/conversationsand/members; it does not travel with individual messages - Membership check happens in put_message MPSC handler
- Non-members receive error: "not a group member"
- Inbox upsert for all members happens locally in
process_db_opafter message storage
POST /groups/{chat_id}/messages/control
Send a control message carrying a protocol / service structure to a
group (attachments metadata, MLS Welcome/Commit). Not a transport for
user content -- encrypted group messages ride text on the plain
endpoint.
Path params:
chat_id-- group chat identifier (hex 0x-prefixed, 32 bytes)
Request body:
{
"msg_type": 20,
"control": "pGplbmNyeXB0aW9u" // base64-encoded CBOR; max 32 KiB (<= 43692 base64 chars)
}
Response 200:
{
"chat_id": "0x...",
"msg_id": "0x...",
"ts": 1699900000000 // Originator wall-clock at send time
}
GET /groups/{chat_id}/messages
Get group message history (paginated).
Path params:
chat_id-- group chat identifier (hex 0x-prefixed, 32 bytes)
Query params:
from-- start timestamp in ms (default 0)to-- end timestamp in ms (optional)after-- pagination cursor (hex-encoded RocksDB key)limit-- max results (1-1000, default 100)reverse-- scan direction (defaultfalse). Same semantics as the DM endpoint:false= oldest-first fromfrom,true= newest-first fromto(chat tail, then scroll up)
Response 200:
{
"items": [
{
"key": "0x...",
"msg_cbor": "0x..."
}
],
"next_after": "0x..."
}
Notes:
skip_membership_check = false-- explicit membership verification- Non-members receive empty result (not an error)
- Same
next_aftercontract andmsg_cbordecoding notes as GET /dialogs/{peer}/messages above
POST /groups/{chat_id}/messages/read
Mark group messages as read up to a given sequence number.
Path params:
chat_id-- group chat identifier (hex 0x-prefixed, 32 bytes)
Request body:
{
"seq": 123
}
Response 200: empty body
Notes:
skip_membership_check = false-- non-members receive error- Broadcasts
ReadProgressvia gossip
GET /groups/{chat_id}/members
List group members with roles plus the group's creation title. Pure local read from members CF + chats_meta -- no gossip roundtrip needed.
Path params:
chat_id-- group chat identifier (hex 0x-prefixed, 32 bytes)
Response 200:
{
"members": [
{
"address": "0x1234...",
"role": 1
},
{
"address": "0x5678...",
"role": 0
}
],
"title": "My Group Chat"
}
Notes:
- Only current members can view the list
- Role values: 0 = participant, 1 = admin, 2 = owner (exactly one per group)
titleis the group's immutable creation title;nullfor unnamed groups (and for groups whose creation meta has not reached this node yet)- Non-members receive error: "not a group member"
POST /groups/{chat_id}/ops
Compound membership operation: create group, add/remove members with optional accompanying messages (MLS Welcome/Commit). This is the only group management endpoint.
Path params:
chat_id-- group chat identifier (hex 0x-prefixed, 32 bytes)
Request body:
{
"ops": [
{
"target": "0x1234...5678",
"sig": "0xabcdef...",
"role": 0,
"op_type": "add"
}
],
"messages": [
{
"text": "",
"msg_type": 20,
"control": "pGplbmNyeXB0aW9u",
"recipients": ["0x1234...5678"]
}
],
"nonce": "0xabcdef1234567890abcdef1234567890",
"title": "My Group Chat"
}
Response 200:
{
"ops_processed": 1,
"messages_sent": 1
}
Authorization:
- ECDSA signature recovery per operation (API-level crypto check)
- For
createops:noncefield is required (exactly 16 bytes); API verifieschat_id == blake3(domain || signer || nonce || title)wheretitledefaults to the empty string when omitted - Role-based check in MPSC handler (DB access):
create: no prior state needed; signer becomes the owner (role=2)add: signer must be admin or owner. Assigningrole: 1(admin) or changing an existing member's role requires the owner; the owner's own role cannot be modified viaadd;role: 2is never assignable viaaddremove: signer must be admin/owner, OR signer == target for self-remove. The owner cannot be removed (by anyone, including self) -- transfer ownership firsttransfer: signer must be the owner;target(the new owner) must be an active member; self-transfer is rejected. Old owner becomes admin, target becomes ownerdelete: signer must be the owner. Deletes the whole group
Behavior:
- Membership ops processed first, then accompanying messages
- Inbox entries (including for creator on Create) are updated locally by
process_db_opafter accompanying message storage - Duplicate Create rejected if group already has members
- Batch ops published as one
MembershipOpBatchgossip message - Within a batch, later ops see the effect of earlier ones (virtual overlay), so
[create, add, add]or[create, transfer, remove(self)]work in a single request - After a
deleteop, any further op for the same chat in the same batch is rejected deletetombstones all members and clears every member's inbox entry; stored messages are untouched (retention GC removes them later)- Each op independently verifiable via its own ECDSA signature
- No atomicity guarantee between ops and messages
opsarray must not be empty;messages,nonceandtitleare optional (noncebecomes required when the batch contains acreateop)- Accompanying messages obey the same size caps as the dedicated message endpoints:
textmax 45056 bytes,controlmax 43692 base64 chars (32 KiB decoded);controlis base64, like on the control endpoints titlesets the group's name at creation. It participates in the chat_id derivation (blake3(domain || creator || nonce || title)), so the exact same string must be used when computing the chat_id client-side. Immutable after creation. Constraints: 1..=128 bytes UTF-8, no control characters, only valid alongside acreateop. Omit for an unnamed group- The stored title is returned in
GET /conversations(insidekind) andGET /groups/{chat_id}/members op_typevalues:"add","remove","create","transfer","delete"rolevalues:0= participant (default),1= admin (2= owner is set only bycreate/transfer, never accepted in the request)
Error responses for /groups/{chat_id}/ops:
| Status | Condition |
|---|---|
| 400 | Invalid op_type, missing required fields, bad nonce length, chat_id binding mismatch (Create), invalid title (empty / >128 bytes / control chars / no create op in batch), accompanying message over the size caps (text 45056 bytes / control 43692 base64 chars) |
| 403 | Insufficient role for the op (see Authorization above), target is the owner, transfer to non-member or self |
| 409 | Group already exists (duplicate Create -- list_members returns non-empty) |
| 422 | Signature verification failed |
GET /messages/{msg_id}
Fetch a single message by its msg_id. Enables deep-linking straight to
one message (e.g. from a push notification) without pulling a whole
range.
Path params:
msg_id-- message identifier (hex 0x-prefixed, 32 bytes)
Response 200:
{
"msg_id": "0x...", // Echoed back (hex, 32 bytes)
"msg_cbor": "0x..." // Hex-encoded CBOR MsgV1 (same shape as range items)
}
Notes:
- Pure local read -- under full replication every node holds all messages, so there is no gossip roundtrip
- Authorization is enforced node-side from the decoded message: for DMs the caller must be one of the two participants (
senderorpeer); for groups/channels the caller must be a current member - A
404is returned both when the id is unknown and when the caller may not see the message, so message existence is never leaked to non-participants - The
msg_cborpayload is byte-identical to a range item'smsg_cbor, so clients reuse their existing decode path - A message that was edited or deleted carries the optional
edited_at/deletedfields inside the decodedmsg_cbor(see the decode note under GET /dialogs/{peer}/messages) -- this is how a client detects the edit/delete state. Edits and deletes are issued via PATCH /messages/{msg_id} and DELETE /messages/{msg_id}
Error responses:
| Status | Condition |
|---|---|
| 400 | Malformed msg_id hex |
| 404 | Unknown id, or caller not authorized to read it |
PATCH /messages/{msg_id}
Edit one of your own messages. text is replaced in place -- the
message keeps the same msg_id and the same position/seq in history, so
nothing reorders and pagination cursors stay valid.
Path params:
msg_id-- message identifier (hex 0x-prefixed, 32 bytes)
Request body:
{
"text": "corrected text" // new content, 1-45056 bytes of UTF-8 (44 KiB), byte-counted (same cap as send endpoints)
}
Response 200:
{
"msg_id": "0x...", // 32 bytes hex, unchanged
"op_ts": 1699900000000 // Server-stamped wall-clock component of the operation HLC; surface as "edited at"
}
No operation signature:
- An edit carries no body
sig. Authorship at the node rests on the request-levelX-Sigauth headers -- the caller must be the message's original sender -- mirroring how a plain message send is authorized (also unsigned) - The new
textis content whose authenticity each recipient verifies end-to-end on Layer 2: the ciphertext is a sign-then-encrypt envelope, checked exactly like a plain message, so no separate operation signature is needed - Contrast with DELETE /messages/{msg_id}, which does carry an operation
sig: a delete leaves only an unauthenticatabledeleted: truestub, so the node must authorize it explicitly. Edits are unsigned, deletes are signed
Notes:
- Only the message's original author may edit it
- 48-hour edit window: the edit is accepted only within 48 h of the original message, measured from HLC stamps so all nodes agree on the cutoff
- Deletion is terminal -- once a message is deleted it can no longer be edited
- On success a packed-HLC
edited_atfield appears on the message and surfaces in the decodedmsg_cboron GET endpoints (see GET /messages/{msg_id}) - The edit propagates to online nodes via gossip and to offline nodes via anti-entropy sync; duplicate delivery is idempotent
- Edits do not reorder the chat or change unread counts (the inbox is left untouched)
Error responses:
| Status | Condition |
|---|---|
| 400 | Malformed msg_id hex, or text outside 1..=45056 bytes |
| 401 | Missing or invalid auth headers |
| 403 | Not the author, edit window expired, or message already deleted |
| 404 | Unknown msg_id |
| 500 | Internal error |
DELETE /messages/{msg_id}
Delete one of your own messages. The message becomes a stub: it stays
in history at the same msg_id and position, but its text is cleared
and a deleted: true flag is set, so pagination stays stable.
Path params:
msg_id-- message identifier (hex 0x-prefixed, 32 bytes)
Request body:
{
"sig": "0x..." // 65-byte ECDSA operation signature (see below), hex
}
Response 200:
{
"msg_id": "0x...", // 32 bytes hex
"op_ts": 1699900000000 // Server-stamped wall-clock component of the operation HLC
}
Operation signature:
- Unlike an edit, a delete requires a body
sig-- an operation signature separate from the auth headers.X-Sigauthenticates the HTTP request;sigauthorizes the delete, must recover to the original message's sender address, and every node re-verifies it on receipt - The client signs
keccak256(canonical_payload)wherecanonical_payload = chat_id[32] || target_msg_id[32] || op_kind[1] || blake3(new_text)[32](97 bytes).op_kind = 1for delete, and since there is no new textblake3("")(BLAKE3 of the empty string) fills the last 32 bytes - Standard Ethereum ECDSA recovery (
r[32] || s[32] || v[1],vin{27,28}or{0,1}) chat_idis not in the URL -- the node looks it up from the stored message, but the client must feed it into the payload (computed for DMs, path param for groups)- The HLC / timestamp is not signed -- the server stamps it, mirroring membership ops
Why a delete is signed but an edit is not:
- A delete erases content, leaving only a
deleted: truestub. No recipient can authenticate that stub end-to-end -- they cannot tell "the author deleted this" from "a node erased it without permission." So the node itself must authorize the delete against this signature, and every node re-verifies it - An edit writes new content instead, whose authenticity the recipient checks end-to-end on Layer 2 (a sign-then-encrypt envelope, like any plain message), so it needs only the request-level
X-Sigauth and no operationsig. Hence the asymmetry: edits are unsigned, deletes are signed
Notes:
- Only the message's original author may delete it, and the same 48-hour window (measured from HLC stamps) applies
- Deletion is terminal -- a deleted message cannot be revived or re-edited
- The stub keeps the same
msg_idand stays in GET responses, so pagination stays stable; clients should render "message deleted" and evict any cached text. Thedeleted: trueflag surfaces in the decodedmsg_cbor(see GET /messages/{msg_id}) - Propagates to online nodes via gossip and to offline nodes via anti-entropy sync; duplicate delivery is idempotent
- Deletes do not reorder the chat or change unread counts (the inbox is left untouched)
Error responses:
| Status | Condition |
|---|---|
| 400 | Malformed msg_id / sig hex |
| 401 | Missing or invalid auth headers |
| 403 | Not the author, edit window expired, or bad operation signature |
| 404 | Unknown msg_id |
| 500 | Internal error |
PUT /identity
Store caller's opaque identity blob. Overwrites any previous value by HLC last-write-wins (no client-visible versioning). The identity is propagated network-wide: the write is gossiped to online peers and reconciled across all nodes by Merkle-tree anti-entropy sync (domain Identity). See PROTOCOL.md (PutIdentity) and SYNC.md.
Request body:
{
"identity": "SGVsbG8gV29ybGQ=" // base64-encoded blob, max 1024 bytes raw
}
Response 200:
{}
Error responses:
| Status | Condition |
|---|---|
| 400 | Invalid base64, blob exceeds 1024 bytes |
| 401 | Missing or invalid ECDSA signature |
Notes:
- The blob is opaque to the node -- it does not parse or validate the contents
- Key in RocksDB
identityCF = caller's 20-byte address - Routed through
DbOp::PutIdentity(async DB writer: HLC last-write-wins, sync-index update, Merkle notify), then published as aPutIdentitygossip message
GET /identity/{address}
Retrieve a previously stored identity blob by user address. Any authenticated user can read any identity.
Path params:
address-- target user address (hex 0x-prefixed, 20 bytes)
Response 200:
{
"identity": "SGVsbG8gV29ybGQ=" // base64-encoded blob
}
Response 404: No identity stored for this address.
Notes:
- Direct synchronous read from
identityCF - No membership or ownership check -- any authenticated caller can query any address
Discovery Endpoints (no authentication)
Two public endpoints let clients discover the network without hardcoding node URLs. Neither requires signature headers. See Building a Client for the bootstrap/failover flow.
GET /node/info
Returns the answering node's identity.
Response 200:
{
"peer_id": "16Uiu2HAm4TfDHnyCsHwyCTDDbAV3g4Cc32DhSXoPzhbfLGN6HpQP",
"api_url": "https://node1.example.com:8080"
}
Notes:
peer_id-- this node's libp2p PeerId (base58). Clients need it for theX-Nodeheader when signing requests to this nodeapi_url-- the node's advertised API base URL (public_api_urlin its config),nullif the node does not advertise one- Served directly from in-process state; no node roundtrip
GET /network/nodes
Returns the catalog of live API-serving nodes known to the answering node,
including itself when it advertises a public_api_url.
Response 200:
{
"nodes": [
{
"peer_id": "16Uiu2HAm4TfDHnyCsHwyCTDDbAV3g4Cc32DhSXoPzhbfLGN6HpQP",
"api_url": "https://node1.example.com:8080"
},
{
"peer_id": "16Uiu2HAmKQKBgQD3g4Cc32DhSXoPzhbfLGN6HpQP4TfDHnyCsHwy",
"api_url": "https://node2.example.com:8080"
}
]
}
Notes:
- Entries come from the in-memory API registry populated by libp2p identify announcements of currently connected peers (see PROTOCOL.md, "Identify-Based API Discovery") -- every listed endpoint was live at response time
- Nodes that do not set
public_api_url(relay-only or private nodes) never appear - The list is a point-in-time snapshot of the answering node's direct connections, not a global network census; querying any node yields enough live endpoints for failover
- Order is not significant
Error Responses
Two body shapes exist; clients must handle both.
Flat error (any status): returned for everything except declared field
validation -- authorization failures, not-found, malformed path parameters
(bad hex, wrong id length), malformed cursors, and semantic input errors
(unknown op_type, nonce required for create op, identity blob exceeds 1024 bytes). No fields map:
{
"error": "forbidden"
}
Validation error (400 only): returned when a declared size/range rule
on a body or query field fails (text 1-45056 bytes, limit 1-1000,
seq >= 1, control payload size, ...). error is always the literal
"validation_error" and fields names each offending field:
{
"error": "validation_error",
"fields": {
"text": {
"msg": "text must be 1..=45056 bytes of UTF-8 (44 KiB)",
"value": ""
}
}
}
Dispatch rule: fields is present if and only if error equals
"validation_error". A 400 alone does not imply a fields map.
Internal Architecture
HTTP Request
|
v
HTTP Metrics Middleware (optional, if expose_metrics=true)
|
v
CORS Layer (allow all origins/methods/headers)
|
v
Signature Middleware (auth.rs)
|
v
Route Handler
|
v
Command::* --> MPSC channel (4096 buffer) --> Event Loop
|
v
oneshot channel <-- Response from handler
|
v
HTTP Response
Each handler creates a Command variant, sends it via MPSC, and waits on a oneshot channel for the response.