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)
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
- 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)
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:
| 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
[]), 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-1000 Unicode scalar values (chars), not bytes
}
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)
POST /dialogs/{peer}/messages/control
Send an E2EE control message (handshake, key rotation, etc.).
Path params:
peer-- recipient's address (hex 0x-prefixed)
Request body:
{
"msg_type": 1, // 1-255 (0 is reserved for regular text)
"control": "pGplbmNyeXB0aW9u" // base64-encoded CBOR payload; max 1024 bytes (<= 1368 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)
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...",
"last_text_preview": "Hey, how are you?",
"unread": 3,
"cursor": "0x..."
}
],
"next_after": "0x..." // null if no more pages
}
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 onlydmandgroupunread=last_seq - last_read_seq(fromuser_read_progressCF)
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)
Response 200:
{
"items": [
{
"key": "0x...", // Hex-encoded RocksDB key (for pagination)
"msg_cbor": "0x..." // Hex-encoded CBOR MsgV1
}
],
"next_after": "0x..." // null if no more pages
}
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 - Messages are in chronological order (ascending HLC, which is monotonic across sends)
- 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": "admin cannot leave group"}
Notes:
- Admin cannot leave their own group -- must transfer ownership first
- 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-1000 Unicode scalar values (chars), not bytes
}
Response 200:
{
"chat_id": "0x...",
"msg_id": "0x...",
"ts": 1699900000000 // Originator wall-clock at send time
}
Notes:
- Group messages carry
ChatKind::Group { title: None }. The protocol does not currently set or propagate a group title/avatar (the Create op has no title field), so/conversationsreportstitle: nullfor groups -- group metadata management is not yet implemented - 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 an E2EE control message to a group (handshake, key rotation, etc.).
Path params:
chat_id-- group chat identifier (hex 0x-prefixed, 32 bytes)
Request body:
{
"msg_type": 1,
"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)
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)
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. Pure local read from members CF -- 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
}
]
}
Notes:
- Only current members can view the list
- Role values: 0 = participant, 1 = admin
- 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": 5,
"control": "0xabcdef...",
"recipients": ["0x1234...5678"]
}
],
"nonce": "0xabcdef1234567890abcdef1234567890"
}
Response 200:
{
"ops_processed": 1,
"messages_sent": 1
}
Authorization:
- ECDSA signature recovery per operation (API-level crypto check)
- For
createops:noncefield is required; API verifieschat_id == blake3(domain || signer || nonce) - Role-based check in MPSC handler (DB access):
create: signer becomes admin, no prior state neededadd: signer must be adminremove: signer must be admin OR signer == target for self-remove
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 - Each op independently verifiable via its own ECDSA signature
- No atomicity guarantee between ops and messages
opsarray must not be empty;messagesandnonceare optionalop_typevalues:"add","remove","create"rolevalues:0= participant (default),1= admin
Error responses for /groups/{chat_id}/ops:
| Status | Condition |
|---|---|
| 400 | Invalid op_type, missing required fields, nonce mismatch (Create) |
| 403 | Sender is not admin (for Add/Remove), sender is admin trying self-remove |
| 409 | Group already exists (duplicate Create -- list_members returns non-empty) |
| 422 | Signature verification failed |
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/
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
Error Responses
Standard error:
{
"error": "forbidden"
}
Validation error (400):
{
"error": "validation_error",
"fields": {
"text": {
"msg": "length must be between 1 and 1000",
"value": "",
"min": 1,
"max": 1000
}
}
}
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.