Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Building a Client

This guide walks through everything needed to build an interoperable p2p-mes client: how to authenticate, how to derive chat identifiers, the message lifecycle, and how to layer end-to-end encryption on top. For the exact schema of every endpoint, see the API Overview and the generated API Reference; for the design rationale behind these choices, see Design Philosophy.

A client never talks to the peer-to-peer network directly. It speaks HTTP to a single node, which signs nothing on the client's behalf except the network clock (see the trust note below). Everything else -- authorship, chat membership, encryption -- is the client's responsibility.

Prerequisites

To talk to a node you need three things:

  • An ECDSA secp256k1 keypair. The user's identity (their address) is derived from the public key exactly as in Ethereum.
  • The node's HTTP API base URL (for example http://localhost:3000).
  • The node's PeerId (Base58). Every request is bound to a specific node, so the client must know which node it is addressing.

Only the first is a real secret you must provision. The other two are discoverable at runtime: GET /node/info returns any node's PeerId, and GET /network/nodes returns the URLs of other live nodes (next section).

Node discovery and failover

Do not hardcode a single node URL. Nodes are full replicas that can be added or retired at any time, so a client should treat any one URL only as an entry point and learn the rest of the network from it.

Two endpoints support this, and neither requires signed headers -- they work before you have signed anything:

  • GET /node/info -- the node's own peer_id (needed for the X-Node header of every signed request) and its advertised api_url.
  • GET /network/nodes -- a catalog of live API-serving nodes: { "nodes": [ { "peer_id": ..., "api_url": ... }, ... ] }. The answering node includes itself. Every listed node was connected to the answering node at response time, so the entries are live.

The recommended bootstrap flow:

  1. Ship the client with one or more seed URLs (any known nodes).
  2. On startup, call GET /network/nodes on a seed and cache the returned catalog locally.
  3. Pick a node, fetch its peer_id via GET /node/info (or from the catalog entry), and use that value as X-Node in signed requests.
  4. If the node stops responding, switch to another cached entry and refresh the catalog from it.

Two caveats. First, the catalog is the answering node's view of its direct connections, not a global census -- different nodes may return slightly different (overlapping) lists; any of them is enough for failover. Second, remember that every signed request embeds the target node's PeerId: after switching nodes, sign subsequent requests with the new node's peer_id in X-Node.

Identity and addresses

A user is identified by a 20-byte address derived from their public key:

address = keccak256(uncompressed_pubkey[1..])[12..32]   // last 20 bytes

This is identical to Ethereum address derivation. Addresses appear in the API as 0x-prefixed hex (42 characters). See Cryptography & Authentication for the full derivation.

Authentication: signing every request

Every endpoint requires a signature, except the two discovery endpoints (GET /node/info, GET /network/nodes) described above. The node verifies it by recovering the signer's address from the signature and comparing it to the X-User header -- so there is no session, token, or password. Each request is signed independently.

Required headers

HeaderValue
X-UserSigner's address, 0x-prefixed hex (20 bytes)
X-TsUnix timestamp in milliseconds; must be within +/- 30 s of node
X-NodeBase58 PeerId of the node being addressed (must match that node)
X-Sig65-byte signature as hex: r[32] || s[32] || v[1] (130 hex chars)
X-Sig-VersionProtocol tag; currently p2p-mes-v1 (the default if omitted)

What you sign

You do not sign the raw request bytes. You sign a canonical string built from the request, so the signature is stable regardless of JSON key order or whitespace. The string is:

p2p-mes-v1
METHOD:{UPPERCASE_METHOD}
PATH:{path}
QUERY:{canonical_query}
BODY:{canonical_body}
TS:{timestamp_ms}
NODE:{node_peer_id_base58}

canonical_query and canonical_body are produced the same way:

  1. Reduce the input to a list of (key, value) pairs.
    • Query string: parse as URL-encoded pairs.
    • JSON body: flatten to dot notation -- {"a":{"b":1}} becomes a.b=1; arrays use a [] suffix -- {"t":[1,2]} becomes t[]=1, t[]=2. An array element that is itself an object is not flattened into dotted keys: the whole element becomes the pair's value, serialized as compact JSON with object keys sorted alphabetically at every depth -- {"ops":[{"b":1,"a":"x"}]} becomes the single pair ops[]={"a":"x","b":1} (see Signing group operations).
    • Empty body or query: the result is the empty string.
  2. Sort the pairs by key, then value.
  3. Percent-encode every key and every value with the NON_ALPHANUMERIC set -- that is, everything except A-Z a-z 0-9 is escaped, including ., -, _, and ~. The = and & joiners are not escaped.
  4. Join as key1=value1&key2=value2.

The full normative rules (including form bodies and binary payloads) are in Cryptography & Authentication.

How you sign

  1. Build the canonical string above.
  2. msg_hash = keccak256(utf8_bytes(canonical_string)).
  3. Produce a recoverable ECDSA signature over msg_hash.
  4. Serialize as r[32] || s[32] || v[1] and hex-encode into X-Sig. The recovery byte v may be 0/1 or the Ethereum-style 27/28; both are accepted.

Worked example

Sending {"text":"Hello, world!"} as a DM. The canonical body is text=Hello%2C%20world%21 (comma, space, and ! are escaped). With no query string, the string to sign is:

p2p-mes-v1
METHOD:POST
PATH:/dialogs/0xabcdef1234567890abcdef1234567890abcdef12/messages
QUERY:
BODY:text=Hello%2C%20world%21
TS:1700000000000
NODE:12D3KooWExampleNodePeerId

Hash it with Keccak-256, sign, and send the signature in X-Sig.

Common pitfalls

  • Signing raw bytes instead of the canonical string. Re-serialize through the canonicalization rules; do not hash the JSON you happened to send.
  • Wrong timestamp unit. X-Ts is milliseconds, and the node rejects anything more than 30 seconds from its own clock.
  • Wrong or missing X-Node. The node rejects requests addressed to a different PeerId.
  • Aggressive percent-encoding. NON_ALPHANUMERIC escapes far more than a typical URL encoder; verify against the worked example.
  • Dot-flattening objects inside arrays. {"ops":[{...}]} does not produce ops[].op_type=... pairs. Each element becomes one ops[] pair whose value is the element's compact JSON with alphabetically sorted keys. Getting this wrong rejects every POST /groups/{chat_id}/ops with 401 while flat-bodied requests keep working.

Reference test vectors

Signing is the easiest thing to get subtly wrong, so the site ships machine-readable vectors at test-vectors.json. Each entry pairs a request with its exact canonical_string, the Keccak-256 message_hash_keccak256, the resulting x_sig, and the full headers to send. They are produced from a fixed test key (0x1111...1111, address 0x19e7e376e7c213b7e7e7e46cc70a5dd086daff2a) using the same code the node verifies with, and a test re-checks every one.

To validate your client, replay a vector: rebuild the canonical string from its request, confirm it matches byte-for-byte, then hash, sign, and verify your signature recovers to the signer address. The POST /dialogs/{peer}/messages vector is the worked example above with its signature filled in. The POST /groups/{chat_id}/ops vector exercises an array-of-objects body -- replay it first if group requests return 401 while DMs work. Its per-op sig values are real signatures by the same test key, and its chat_id derives from the test address, the body's nonce and the body's title, so every layer of a group request can be checked against it.

Deriving chat identifiers

Chat IDs are 32 bytes and are computed by the client, not assigned by the server.

Direct messages -- derived from the two participant addresses, order- independent, so both parties compute the same value with no coordination:

chat_id = blake3("p2p-mes:chat:dm:v1:" || min(a,b) || max(a,b))

min/max are taken over the raw 20-byte addresses. No membership is stored for DMs: the ability to compute the ID is the access control.

Groups -- derived from the creator's address, a random 16-byte nonce the client generates at creation time, and the group title:

chat_id = blake3("p2p-mes:chat:group:v2:" || admin_address || nonce || title_utf8)
  • nonce must be exactly 16 bytes.
  • title is the group's name as raw UTF-8 bytes, no Unicode normalization -- hash the exact string you will send in the create request's title field. For an unnamed group, omit title from the request and hash zero title bytes.
  • Both nonce and title are sent in the create operation, and every node re-verifies the derivation (the HTTP API rejects a mismatch with 400; gossip receivers drop forged ops).

