Skip to content

Writing your own client

This page gives you what you need to write a Ricochet client from scratch: how to connect, how a request travels, what each protocol expects, and how to check your implementation. The full field lists are in Wire format & status codes; the protocol IDs are in Protocol IDs.

Your stack needs a libp2p implementation with a UDX transport, Noise and Yamux, because a go-ricochet 1.1.0 server accepts nothing else. Today that means go-libp2p, dart_libp2p or js-libp2p; see UDX: Go, Dart, JS. If your language has a Ricochet client already (Go, Dart), use it.

There is no login, token or signature in any request. The server takes the caller’s identity from the peer ID the Noise handshake proved.

  • Fields that name a peer on the caller’s behalf (senderPeerId on a message, ownerPeerId on MMA requests, peerId on expunge) must equal your peer ID or be empty. Anything else is refused with 403.
  • Writes to documents, feeds and collections are the owner’s alone, checked against ownerPeerId. The one exception is APPEND to a feed its owner created as collaborative.
  • Reads are checked against the resource’s visibility and reader list; see Who can read what.
  • With features.enable_authentication on, the server accepts connections only from peers in security.trusted_peers; an untrusted peer is refused before any stream opens.

Use an Ed25519 identity if you want to interoperate with the Go and Dart clients’ payload encryption: it derives X25519 keys from the Ed25519 identity keys.

  1. Open a new libp2p stream to the server with the protocol ID.
  2. Write one frame: a 4-byte big-endian length, then that many bytes of UTF-8 JSON.
  3. Close your write side, or simply start reading.
  4. Read one frame back. The server closes the stream after writing it.
Rule Value
Frame payload limit 10 MiB, either direction
Binary fields Standard base64 with padding (payload, body, content, batchDocuments[].body)
Server idle limit 30 s without bytes moving (default performance.connection_timeout_sec)
Server work limit 10 s from the decoded request to the response (default performance.message_timeout_sec)
Timestamps Unix milliseconds, except Notification.timestamp (seconds) and some RFC 3339 strings in SDA bodies

Do not reuse a stream for a second request. Open a fresh one; Yamux makes that cheap.

On /sf-network/submit/1.0.0 the request is the message object itself, with no wrapper or operation field:

{
"messageId": "6f1c2e0a-8f4b-4f57-9d0e-2b6f1d3c9a10",
"recipientPeerId": "12D3KooWRecipient...",
"senderPeerId": "12D3KooWYourPeerId...",
"payload": "aGVsbG8=",
"priority": 1,
"expiryTimestamp": 0,
"hopCount": 0,
"flags": 0,
"createdTimestamp": 1760000000000,
"folderPath": "inbox"
}

The response is a StoreAck:

{"messageId": "6f1c2e0a-8f4b-4f57-9d0e-2b6f1d3c9a10", "success": true}

A refusal sets success to false with errorMessage, status and, when the server knows one, retryAfterMs. /sf-network/submit/batch/1.0.0 takes {"messages": [...]} with 1 to 100 messages and answers {"acks": [...], "accepted": n, "total": n}, one ack per message in request order.

On /sf-network/access/1.0.0, operationType selects the operation; an absent operationType means retrieve.

{"operationType": "retrieve", "peerId": "12D3KooWOwner...", "folderPath": "inbox", "maxMessages": 50}

peerId is the mailbox owner: yourself, or another peer whose shared mailbox lists you or whose mailbox is public. The response frame is not plain JSON. Its payload is a 4-byte length and a JSON header {"messageCount": n, "hasMore": bool}, followed by n parts, each a 4-byte length and one message object. Check for a plain JSON error object first: a refused retrieve answers {"error": "...", "status": 403} instead.

Retrieval removes nothing. Acknowledge with {"operationType": "markDelivered", "messageIds": [...]}: the server removes non-persistent messages and marks persistent ones seen. Page with fromSequence set to the last sequenceNumber plus one while hasMore is true.

On /sf-network/admin/1.0.0, every request carries operationType and the response is {"success": bool, "errorMessage": ..., "status": ..., ...} plus an operation-specific field (mailboxes, acl, capacity or data).

{"operationType": "createMailbox", "ownerPeerId": "12D3KooWYourPeerId...", "folderPath": "team", "mailboxType": "shared"}
{"operationType": "grantAccess", "ownerPeerId": "12D3KooWYourPeerId...", "folderPath": "team", "granteePeerId": "12D3KooWFriend...", "accessMode": "readWrite"}

The three store protocols share an HTTP-like envelope. The request names an operation, an ownerPeerId and usually a path; the response carries a status, a headers map and a base64 body.

