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.
Prerequisites
Section titled “Prerequisites”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.
Identity is the connection
Section titled “Identity is the connection”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 (
senderPeerIdon a message,ownerPeerIdon MMA requests,peerIdonexpunge) 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 isAPPENDto 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_authenticationon, the server accepts connections only from peers insecurity.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.
One request per stream
Section titled “One request per stream”- Open a new libp2p stream to the server with the protocol ID.
- Write one frame: a 4-byte big-endian length, then that many bytes of UTF-8 JSON.
- Close your write side, or simply start reading.
- 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.
Submitting a message
Section titled “Submitting a message”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.
Retrieving
Section titled “Retrieving”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.
Mailbox administration
Section titled “Mailbox administration”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"}Documents, feeds and collections
Section titled “Documents, feeds and collections”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}}ownerPeerIdis required on every store request, including directorybrowse; 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
statuswith the reason inheaders.Errorand any wait hint inheaders["Retry-After-Ms"]. - SCA item
GETandQUERYput their JSON in adatafield, not base64 inbody. Readdatawhen present and fall back tobody. - The peer directory lives on SDA:
{"operation": "DIRECTORY", "directoryAction": "join", ...}with the listing as a base64 JSONbody(displayNamerequired), andleave,browseandgetactions.
The operations each protocol accepts are listed on Protocol IDs.
Errors and status codes
Section titled “Errors and status codes”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.
Notifications
Section titled “Notifications”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.
Payload encryption and compression
Section titled “Payload encryption and compression”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 (inboxwhen 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.
Conformance checklist
Section titled “Conformance checklist”- 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
payloadand storebodyfields with standard padding. - Sets
senderPeerIdandownerPeerIdto its own peer ID. - Parses the compound MAA
retrieveresponse, and the plain error object a refused retrieve returns. - Acknowledges messages with
markDeliveredrather than assuming retrieval removed them. - Pages retrieval with
fromSequencewhilehasMoreis true. - Sends
If-Matchon document and collection writes it wants to make conditional, and handles 409. - Reads SCA
databeforebody. - Distinguishes 429, 503 and 507, and honours retry hints in milliseconds.
- Handles
/ricochet/mailbox-notify/1.0.0streams, 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.