Because your signature covers the chat_id and the chat_id commits to the title, the title is authenticated end-to-end -- and therefore immutable: renaming a group is not possible without creating a new one. The title comes back to you in GET /conversations (inside kind) and GET /groups/{chat_id}/members. See Cryptography & Authentication.

The message lifecycle

Sending is fire-and-forget. When you POST a message, the node validates and signs nothing further, publishes it to the gossip network, queues it for storage, and returns 200 before the write is durable. A success response means "accepted and broadcast by this node," not "durably replicated everywhere." Convergence across nodes happens asynchronously through anti-entropy sync.

Reading is served from the queried node's local store and returns immediately -- under full replication every node stores everything, so there is no network round-trip on read. A node that is still catching up simply returns its partial local view. Treat reads as eventually consistent; see Operational notes for client authors at the end of this guide for handling incompleteness, ordering, retries, and node selection.

Sending a direct message

POST /dialogs/0xPEER.../messages
X-User: 0xSENDER...
X-Ts: 1699900000000
X-Node: 12D3KooW...
X-Sig: 0x<130 hex chars>
Content-Type: application/json

{ "text": "Hello, world!" }

Response:

{
  "chat_id": "0x<32-byte hex>",
  "msg_id":  "0x<32-byte hex>",
  "ts": 1699900000000
}

Group messages are the same against POST /groups/{chat_id}/messages; the node rejects the send if the sender is not a member.

Reading history (and decoding messages)

GET /dialogs/{peer}/messages (and the group equivalent) accept from/to (millisecond bounds), limit (1-1000), a reverse flag, and an opaque after cursor. The response is a page of messages plus a next_after cursor:

{
  "items": [
    { "key": "0x<hex key>", "msg_cbor": "0x<hex-encoded CBOR>" }
  ],
  "next_after": "0x<opaque cursor>"
}

Things to note:

  • Message bodies are returned as hex-encoded CBOR (msg_cbor). The client must hex-decode, then CBOR-decode, to obtain the message fields (sender, timestamp, text, msg_type, control). The message structure is documented in Gossip Protocol and Types.
  • Cursors are opaque. Pass next_after back as after to fetch the next page; do not parse or construct cursors yourself. One identity is guaranteed: the cursor is the storage key of the page's last row, so every item's key is itself a valid after value -- that is what makes tail-polling possible after a cursorless page (next bullet).
  • next_after comes back only on full pages -- and null does not mean "end of history". The node emits a cursor only when the page hit limit; a shorter or empty page carries next_after: null, meaning "end of this node's current local view":
    • items non-empty, next_after: null -- normal exhaustion: you have everything this node currently holds in the requested window. To keep following the tail, remember the key of the newest item and later re-poll forward (reverse omitted or false) with it as after -- or re-send the after you just used and dedup by key/msg_id.
    • items empty, next_after: null -- ambiguous. The window may be genuinely empty, or the node may not have the data yet: sends are fire-and-forget (200 returns before the write commits, so a message you just sent can be invisible for a few hundred milliseconds) and a node behind on sync serves its partial view. Never treat this page as proof of exhaustion: keep the cursor you used and retry when you have reason to expect data (you just got a 200, or /conversations shows unread > 0).
    • A non-null next_after promises nothing either: it means "this page was full", not "more rows exist" -- the next call may legitimately return an empty page.
  • Direction is controlled by reverse. Omit it (or send false) for oldest-first paging from from. Send true for newest-first paging from to -- the natural chat UI: load the latest screen with ?reverse=true&limit=N, then keep scrolling up by feeding next_after back as after (still with reverse=true). Keep the flag stable across one pagination run; the after cursor is the same opaque key in both modes, only the direction of travel flips.

Deep-linking to one message: GET /messages/{msg_id}

To jump straight to a single message -- e.g. opening a chat from a push notification -- call GET /messages/{msg_id} instead of scanning a range. The response carries the same hex-encoded CBOR as a range item, so you reuse the decode path above:

{ "msg_id": "0x<32-byte hex>", "msg_cbor": "0x<hex-encoded CBOR>" }

The node authorizes you against the message: DM participants for DMs, current members for groups. If the id is unknown or you are not allowed to see it, you get 404 -- existence is never leaked to non-participants. From the decoded hlc/seq you can then page around the message with the range endpoint.

Reference: decoding msg_cbor

Each msg_cbor is the hex of a CBOR map. Decode in two steps: hex -> bytes, then CBOR -> fields. The keys and value types:

KeyCBOR typeMeaning
schemauintwire schema version (currently 1)
msg_idarray of 32 uintsmessage id
chat_idarray of 32 uintschat id
senderarray of 20 uintssender address
hlcuint (u64)packed HLC (physical_ms << 16 | logical)
origin_wall_tsuint (u64)sender wall-clock ms, for display
sequintper-chat sequence number
texttext stringmessage text ("" for pure control messages)
msg_typeuintclient-defined; 0 = regular text
controlarray of uintsoptional (omitted when absent): opaque Layer-2 payload
kindmap{"t": "0"|"1"|"2", "d": {...}} -- DM / Group / Channel (see TYPES.md); two gotchas below
edited_atuint (u64)optional (present only if edited): packed HLC of the last edit -- show an "edited" marker
deletedbooloptional (present only if deleted): true marks a deleted stub, and text is then ""

Gotcha: byte fields are CBOR arrays, not byte strings. msg_id, chat_id, sender (and control, when present) encode as CBOR arrays (major type 4) of u8 integers -- not CBOR byte strings (major type 2), because the wire format uses no serde_bytes annotation. A decoder that expects byte strings will fail to parse.

Gotcha: the kind tag is a text string. kind.t is the CBOR text string "0" (DM), "1" (group), or "2" (channel) -- not an integer. This falls out of serde's adjacently-tagged enum encoding and stays this way for compatibility with already-stored messages, so match on strings, not numbers.

Gotcha: kind.d.peer is not "the other side". For DMs, d.peer is the address of the original recipient, fixed by the sender at send time -- the same bytes for every reader. In a message you received, d.peer is therefore your own address. To find your interlocutor:

other = (d.peer == my_address) ? sender : d.peer