{
"operation": "PUT",
"ownerPeerId": "12D3KooWYourPeerId...",
"path": "config/settings",
"headers": {"Content-Type": "application/json", "If-Match": "<etag>"},
"body": "eyJ0aGVtZSI6ImRhcmsifQ==",
"visibility": "private"
}
{"status": 200, "headers": {"ETag": "<new etag>", "Last-Modified": 1760000000000}}
  • ownerPeerId is required on every store request, including directory browse; send your own peer ID when the operation has no owner.
  • Paths match ^[a-zA-Z0-9\-_/]+$, are at most 256 bytes and must not start with / or contain ...
  • A refusal comes back as a non-2xx status with the reason in headers.Error and any wait hint in headers["Retry-After-Ms"].
  • SCA item GET and QUERY put their JSON in a data field, not base64 in body. Read data when present and fall back to body.
  • The peer directory lives on SDA: {"operation": "DIRECTORY", "directoryAction": "join", ...} with the listing as a base64 JSON body (displayName required), and leave, browse and get actions.

The operations each protocol accepts are listed on Protocol IDs.

Status codes are HTTP’s. Where they appear depends on the protocol:

Protocol Status field Message field Retry hint
MSA status in each ack errorMessage retryAfterMs
MAA status in the error object error retryAfterMs
MMA status errorMessage retryAfterMs
SDA, SFA, SCA status headers.Error headers["Retry-After-Ms"]

Treat these three differently:

Code Meaning What to do
429 You exceeded a per-peer rate limit Wait the hint, then retry
503 The server is shedding load or draining Back off and retry, or try another server
507 The mailbox is full or storage is exhausted Do not retry on a timer: only the owner or operator can clear it

An MSA ack or MMA response with success false and no status comes from validation in the handler; treat it as 400 for MMA and as unclassified for MSA. The full table is on Wire format & status codes.

For a private mailbox, the server opens a stream to the owner with /ricochet/mailbox-notify/1.0.0 and writes one frame:

{"mailboxPath": "inbox", "mailboxType": "private", "messageCount": 1, "timestamp": 1760000000, "serverPeerId": "12D3KooWServer..."}

Register a stream handler for that protocol, read one frame, then retrieve from serverPeerId. The server uses the connection your client opened, so you do not need to be dialable. For shared and public mailboxes, the server publishes the same JSON, without a length prefix, on the GossipSub topic /ricochet/mailbox/<ownerPeerID>/<folderPath>; subscribe if your stack runs GossipSub. Notifications are best effort: the server drops them when its push workers are busy, so poll as well.

The server stores payloads as sent. To read messages from, and write messages for, the Go and Dart clients, implement their payload format:

  • Compression (flag 0x04): a 4-byte big-endian original length, then an LZ4 block. Applied before encryption.
  • Encryption (flag 0x01): "RCE2", a 24-byte nonce, then a NaCl box (X25519 keys converted from the two Ed25519 identity keys, XSalsa20-Poly1305) over a header and the payload. The header is three fields, each a 2-byte big-endian length and the bytes: recipient peer ID, folder path (inbox when empty), message ID.

pkg/client/encryption.go and the Dart client’s lib/crypto/payload_encryption.dart both carry the same fixed test vectors; match them byte for byte. See Encryption & compression.

  • Dials /ip4/<ip>/udp/<port>/udx/p2p/<peerID> with UDX wire version 3, Noise and Yamux.
  • Opens one stream per request and reads exactly one response frame.
  • Writes a 4-byte big-endian length before each JSON payload and refuses frames over 10 MiB.
  • Base64-encodes payload and store body fields with standard padding.
  • Sets senderPeerId and ownerPeerId to its own peer ID.
  • Parses the compound MAA retrieve response, and the plain error object a refused retrieve returns.
  • Acknowledges messages with markDelivered rather than assuming retrieval removed them.
  • Pages retrieval with fromSequence while hasMore is true.
  • Sends If-Match on document and collection writes it wants to make conditional, and handles 409.
  • Reads SCA data before body.
  • Distinguishes 429, 503 and 507, and honours retry hints in milliseconds.
  • Handles /ricochet/mailbox-notify/1.0.0 streams, and keeps polling.
  • If it encrypts, matches the Go and Dart test vectors.
  • Passes the Dart package’s interop harness scenarios against a local server, or your own equivalent: send, retrieve, acknowledge, document put/get with ETags, feed append/read, collection put/query.

To run a local server for testing, see Install and First run & your peer ID.