Do not confuse this with /conversations, where kind.peer is viewer-relative (the node rewrites each user's inbox entry to hold the other participant). A client that builds its chat list from wire messages using d.peer alone files every incoming DM into a "dialog with itself" -- and read markers posted to that derived peer target a chat that does not exist.

Worked example. A DM with text = "Hello, world!", msg_type = 0, and no control payload encodes as the following msg_cbor (also checked by cargo test -p db, test msg_cbor_reference_vector, so it cannot drift):

aa66736368656d6101666d73675f69649820111111111111111111111111111111111111111111111111111111111111111167636861745f69649820182218221822182218221822182218221822182218221822182218221822182218221822182218221822182218221822182218221822182218221822182218226673656e646572941833183318331833183318331833183318331833183318331833183318331833183318331833183363686c631b018bcfe5680000006e6f726967696e5f77616c6c5f74731b0000018bcfe56800637365710164746578746d48656c6c6f2c20776f726c6421686d73675f7479706500646b696e64a2617461306164a164706565729418441844184418441844184418441844184418441844184418441844184418441844184418441844

It decodes to:

  • schema = 1
  • msg_id = 0x1111...11 (32 bytes)
  • chat_id = 0x2222...22 (32 bytes)
  • sender = 0x3333...33 (20 bytes)
  • hlc = 111411200000000000 (physical 1700000000000 ms, logical 0: 1700000000000 << 16)
  • origin_wall_ts = 1700000000000
  • seq = 1
  • text = "Hello, world!"
  • msg_type = 0
  • kind = { "t": "0", "d": { "peer": 0x4444...44 } } -- a DM; note the string tag, and that 0x4444...44 is the recipient the sender addressed (sender is 0x3333...33), per the d.peer gotcha above

Reference decoder (Rust; any CBOR library works -- the keys are plain strings):

#![allow(unused)]
fn main() {
use serde::Deserialize;

#[derive(Deserialize)]
struct Message {
    schema: u8,
    msg_id: [u8; 32],
    chat_id: [u8; 32],
    sender: [u8; 20],
    hlc: u64,            // packed: (physical_ms << 16) | logical
    origin_wall_ts: u64,
    seq: u32,
    text: String,
    #[serde(default)]
    msg_type: u8,
    #[serde(default)]
    control: Option<Vec<u8>>,
    #[serde(default)]
    edited_at: Option<u64>,  // packed HLC; Some only if the message was edited
    #[serde(default)]
    deleted: bool,           // true only if deleted (then `text` is "")
    // `kind` omitted here; unknown CBOR keys are skipped by default.
}

let bytes = hex::decode(msg_cbor.trim_start_matches("0x"))?;
let msg: Message = serde_cbor::from_slice(&bytes)?;
}

Read progress and unread counts

Mark progress with POST /dialogs/{peer}/messages/read carrying the highest sequence number you have read:

{ "seq": 123 }

Success is a 200 with an empty body (the group variant behaves the same) -- do not expect JSON there.

Unread counts are not stored: GET /conversations derives each chat's unread by comparing its latest sequence against your stored read progress.

Editing and deleting messages

You can edit or delete your own messages after sending. Two endpoints do this, both signed with the standard auth headers like any other request:

  • PATCH /messages/{msg_id} -- replace the text. Body: { "text": "<new text>" }.
  • DELETE /messages/{msg_id} -- clear the text and tombstone the message. Body: { "sig": "0x<hex>" }.

Both return the affected id and the node's server-stamped operation time:

{ "msg_id": "0x<32-byte hex>", "op_ts": 1699900000000 }

op_ts is milliseconds and is assigned by the node -- you never send it.

Why delete is signed but edit is not

Edit and delete are authorized differently, and getting this asymmetry right is the whole trick:

  • An edit writes new content. Its authenticity is verified end to end by the recipient on Layer 2 -- the sign-then-encrypt envelope -- exactly like a normal message. So an edit needs no operation signature: the PATCH body is just { "text": "<new text>" }, and authorship at the node rests on the normal request signature (X-Sig), which must recover to the message's original sender. Editing is just like sending.
  • A delete produces the absence of content. The recipient is left with a deleted: true stub and has nothing to re-authenticate on Layer 2 -- it cannot cryptographically tell "the author deleted this" from "a node erased it without permission." So the node itself must authorize the delete, against a dedicated operation signature that every node re-verifies as the tombstone propagates over gossip.

The rule in one line: sign an operation only when it destroys content the recipient can no longer re-authenticate. A delete does, so sign it; an edit -- like a normal send -- does not, because Layer 2 covers the new bytes.

The delete operation signature

A DELETE therefore carries two distinct signatures, and they are easy to confuse:

  1. The request signature in X-Sig, over the canonical string as for any request (the body's sig field is just another key that gets canonicalized).
  2. An operation signature in the body's sig field, which authorizes the delete itself. Every node re-verifies it as the tombstone propagates over gossip, so it -- not the X-User header -- is what proves you authored the message.

The operation signature is taken over a canonical 97-byte payload:

payload = chat_id[32] || target_msg_id[32] || op_kind[1] || blake3(new_text)[32]
  • chat_id -- the chat the message lives in. For a DM you derive it yourself (see Deriving chat identifiers); for a group it is the group chat_id.
  • target_msg_id -- the 32-byte id of the message being deleted (the {msg_id} in the path).
  • op_kind -- one byte; the payload's operation tag, 1 for a delete.
  • blake3(new_text) -- 32-byte BLAKE3 hash of the replacement text. A delete has no replacement text, so new_text is the empty string "" and this is BLAKE3 of zero bytes.

Then sign exactly as a group op does: hash the payload with keccak256, produce a recoverable secp256k1 signature, serialize it r[32] || s[32] || v[1] (65 bytes), hex-encode with a 0x prefix, and put it in the body sig field. Do not include any timestamp in the payload -- the node stamps op_ts server-side, so the signed bytes stay stable.

The node accepts the delete only if the signature recovers to the original message's sender -- that is the author-only rule, enforced cryptographically rather than by trusting a header.

Worked example -- deleting message 0x1111...11 in chat 0x2222...22:

[ 0..32]  chat_id        0x2222...22
[32..64]  target_msg_id  0x1111...11
[64..65]  op_kind        0x01                 (delete)
[65..97]  blake3(text)   blake3("")           (BLAKE3 of the empty string, 32 bytes)

sig = 0x || secp256k1_recoverable( keccak256(payload) )   (130 hex chars)

The request body then carries only this sig.

Rules

  • Author only. You can change only your own messages. On a delete this is the operation signature recovering to the original sender; on an edit it is the request signature (X-Sig) doing the same. Either way, you cannot touch anyone else's message.
  • 48-hour window. An edit or delete is accepted only within 48 hours of the original message's send time. After that the message is frozen.
  • Delete is terminal and network-wide. The text is cleared on every node, and a later edit of a deleted message is refused. There is no un-delete.

Rendering edited and deleted messages

Neither operation changes the message's msg_id or its position -- the row is rewritten in place, keeping the same id and seq. Detect the new state from the decoded msg_cbor (see decoding msg_cbor):

  • Edited -- the optional edited_at field is now present (the packed HLC of the edit). text holds the new value. Show an "edited" marker and replace any cached copy of the text.
  • Deleted -- the optional deleted field is true and text is "". The message stays in history as a stub with the same id. Render it as "message deleted" and drop any cached plaintext for that id -- including decrypted Layer 2 content.

Both fields are absent on an untouched message, so a decoder that ignores unknown keys keeps working on messages that were never changed.

Keeping caches fresh

An edit or delete rewrites the message in place without bumping the chat's last_ts or its unread count: the conversation is not reordered, and /conversations gives no signal that anything changed. A client that already holds a page in memory therefore will not notice the change on its own. For v1:

  • Re-fetch the visible message window when you poll (you already poll for new messages) and reconcile each row by msg_id; or
  • Re-fetch a single message with GET /messages/{msg_id} to pull its latest state -- for example before acting on a cached copy.

There is no "changes since" endpoint today; one may be added later, but until then re-reading is the mechanism.

Encrypted chats

Edit and delete are content-agnostic: the node treats text as opaque bytes. To edit an E2EE message, encrypt the new plaintext and send the fresh ciphertext envelope as text -- the flow is identical to a plaintext edit, and the new envelope is authenticated end to end on Layer 2 exactly like the original message. Delete behaves the same regardless of what was stored; its operation signature covers blake3(""), independent of the ciphertext it clears.

Groups

Group membership is managed through a single compound endpoint, POST /groups/{chat_id}/ops, which bundles one or more membership operations and optional accompanying messages (for example, encryption handshake data) in one call:

{
  "ops": [
    { "op_type": "create", "target": "0xADMIN...", "role": 2, "sig": "0x..." },
    { "op_type": "add",    "target": "0xMEMBER...", "role": 0, "sig": "0x..." }
  ],
  "messages": [],
  "nonce": "0x<16-byte hex>",
  "title": "My Group Chat"
}

nonce is required whenever the batch contains a create op. title names the group at creation: it is the same string you hashed into the chat_id (see Deriving chat identifiers), 1-128 UTF-8 bytes, no control characters, immutable afterwards; omit it for an unnamed group. List members with GET /groups/{chat_id}/members (returns the member list plus the group title; role is 0 = participant, 1 = admin, 2 = owner), and leave with DELETE /groups/{chat_id}/membership. The title also appears in every /conversations entry for the group, so a client never needs to store it separately.

For end-to-end-encrypted groups, note that the title is public network metadata (it is gossiped and stored in plaintext on every node). Privacy- sensitive clients should leave groups unnamed at the node level and carry the display name inside their encrypted Layer 2 payloads instead.

Roles

Creating a group makes you its owner (role 2) -- there is exactly one per group. Owners do everything; admins (role 1) may add participants and remove non-owners; participants only chat. Assigning or changing roles is owner-only, and role 2 can never be requested via add -- it moves only through transfer:

  • { "op_type": "transfer", "target": "0xNEWOWNER..." } -- signed by the current owner; the target must already be a member. You become an admin, the target becomes the owner. This is the escape hatch for the owner, who cannot leave the group directly: transfer first, then leave (both ops can ride in one batch).
  • { "op_type": "delete", "target": "0xSELF..." } -- signed by the owner; dissolves the group for everyone. The chat vanishes from every member's /conversations; old messages age out via retention rather than being wiped immediately.

The second signature

Group operations require two distinct signatures, and confusing them is the most common group-related bug:

  1. The request signature in X-Sig, over the canonical string (as for any request).
  2. A per-operation signature inside each op's sig field, over the raw binary message chat_id[32] || target[20] || op_type[1], hashed with Keccak-256. The op_type byte is 0 for add, 1 for remove, 2 for create, 3 for transfer, 4 for delete -- even though the JSON field spells it "add"/"remove"/"create"/"transfer"/"delete". For delete the target has no protocol meaning; sign your own address.

The per-op signature lets every node independently verify who authorized each membership change as it propagates over gossip. Leaving a group (DELETE .../membership) carries the same kind of per-op signature over chat_id || sender || 1 (a self-remove).

Signing group operations

POST /groups/{chat_id}/ops is the one endpoint whose body holds an array of objects, and its canonical form is where clients most often go wrong. Each element of ops (and of messages, when present) is not flattened into dotted keys. Instead it becomes a single pair: the key is ops[], the value is the element serialized as compact JSON -- no whitespace, object keys sorted alphabetically at every depth. The create+add request above therefore reduces to exactly four pairs:

nonce=0x<16-byte hex>
ops[]={"op_type":"add","role":0,"sig":"0x...","target":"0xMEMBER..."}
ops[]={"op_type":"create","role":2,"sig":"0x...","target":"0xADMIN..."}
title=My Group Chat

Then the usual steps apply: sort the pairs by (key, value) -- note this orders the ops[] pairs by their JSON strings, so here add comes before create regardless of their positions in the request -- percent-encode every key and value, and join with &. Two consequences worth spelling out:

  • A plain JSON.stringify of the op object is not enough: it preserves insertion order, so sort the keys of every object recursively before serializing.
  • The serialized values must match the sent body byte-for-byte: same hex casing, same 0x prefixes, integers without a decimal point.

The test vectors include a full group-ops request with the exact canonical string, so an implementation of this rule can be checked byte-for-byte before touching a live node.

Identity blobs

A user may publish one opaque identity blob (for example, a public-key bundle) with PUT /identity, and anyone may fetch it with GET /identity/{address}. The blob is base64 in JSON and capped at 1024 bytes; the node stores it last-write-wins and never inspects it.

{ "identity": "SGVsbG8gV29ybGQ=" }

Layer 2: end-to-end encryption

The node is a transport: it never reads message contents for meaning. Two channels exist, with a normative split of duties:

  • POST .../messages (text) is the only transport for user content -- literal text or an encrypted envelope (ciphertext in base64 is still a string). Max 45056 bytes of UTF-8 (44 KiB), sized so any payload that fits the control cap also fits here once base64-encoded and prefixed. How a client marks its envelope formats inside text is a client-layer convention -- see text prefixes.
  • POST .../messages/control (msg_type + control) carries protocol / service structures only -- attachment metadata, the MLS handshake. control is base64 in JSON (note: addresses, IDs, and signatures are hex, but control and identity blobs are base64), max 43692 base64 chars = 32 KiB decoded; msg_type (u8) says which structure it is. Clients MUST NOT claim new control types for user content.

A typical E2EE client performs its handshake over control messages, then sends ciphertext through text as ordinary messages, encrypting on the client and decrypting after the CBOR decode on read. Because the node cannot interpret any of this, two clients can agree on any scheme without node support.

Three Layer 2 conventions are documented in this guide and are normative for interoperability: the identity key bundle (key distribution), DM encryption (p2pmes:dm-e2ee-v1: envelopes in text), and attachments (control msg_type = 10). Group E2EE runs (D)MLS on top of the same identity bundle (control 20/21 for the handshake, p2pmes:mls-app-v1: in text for application messages). The sections below define the wire formats and vectors; for the implementation walkthrough -- key lifecycle, MLS-on-p2p-mes recipes, state recovery, pitfalls -- follow the E2EE Cookbook.

Text prefixes: identifying client formats

The node enforces exactly one thing about text: its size. Whether the string is literal text or an envelope of some client format is decided entirely by the client layer, and the recommended way to mark a format is a short prefix (slug) in front of the base64 payload:

<slug>:<base64 payload>          e.g.  p2pmes:dm-e2ee-v1:pGpl...

A prefix is how a client identifies formats it can handle: a known slug means "decode accordingly" (decrypt the envelope, render the card); an unknown one means a protocol-compatible but format-foreign client. The protocol does not regulate the slug namespace and nodes never inspect it -- but each profile in this guide pins its own prefix (that is what makes the profile interoperable across clients), and anything outside these profiles is free space.

Slugs used by the profiles in this guide and the demo client:

PrefixPayload after the prefix
(none)literal message text
p2pmes:dm-e2ee-v1:base64(CBOR dm-e2ee-v1 envelope)
p2pmes:mls-app-v1:base64(TLS-encoded MLSMessage, mls_private_message)
p2pmes:text-v1:base64(UTF-8) -- escape hatch for literal text that itself starts with p2pmes:

Recommended client rules:

  • a known prefix -> decode accordingly;
  • an unknown p2pmes:* prefix -> render as "unsupported message" (forward compatibility), never as raw base64;
  • literal user text that starts with p2pmes: -> wrap it in p2pmes:text-v1: before sending, so it cannot be mistaken for an envelope;
  • previews are the client's job: build conversation-list lines from your local message cache (decrypting first where needed) and never surface a raw envelope string in the UI.

Identity key bundle (idbundle-v2)

Every encryption feature below needs one thing from the network: a way to learn a peer's public encryption key given only their address. The identity blob (PUT /identity) is the distribution channel, but the transport does not authenticate blob contents -- the blob is opaque to nodes, and the PutIdentity gossip message carries no client signature, so any node can rewrite anyone's blob network-wide. The bundle fixes this end to end: it is self-signed by the account key, so a reader verifies it against the one thing it already knows authentically -- the peer's address.

The bundle also solves custody. The Ed25519 identity seed is derived deterministically from the account key, so a client holds exactly one long-term secret; identity, DM encryption, MLS keys, and any future derived key all recover from it.

Key derivation

seed(gen) = keccak256( sign65( account_key,
                keccak256("p2p-mes:id-seed:v1" || u32be(gen)) ) )
  • sign65 is the recoverable ECDSA signature r[32] || s[32] || v[1] with v normalized to 0/1 (subtract 27 from Ethereum-style 27/28).
  • Signing MUST be deterministic (RFC 6979, the default in secp256k1 libraries). A signer that randomizes k would derive a different seed every time and break recovery.
  • gen is a rotation counter starting at 0. Rotating the identity key = bump gen, re-derive, republish the bundle. After device loss, recover by reading your own published bundle (it carries gen) or by trying gen = 0, 1, 2... until the derived key matches.

From the 32-byte seed, everything else is deterministic:

KeyDerivationUsed for
Ed25519 keypairseed is the Ed25519 private keybundle's ed_pub, message signatures, MLS signature key
X25519 staticpub: toMontgomery(ed_pub); priv: clamped SHA512(seed)[0..32]DM encryption wraps, MLS initKey
MLS leaf keyX25519(SHA256(seed || "mls-enc-key"))MLS leaf encryption_key (RFC 9420 requires it to differ from initKey)
state backupHKDF-SHA256(seed, info = "p2p-mes:state-backup:v1")reserved: encrypting local-state snapshots (e.g. MLS state) stored as remote blobs

The Ed25519 -> X25519 conversion is the standard RFC 7748 birational map: u = (1 + y) / (1 - y) mod p on the public side (p = 2^255 - 19, y from the Ed25519 point encoding with the sign bit cleared).

Custody trade-off, stated plainly: deriving everything from the account key means one secret to protect and full recovery from it -- and it means compromise of the account key exposes recorded E2EE traffic, not just future impersonation. This is a deliberate choice for recoverability; the account key already controls the bundle (an attacker holding it can rotate your keys anyway), and message retention bounds how much recorded ciphertext exists.

Bundle format

IdentityBundleV2 = {
  "v":      2,
  "gen":    uint,              ; seed rotation counter
  "ts":     uint,              ; unix ms at publication (anti-replay)
  "ed_pub": bstr .size 32,     ; Ed25519 public key
  ? "kp":   bstr,              ; last-resort MLS KeyPackage (TLS-serialized)
  "sig":    bstr .size 65      ; recoverable secp256k1 signature, v in {0,1}
}

preimage = "p2p-mes:idbundle:v2" || u32be(gen) || u64be(ts)
           || ed_pub || (kp | empty)
sig      = sign65(account_key, keccak256(preimage))

Publish it base64-encoded via PUT /identity. Without kp the bundle is 133 bytes; with a typical KeyPackage (~400 bytes) it stays well inside the 1024-byte blob limit.

kp is a last-resort KeyPackage for (D)MLS (MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519, signed inside by ed_pub): it lets others add you to groups while you are offline, and because its initKey is the deterministic X25519 above, a pending Welcome stays decryptable from the seed alone even after total state loss. The cost is a reusable init key -- seed compromise exposes recorded Welcomes. That trade is inherent to the deterministic design and is accepted here.

Verifying a bundle (reader side)

  1. Fetch GET /identity/{address}. A 32-byte blob is a legacy raw Ed25519 key: unauthenticated -- treat it as trust-on-first-use and refuse E2EE features that need stronger guarantees.
  2. CBOR-decode; require v == 2.
  3. Rebuild preimage from the fields, keccak256 it, ecrecover the signature, and require the recovered address to equal the address you fetched. A node cannot forge this -- it does not hold the account key.
  4. Pin (gen, ts) per peer and require monotonic growth: a bundle older than the pinned one is a replay (a malicious node serving a rotated-away key) -- keep the pinned one and warn.

What this buys: a malicious node can no longer substitute keys (the MITM on E2EE bootstrap is closed); it can only serve a stale bundle (caught by pinning), serve nothing (a visible availability failure), or delete the blob. The full authentication chain becomes: address -> secp256k1 bundle signature -> ed_pub -> (KeyPackage signature -> MLS keys) / (message signatures).

Reference test vector

Account keys are the docs' fixed test keys; never use them outside tests.

Alice account key = 0x11 * 32
  address     = 19e7e376e7c213b7e7e7e46cc70a5dd086daff2a
  seed_sig    = 43b00ef256fa15181de74cb6b9f6668853a21296176b62dde763fc73f6faabbe1437fdc1ce513eb3d6998704329914bcf655c5c16b4542a61198e7f7d7a1713101
  seed        = 6cccccc86d8b15e7ea7fc0a817235593f6631018d0e28e810677184dcee01dde
  ed25519_pub = 2d41d2645fed8100c458f8442301dd92e504d1e16bf258272d7fa9b8738bd45d
  x25519_pub  = fb1a6a847fbee0fb7f4119c602a8d0e6a9aa8e2cd785f0c580de3ec5b2e6f705

Bob account key = 0x22 * 32
  address     = 1563915e194d8cfba1943570603f7606a3115508
  seed_sig    = 631a18bd87f0af380c34fba2b451a31739c3d8e7ca7645986f239b963e21258f03f9b53e21845dfc56d16a28baa75c18038597100a8555a315b1555e42e4292a01
  seed        = 12846d92d13846ec688705784824ca4a4ebfe1b3d339efb872e27e27b096b51a
  ed25519_pub = 73c163c6727022f1d51334199bb2405bc85197e4eb00430de51cfcb3e8302fbe
  x25519_pub  = 0251e47e353278612e450513eee3667a2c8bbf2c3b80470fe5f5ae496b2dd540

Alice's bundle with gen = 0, ts = 1700000000000, kp omitted:

preimage    = 7032702d6d65733a696462756e646c653a7632000000000000018bcfe568002d41d2645fed8100c458f8442301dd92e504d1e16bf258272d7fa9b8738bd45d
keccak256   = 7e12d9e103fa16043608ce20fb211bf5b80b64334219c8149ab1dc3d24a2f6b1
sig         = f48b94afee153bf7eb729b822c298faa832b5f1c3d99def204be65927b0f1f706e1813c1dafb497656f93257b1181c3d21058e14a5512aa05d0e715fb70097df01
bundle CBOR = a56176026367656e006274731b0000018bcfe568006665645f70756258202d41d2645fed8100c458f8442301dd92e504d1e16bf258272d7fa9b8738bd45d637369675841f48b94afee153bf7eb729b822c298faa832b5f1c3d99def204be65927b0f1f706e1813c1dafb497656f93257b1181c3d21058e14a5512aa05d0e715fb70097df01

Direct message encryption (dm-e2ee-v1)

Stateless end-to-end encryption for direct messages, rooted in the identity bundle. Design goals, in order: nothing to lose (no session state, no ratchet -- every message is independently decryptable by the two static identities), full recovery from the account key alone, and end-to-end authenticity over nodes you do not trust. The deliberate non-goal is forward secrecy within a conversation: compromise of a party's account key exposes their recorded DM ciphertext. Groups do not use this profile -- they run (D)MLS, which provides FS/PCS where it matters most.

The convention

  • A DM E2EE message is an ordinary message: text = "p2pmes:dm-e2ee-v1:" + base64(envelope) with the envelope below -- user content, encrypted or not, always rides the plain message endpoint. The prefix is pinned by this profile. A side benefit of the text carriage: msg_id commits to text, so the ciphertext is bound by the message id (unlike control payloads).
  • Requires both parties to have published a verified idbundle-v2. Without one, fall back to plaintext and say so in the UI.

Envelope format

DmEnvelopeV1 = {
  "eph_pub": bstr .size 32,      ; fresh X25519 ephemeral, one per message
  "wraps": [+ KeyWrap],          ; content key wrapped per recipient
  "nonce":  bstr .size 12,       ; AES-GCM nonce for "ct"
  "ct":     bstr                 ; AES-256-GCM(content_key, nonce, SignedContent)
}

KeyWrap = {
  "addr":  bstr .size 20,        ; recipient address (peer, and yourself)
  "nonce": bstr .size 12,
  "key":   bstr .size 48         ; AES-256-GCM(kek, nonce, content_key)
}

SignedContent = {
  "m":   bstr,                   ; the inner content, CBOR, signed byte-exact
  "sig": bstr .size 64           ; Ed25519 over "p2p-mes:dm-sig:v1" || chat_id || m
}

InnerContent = {
  "t":  uint,                    ; content type: 0 = text, 10 = attachments,
  "ts": uint,                    ;   100-255 app-private
  ...                            ; t = 0: "text": tstr
}                                ; t = 10: "attachments" / "caption" as in the
                                 ;         attachments profile
                                 ; "ts" = sender wall-clock ms -- the SIGNED
                                 ;   display time

InnerContent is deliberately reusable: the same map -- without the SignedContent wrapper, since MLS authenticates its own senders -- is also the normative plaintext framing of MLS application messages (sent as p2pmes:mls-app-v1: + base64 in text); see the E2EE Cookbook.

Sending

  1. Fetch and verify the peer's bundle; compute their static X25519 as toMontgomery(ed_pub). Your own keys come from your seed.
  2. Build InnerContent with ts = now_ms, CBOR-encode it into m, and sign: sig = Ed25519_sign(seed, "p2p-mes:dm-sig:v1" || chat_id || m).
  3. Generate a random 32-byte content_key and a fresh ephemeral X25519 keypair. Encrypt: ct = AES-256-GCM(content_key, nonce, SignedContent).
  4. Wrap content_key twice -- for the peer and for yourself (so your own devices and history restores can read it): kek = HKDF-SHA256(ikm = X25519(eph_priv, recipient_static_pub), salt = empty, info = "p2p-mes:dm-wrap:v1"), then key = AES-256-GCM(kek, wrap_nonce, content_key).
  5. Send "p2pmes:dm-e2ee-v1:" + base64(envelope) as text via the plain DM endpoint.

Receiving

  1. A text starting with p2pmes:dm-e2ee-v1: is this profile: strip the prefix and base64-decode the rest into the envelope.
  2. Find your wrap by addr, compute the same kek with your static X25519 private key against eph_pub, unwrap content_key, decrypt ct.
  3. Verify sig over "p2p-mes:dm-sig:v1" || chat_id || m against the expected author's ed_pub -- the peer's verified bundle for incoming messages, your own for echoes of your messages. Reject on failure.
  4. Decode m. Use its signed ts as the authoritative display time -- it is the one timestamp a relay cannot forge (hlc and origin_wall_ts are not client-signed). Flag duplicates (same sig seen before) as replays.
  5. Dispatch on t: 0 render text; 10 follow the attachments profile's receive flow from step 2.

Why sign-then-encrypt

ECIES to public keys is sender-anonymous: anyone, including a node, can encrypt to two published bundles and inject a message that decrypts cleanly -- GCM authenticates bytes, not authorship. The inner Ed25519 signature binds author and chat: the chain is address -> bundle signature -> ed_pub -> message signature. Replaying an old envelope into another chat fails the chat_id binding; replaying into the same chat repeats a known sig and is flagged. This also closes, for E2EE DMs, the known relay-tampering gap on control described in the attachments section.

Size budget

The envelope costs 365 bytes over the inner plaintext (two wraps, ephemeral key, signature, framing), and the base64 carriage adds one third on top. text is capped at 45056 bytes (44 KiB), sized so that anything that would fit the 32 KiB control cap also fits here enveloped: after the prefix, base64, and envelope overhead an E2EE DM carries about 33 KB of inner plaintext -- a very long text or a large multi-attachment album. A payload that does not fit goes by reference through the attachments pipeline (upload the encrypted blob, send the metadata) -- never chunk one logical message across several messages.

Reference test vector

Alice -> Bob (identities from the bundle vector above), chat_id = blake3("p2p-mes:chat:dm:v1:" || min || max) = a91602ff4fbe6b4ff0555945932d5367db2b815cbcb6d05cdf3c399c6fa9e30f. Fixed values (test only): content_key = 0x44*32, eph_priv = 0x55*32, wrap nonces 0x66*12 (Bob) and 0x77*12 (Alice), message nonce 0x88*12, ts = 1700000000000, text "Hello, Bob!".

inner m     = a36174006274731b0000018bcfe5680064746578746b48656c6c6f2c20426f6221
msg sig     = 56cf1140361ca416222fd29ffb3a37b0a474e2ec22b3868ddab55af1f072dc15cd433d8d82a287f3e93e519d2677ff4e3155f2c0f155f3aa1450aa1841a15700
eph_pub     = 38ab664bd86f77d7e66bdd9ae0792913a94fd8b33a1260027e4b46c1f4884c67
kek (Bob)   = 9bef6660bff2a7b28246abb7010774affd36764e34f75baa58f268de69e955b3
wrap (Bob)  = e5c06987c117db0c8d917898e7c9a19cc72f90d479d9311df3bef577bab5fa58917ccd1466976ed483493679254d7dc2
kek (Alice) = 24d98109b6123a1984dca8f7c130175e90c9cdee27ddc3e5d28fdec9f5e32319
wrap (Alice)= 0fe06669ed36807974a548748cc839ddc9210f7bb2a124d09725300df6e2ebe0fb8699bb25817a6b0d2c5c5c14f170f8
ct          = d47ac0c2045d7fd2f79e7a3f3200c0be9ee16bf675a57603cf4270ef98b744171c28b47ba6c08da862a778541ea499e0e8fc46a250e144431c6a62ad39627c8d7e985d48f585cddbfaacf8eca1c25ee1ec70f54f37bbdacf47e6ac3e119fa21dd6fdda434356673c5233ed23765e38499dc1c2d8a8d6ec604a65e06d

Full envelope (398 bytes of CBOR; the wire text is "p2pmes:dm-e2ee-v1:" + base64(envelope) -- 18 + 532 = 550 chars for this vector):

a4676570685f707562582038ab664bd86f77d7e66bdd9ae0792913a94fd8b33a1260027e4b46c1f4884c6765777261707382a36461646472541563915e194d8cfba1943570603f7606a3115508656e6f6e63654c666666666666666666666666636b65795830e5c06987c117db0c8d917898e7c9a19cc72f90d479d9311df3bef577bab5fa58917ccd1466976ed483493679254d7dc2a364616464725419e7e376e7c213b7e7e7e46cc70a5dd086daff2a656e6f6e63654c777777777777777777777777636b657958300fe06669ed36807974a548748cc839ddc9210f7bb2a124d09725300df6e2ebe0fb8699bb25817a6b0d2c5c5c14f170f8656e6f6e63654c888888888888888888888888626374587cd47ac0c2045d7fd2f79e7a3f3200c0be9ee16bf675a57603cf4270ef98b744171c28b47ba6c08da862a778541ea499e0e8fc46a250e144431c6a62ad39627c8d7e985d48f585cddbfaacf8eca1c25ee1ec70f54f37bbdacf47e6ac3e119fa21dd6fdda434356673c5233ed23765e38499dc1c2d8a8d6ec604a65e06d

Attachments (files and media)

The replicated store carries only short text and the small control payload -- binary files must never enter it (every byte would be copied to every node and live for the whole retention window). Files are therefore shared by reference: the file is encrypted client-side, uploaded to ordinary HTTPS object storage (for example an S3-compatible bucket operated alongside the nodes), and the message carries only compact metadata -- the URL, the decryption key material, an integrity digest, plus an optional caption. The node relays that metadata as an opaque Layer 2 payload; the storage provider sees only ciphertext.

This convention is an adaptation of XMTP's remote-attachment content types (XIP-17 and XIP-50): the encryption scheme and the metadata fields are reused as-is; the wire encoding is CBOR instead of protobuf, and versioning rides on msg_type instead of XMTP's content-type identifiers.

The convention

An attachment message is a regular control message:

  • POST /dialogs/{peer}/messages/control or POST /groups/{chat_id}/messages/control
  • msg_type = 10 (attachments v1; a breaking format change would claim a new value)
  • control = base64 of the CBOR metadata described below

Control msg_type values used by known conventions:

ValueMeaningStatus
0regular textset by the plain message endpoints; never used on control
1, 2E2EE handshake / key rotationlegacy client convention, not normative
10attachments v1this section; normative for interoperability
11, 12, 22--retired early E2EE carriages; do not reuse
20MLS Welcome (+ ratchet tree)group E2EE handshake; see the E2EE Cookbook
21MLS Commitgroup E2EE handshake; see the E2EE Cookbook
100-255application-privatenever standardized; safe for app-local experiments

User content never claims a control type: encrypted DMs and MLS application messages ride text with a format prefix (see text prefixes).

Metadata format

The control payload is a CBOR map:

MultiRemoteAttachment = {
  "attachments": [+ RemoteAttachmentInfo],   ; one or more
  ? "caption": tstr                          ; message-level caption,
                                             ; recommended <= 1000 chars
}

RemoteAttachmentInfo = {
  "url":            tstr,             ; where the ciphertext lives
  "content_digest": bstr .size 32,    ; SHA-256 of the uploaded ciphertext
  "secret":         bstr .size 32,    ; HKDF input key material
  "salt":           bstr .size 32,    ; HKDF salt
  "nonce":          bstr .size 12,    ; AES-GCM nonce
  "scheme":         tstr,             ; MUST be "https://"
  ? "content_length": uint,           ; ciphertext size in bytes
  ? "filename":     tstr,             ; display hint (duplicates the envelope)
  ? "mime_type":    tstr,             ; display hint (duplicates the envelope)
}

Field names follow XIP-50's RemoteAttachmentInfo with three deviations: content_digest is 32 raw bytes rather than a hex string; mime_type is an extension (XIP-50 keeps the MIME type only inside the encrypted envelope, which makes it impossible to render a useful preview before downloading); and the message-level caption is an extension -- XIP-50 has no caption concept, and without it a caption would require a separate text message.

Encoding notes:

  • Byte fields here are real CBOR byte strings (major type 2). This is the opposite of the node's msg_cbor quirk (arrays of integers, see the decoding gotcha above). The distinction is deliberate: control is client-defined end to end, so this profile picks the compact native encoding. On read, the control field itself arrives as a CBOR array of integers at the outer msg_cbor layer -- reassemble it into bytes, then CBOR-decode those bytes with this schema.
  • Each attachment MUST use freshly generated secret, salt, and nonce. Never reuse key material across attachments (XIP-50 requires per-file keys).
  • The decrypted payload MUST NOT itself be a MultiRemoteAttachment -- no recursion (XIP-50 rule).

The encrypted envelope

What you encrypt and upload is not the raw file but a small CBOR envelope (the analog of XMTP's XIP-15 attachment type), so the storage provider learns neither the file name nor its type:

Attachment = {
  "filename":  tstr,
  "mime_type": tstr,
  "data":      bstr    ; the file bytes
}

Encryption

The scheme is exactly XMTP's remote-attachment encryption, so existing implementations translate directly:

  1. Generate secret (32 random bytes), salt (32 random bytes), and nonce (12 random bytes) -- fresh per attachment.
  2. key = HKDF-SHA256(ikm = secret, salt = salt, info = empty, length = 32). The info parameter is empty (zero bytes), matching the XMTP implementation.
  3. ciphertext = AES-256-GCM(key, nonce, plaintext = envelope CBOR) with no additional authenticated data. The 16-byte GCM tag is appended to the ciphertext -- the default behavior of WebCrypto, Rust's aes-gcm, and Python's cryptography.
  4. content_digest = SHA-256(ciphertext) -- over the exact bytes uploaded.

Sending

  1. Build and encrypt the Attachment envelope as above.
  2. Upload the ciphertext to your storage. Use hex(content_digest) as the object key: uploads become idempotent, identical files deduplicate, and the key leaks nothing. How you authenticate the upload (presigned PUT, operator-issued credentials) is a deployment concern, not part of the protocol.
  3. Repeat per attachment, fresh key material each time.
  4. Assemble MultiRemoteAttachment, CBOR-encode it, base64 it, and send it as a control message with msg_type = 10.

Receiving

  1. Decode msg_cbor; on msg_type == 10, reassemble the control bytes and CBOR-decode the metadata.
  2. Render the caption (if present) and a placeholder from filename / mime_type / content_length. This is also where chat previews come from: use the caption for your conversation-list line. Do not fetch automatically: the URL is sender-controlled, and a download reveals your IP address to whoever operates the storage (the XIP-50 threat model). Fetch on user action, or auto-fetch only from storage hosts your deployment trusts.
  3. Before fetching, require scheme == "https://" and reject anything else. While fetching, enforce a size cap -- treat content_length as a hint, not a promise.
  4. Verify SHA-256(downloaded bytes) == content_digest; abort on mismatch.
  5. Derive the key, decrypt (the GCM tag authenticates the content), CBOR-decode the envelope, and render according to mime_type. Treat filename as a display string only -- never let it choose a filesystem path or an execution decision.

A client that does not understand msg_type = 10 sees an ordinary control message with empty text and falls back to its generic "unsupported message" state -- the same fallback rule XIP-50 prescribes.

Reference test vector

Fixed inputs (fixed key material is for tests only):

secret   = 0x11 repeated 32 times
salt     = 0x22 repeated 32 times
nonce    = 0x33 repeated 12 times
envelope = { "filename": "hello.txt", "mime_type": "text/plain",
             "data": "Hello, world!" (13 bytes) }

Expected outputs (all hex):

envelope CBOR  (60 bytes):
a36866696c656e616d656968656c6c6f2e747874696d696d655f747970656a746578742f706c61696e64646174614d48656c6c6f2c20776f726c6421

derived key:
6b8121baa9b21c516029bf185397a0f193ff7297a22ae6292f92259961a461a0

ciphertext with appended GCM tag (76 bytes):
ffbb7ec2d2424f497d2ba1ba33416f51f529e652d16747816a4273d489715ed4dd0f05f7d52ffff3d112a99ab9d30969791c41ff33e20b165c3285cd9ee40a9e16ae5bd562d3cfcc186a7838

content_digest:
f0775ac2e1fd2096683a1ac3a338d56e92c051647ad0f4297c2477d6d964f277

With url = "https://files.example.com/" + hex(content_digest), all optional attachment fields present, and no caption, the full one-attachment MultiRemoteAttachment encodes to 332 bytes of CBOR:

a16b6174746163686d656e747381a96375726c785a68747470733a2f2f66696c65732e6578616d706c652e636f6d2f663037373561633265316664323039363638336131616333613333386435366539326330353136343761643066343239376332343737643664393634663237376e636f6e74656e745f6469676573745820f0775ac2e1fd2096683a1ac3a338d56e92c051647ad0f4297c2477d6d964f27766736563726574582011111111111111111111111111111111111111111111111111111111111111116473616c7458202222222222222222222222222222222222222222222222222222222222222222656e6f6e63654c33333333333333333333333366736368656d656868747470733a2f2f6e636f6e74656e745f6c656e677468184c6866696c656e616d656968656c6c6f2e747874696d696d655f747970656a746578742f706c61696e

and the control field of the request body is its base64:

oWthdHRhY2htZW50c4GpY3VybHhaaHR0cHM6Ly9maWxlcy5leGFtcGxlLmNvbS9mMDc3NWFjMmUxZmQyMDk2NjgzYTFhYzNhMzM4ZDU2ZTkyYzA1MTY0N2FkMGY0Mjk3YzI0NzdkNmQ5NjRmMjc3bmNvbnRlbnRfZGlnZXN0WCDwd1rC4f0glmg6GsOjONVuksBRZHrQ9Cl8JHfW2WTyd2ZzZWNyZXRYIBERERERERERERERERERERERERERERERERERERERERERZHNhbHRYICIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiZW5vbmNlTDMzMzMzMzMzMzMzM2ZzY2hlbWVoaHR0cHM6Ly9uY29udGVudF9sZW5ndGgYTGhmaWxlbmFtZWloZWxsby50eHRpbWltZV90eXBlanRleHQvcGxhaW4=

Size budget

DM and group control payloads are both capped at 32 KiB (decoded). One RemoteAttachmentInfo with a 90-character URL and all optional fields is ~330 bytes, and a caption shares the same budget (CBOR counts its UTF-8 bytes), so the cap fits dozens of attachments plus a full-length caption. Keep albums to ~10 per message for UX, not because of the budget. The text cap (45056 bytes) is deliberately sized so the same metadata also fits inside the encrypted carriages, where it rides base64-encoded in text instead.

There is no protocol-level limit on the blob itself -- cap uploads by storage policy and enforce a download cap client-side.

Storage requirements

Anything that serves plain unauthenticated HTTP GET works. For the typical S3-compatible setup:

  • Objects must be readable without credentials by anyone holding the URL. Privacy comes from the unguessable content-addressed key plus the encryption -- the same capability-by-knowledge pattern as DM chat IDs. Do not embed presigned GET URLs in messages: they expire (7 days maximum on S3, far short of the 30-day message retention) and their length blows the DM control budget.
  • Set the bucket lifecycle to at least the message retention window (30 days) so links do not die before the messages that carry them. Links that outlive retention are a privacy consideration for the operator.
  • Web clients need CORS allowing GET on the bucket.

Privacy and integrity caveats

  • In plaintext chats, node operators can decrypt your attachments. The secret travels in control, which every node stores -- and the caption, filenames, and URLs are readable there directly. The encryption protects against the storage provider and anyone who merely obtains the URL -- not against nodes. That is exactly the trust level of plaintext text. For confidentiality from node operators, carry the same fields inside an encrypted envelope instead of the plaintext control: in DMs, send dm-e2ee-v1 with inner t = 10; in (D)MLS groups, put the MultiRemoteAttachment CBOR inside an MLS application message. The blob encryption itself is identical in all three carriages.
  • msg_id does not cover control (it commits to chat || sender || hlc || text only), so attachment metadata is not bound by the message id -- the same known relay-tampering gap as hlc rewriting (see Ordering and timestamps). The encrypted carriages close it: the dm-e2ee-v1 envelope signs the inner content, and MLS authenticates its application messages.

Current limitations

  • No node-side previews. /conversations carries no content preview field at all -- by design, the node cannot read a caption buried in an opaque payload (or a ciphertext envelope), so previews are the client's job. Render conversation-list lines from your local message cache; an attachment's caption becomes visible only after fetching and decoding the message.

Encoding conventions

DataEncoding in JSON / headers
Addresses, chat IDs, msg IDs, cursors0x-prefixed hex
Signatures (X-Sig, op sig)0x-prefixed hex
Message bodies on read (msg_cbor)0x-prefixed hex of CBOR
control payloads, identity blobsbase64
Timestampsinteger milliseconds

Error handling

Errors are returned with a conventional HTTP status and a JSON body:

{ "error": "forbidden" }
StatusMeaning
400Bad input -- malformed hex/base64, wrong length, invalid field
401Authentication failed -- bad signature, stale X-Ts, wrong node
403Forbidden -- not a member, or not authorized for a membership op
404Not found
500Internal error

A 400 arrives in one of two shapes, depending on what failed:

  • Declared field validation (the size/range rules on body and query fields, e.g. text 1-45056 bytes, limit 1-1000) returns error: "validation_error" plus a structured fields map naming each offending field and why:

    {
      "error": "validation_error",
      "fields": {
        "text": { "msg": "text must be 1..=45056 bytes of UTF-8 (44 KiB)",
                  "value": "" }
      }
    }
    
  • Everything else is the flat shape with no fields: malformed path parameters ({"error": "bad hex"}, {"error": "wrong id length"}), malformed cursors, and semantic input errors ("unknown op_type", "nonce required for create op", "identity blob exceeds 1024 bytes").

Handle both: fields is present only when error is "validation_error".

Operational notes for client authors

This section is the honest current state of the protocol from a client's point of view: what works today, the sharp edges, and how to cope with them. Several items here are limitations the protocol intends to address; they are called out so you can design around them now.

Real-time delivery and background

There is no push, WebSocket, or SSE today. The only way to learn of new messages is to poll GET /conversations (cheap: reverse-time, carries unread counts) and then GET .../messages for chats that changed. Poll on a backoff while foregrounded. Background delivery on iOS/Android does not work -- there is no push gateway (APNs/FCM), so a backgrounded app will not receive messages until it next polls. Do not emulate typing/presence with normal messages: they would replicate and persist for the whole retention window. Real-time transport and push are the largest planned additions.

Which node, and trusting it

A client speaks HTTP to a single node it does not run (a phone cannot hold a full replica of the whole network). There is no node discovery, health, or failover endpoint yet: pin a node URL (or a short operator-provided list) and handle transport errors with retry/backoff. You trust that node's operator with all of your metadata -- see Privacy.

What 200 means; delivery state

A 200 means the node accepted and broadcast the message, not that it is durably stored or delivered (the write is queued after the response). There are no delivery receipts, and the only read signal is coarse per-chat read progress. Build optimistic UI and reconcile by reading back; do not present "delivered" as a guarantee.

Idempotency and retries

The node computes msg_id from its own HLC, so resending the same text after a timeout produces a different msg_id -- a duplicate, not a dedup. There is no client idempotency key and no anti-replay nonce yet. Until there is: prefer waiting for the response (its body carries the real msg_id) before retrying; if you must retry blind, dedup on the client by (sender, text, approximate time) and reconcile when the real msg_id arrives.

Ordering and timestamps

Each message carries two times: hlc (the network-consistent stamp that defines storage and sync order) and origin_wall_ts (the sender's wall clock, for display). Sort by hlc for stable, cross-node-consistent ordering. Show origin_wall_ts as the human time, but treat it as untrusted -- the sender sets it and nothing validates it, so clamp obvious outliers and never use it for ordering. Note a known gap: a malicious relay can rewrite hlc without invalidating the client signature, so hlc is not cryptographically authoritative; prefer a node you trust until this is closed.

seq, read progress, and switching nodes

seq is assigned locally by each node (last_seq + 1 at write time), not globally. Because nodes can apply the same messages in different orders (gossip vs. anti-entropy sync), the seq of one message can differ between nodes -- and so can pagination cursors, which embed the storage key. Consequences:

  • Use msg_id (deterministic BLAKE3) as the stable, cross-node message identity.
  • Treat seq, cursors, and read progress (which is keyed by seq) as node-relative. Marking read on node A does not map cleanly onto node B.
  • For consistent unread/read state, keep one identity pinned to one node until globally consistent sequencing lands.

Pagination and the message tail

GET .../messages pages in both directions: forward (oldest-first from from) and, with reverse=true, newest-first from to -- the "load the latest screen, then scroll up" pattern (see Reading history above). Use /conversations (last_ts, unread) as the cheap signal that a chat has a new tail. Since reads never block on the network, a node that is behind returns a partial page with no "is this complete?" flag -- show a soft "syncing" state rather than implying the history is final, and apply the next_after contract from Reading history: a cursorless page marks the end of the node's current view, never proof that the history is exhausted.

Clock skew and X-Ts

X-Ts must be within +/- 30 s of the node's clock or the request is rejected (401). There is no server-time endpoint yet, so sync the device clock (NTP); if you see unexplained 401s, suspect clock drift before signature bugs.

Key custody and recovery

Every request is signed by the user's secp256k1 key, so the key is the identity. Store it in the platform secure store (iOS Secure Enclave / Android Keystore). Note that recoverable ECDSA with hardware-backed keys is fiddly: you must recover the recovery byte v (the node tolerates a wrong v by trying both). There is no key backup or recovery -- losing the key permanently loses the identity. Design enrollment and backup UX accordingly.

Multi-device

One keypair is one identity. Multi-device is not specified: sharing the private key across devices authenticates fine, but read progress is node-relative (see above) and any Layer 2 session state is yours to coordinate. Treat the protocol as single-device today.

Identity blobs and key trust

PUT /identity is signed, so the receiving node can attest who published a blob, and the blob propagates network-wide (last-write-wins by HLC). But the transport does not authenticate blob contents to readers -- gossip replication carries no client signature, so a malicious node can rewrite any address's blob network-wide. Do not trust a bare key fished out of a blob. The identity key bundle is the documented answer: contents self-signed by the account key, verified by the reader via ecrecover against the peer's address, with (gen, ts) pinning against stale-bundle replay. Treat legacy raw 32-byte blobs as trust-on-first-use only, and refuse E2EE bootstrap on them unless the user explicitly accepts the risk.

Groups: rekey is not atomic

A compound ops call can bundle membership changes with accompanying messages (e.g. an MLS Commit), but there is no atomicity between them: a Remove can apply while the rekey message is lost, leaving the group in a broken crypto state. Until atomic membership+rekey exists, detect the gap (an expected rekey never arrives) and recover by re-issuing it.

Text length and large messages

text is validated as 1-1000 Unicode scalar values (Rust chars), not bytes -- an emoji counts as one or more chars, and percent-encoding during canonicalization does not change the count. There is no server-side chunking: messages longer than 1000 must be split client-side, and reassembly is your Layer 2 concern.

Feature scope today

  • No first-class media. The replicated store carries only text plus the opaque control payload -- binary files never enter it. Files are shared by reference instead: encrypted blobs on external HTTPS storage, metadata in control. See Attachments for the convention, including the encryption scheme.
  • Edit and delete are supported for your own messages within 48 hours of sending -- see Editing and deleting messages. Beyond that window messages are append-only and leave only via the retention window.
  • channel chats are reserved -- no channel create/post/subscribe endpoints exist yet.