NoPeek documentation
E2EE chat APIs your servers can’t read. Every channel is end-to-end encrypted by default with MLS (RFC 9420); NoPeek stores, orders and fans out opaque ciphertext — and nothing more.
https://api.nopeekapi.com.
The base URL of the current deployment is injected at deploy time via config.js
(window.NOPEEK_API_URL); when set, the URLs on this page update automatically.Quickstart
Go from zero to an end-to-end-encrypted conversation in about five minutes. You need a NoPeek account, a backend that can hold a secret, and a client app.
1. Sign up and create an app
Sign up in the dashboard and create an app
(pick the test environment while you build). App provisioning is
instant. Creating an app also mints its first app server key —
copy it now, the secret is shown exactly once. The app ships with three default
channel types, all end-to-end encrypted: direct, group
and broadcast.
Prefer the API? POST /v1/orgs signs you up and returns a one-time
org admin key (npk_test_…/npk_live_…), and
POST /v1/apps creates the app:
curl -X POST "https://api.nopeekapi.com/v1/apps" \
-H "Authorization: Bearer npk_test_ORG_ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{ "name": "Acme Chat (test)", "environment": "test" }'
Keep the credential classes straight: the org admin key is for
the dashboard and org management only; the app server key lives on
your backend; session tokens (nps_…) are the only
credential that ever reaches a client. See
Errors & authentication.
2. Create a user (server side)
Users belong to your app and are keyed by your own externalId —
the call is an upsert, so syncing your user table is one idempotent request per
user:
curl -X POST "https://api.nopeekapi.com/v1/apps/app_7f3kq2c9/users" \
-H "Authorization: Bearer npk_test_SERVER_KEY" \
-H "Content-Type: application/json" \
-d '{ "externalId": "user-1001", "nickname": "Amina" }'
Identity & who can talk to whom. Every user
belongs to exactly one app, and every credential is app-scoped. When you create a
channel you pass member userIds, and NoPeek rejects any id that isn't a
user of that same app — so your users can only chat with each
other, never with users of another company/app. Different companies get
different apps (or orgs) and are fully isolated: an app-server key, org-admin key,
or user session from Company A cannot read, list, message, or add anyone in
Company B's app. Isolation is enforced on every request, not just by convention.
You don't manage encryption keys yourself: when a user's device connects it
calls client.registerDevice(), which generates that device's keypair
and publishes its public key packages. Creating an E2EE channel then
claims one key package per member device and runs the key ceremony automatically —
each user's messages become readable only to the devices in that channel.
3. Mint a session token (server side)
Your backend authenticates the end user however it already does, then asks NoPeek for a short-lived session token (default TTL 24 h) and hands it to the client. Never ship a server key to a client.
curl -X POST "https://api.nopeekapi.com/v1/apps/app_7f3kq2c9/users/user_8m2n1xq4/sessions" \
-H "Authorization: Bearer npk_test_SERVER_KEY" \
-H "Content-Type: application/json" \
-d '{ "ttlSeconds": 86400 }'
# → { "sessionToken": "nps_…", "expiresAt": "2026-07-07T12:00:00Z" }
4. Connect from the client
npm i @nopeek/chat
import { NoPeek } from '@nopeek/chat';
const client = await NoPeek.connect({
apiUrl: 'https://api.nopeekapi.com',
sessionToken: SESSION_TOKEN, // minted by your backend in step 3
});
// First run on this device: generates the MLS signature credential,
// registers the device, and publishes a pool of MLS key packages.
await client.registerDevice({ displayName: 'Amina’s laptop' });
// Create (or reuse) a 1:1 channel — E2EE by default.
const channel = await client.channels.create({
channelTypeKey: 'direct',
memberIds: ['user_3p9dq5w2'],
});
channel.on('message', (msg) => {
console.log(msg.senderUserId, msg.text); // decrypted locally on this device
});
await channel.send({ text: 'Hello — the server can’t read this.' });
Under the hood the SDK talks REST to https://api.nopeekapi.com and
opens a WebSocket at:
wss://api.nopeekapi.com/v1/ws?token=SESSION_TOKEN&deviceId=DEVICE_ID
If you are building your own client, the full frame protocol is documented in Realtime WebSocket protocol.
5. What just happened
registerDevice()calledPOST …/deviceswith the device's Ed25519 credential key, then published single-use MLS key packages viaPOST …/devices/{deviceId}/key-packages.channels.create()calledPOST …/channels; becausedirectis an E2EE type, the SDK then claimed one key package per device of the other member and posted an MLS Welcome — establishing a group only member devices can decrypt.channel.send()encrypted the text into an MLS application message and sent the ciphertext; NoPeek assigned it aseq, stored the opaque envelope, and fanned it out.
Read the E2EE model next to understand exactly what NoPeek can and cannot see.
White-label messenger
NoPeek ships a turnkey, white-label WhatsApp-style messenger — end-to-end encrypted by default — that you can brand and hand to end users with no backend of your own. The same endpoints are also there for teams who want to build their own client and bring their own auth.
Two ways to adopt it
| Turnkey clone | API / MCP customer |
|---|---|
| Ship our branded app; users authenticate directly against NoPeek. | You run your own client and identity, and mint session tokens server-side. |
| Cognito is the primary IdP (password, MFA, refresh) with recovery-key backup, or native username/password when Cognito is off. | Your backend calls POST /v1/apps/{appId}/users/{userId}/sessions after your own login. |
No backend required — the messenger/* endpoints are the backend. | The messenger/* endpoints are optional; use the core API + MCP. |
Everything below describes the turnkey path. It rides on the same E2EE core as the rest of the API — see the E2EE model.
Sign-in
Two front doors, both returning the same session payload (a
sessionToken, the user profile, their workspaces, and the
mustChangePassword/hasRecoveryKey flags):
# Native username/password (when Cognito is disabled)
POST /v1/apps/{appId}/messenger/login
{ "username": "amina@acme.dev", "password": "…" }
# Cognito is the IdP: the app authenticates against Cognito, then exchanges
# the verified id token for a NoPeek session (the primary clone login).
POST /v1/apps/{appId}/messenger/cognito-session
{ "idToken": "eyJ…" }
On cognito-session NoPeek verifies the token, then finds the user by
their Cognito sub, else links by email, else provisions a new account —
so Cognito and NoPeek identities stay in sync automatically.
Onboarding — no open registration
There is deliberately no public sign-up. A user gets an account one of two ways:
- Org / referral code.
POST …/messenger/joinwith a workspacecodeplus a username and password creates the account and drops the user into that workspace as a member. Referral codes only work when the workspace hasallowReferralJoinset. - Admin-created. Your server key calls
POST …/messenger/users. Username defaults to the user's email (admins add people by email); the display handle is separate. If you omit a password one is generated and returned once asinitialPassword, and the account is flaggedmustChangePassword.
curl -X POST "https://api.nopeekapi.com/v1/apps/app_7f3kq2c9/messenger/users" \
-H "Authorization: Bearer npk_live_SERVER_KEY" \
-H "Content-Type: application/json" \
-d '{ "username": "amina@acme.dev", "displayName": "Amina", "workspaceId": "ws_6b3n8kq2", "role": "member" }'
# → { "user": { … }, "initialPassword": "Np…7z", "mustChangePassword": true }
Email as username, handle as identity. The login
identifier is the email; the displayName/nickname is what
other members see. When Cognito is configured, admin-created email users are mirrored
into the Cognito pool and their sub stored for token linking.
The unified multi-org inbox
A user can belong to more than one workspace (an already-signed-in user redeems
another code via POST …/messenger/join-workspace). The inbox stitches
every workspace together into one list, newest activity first, each conversation
tagged with the workspace it came from:
GET /v1/apps/{appId}/messenger/inbox
# → {
# "inbox": [ { "channelId": "chan_…", "workspaceId": "ws_…",
# "workspaceName": "Acme", "unreadCount": 3, "myRole": "member", … } ],
# "workspaces": [ { "workspaceId": "ws_…", "name": "Acme" } ]
# }
Each item is a full channel envelope plus workspaceId,
workspaceName, communityId, unreadCount and the
caller's myRole. See Workspaces &
communities for how membership works.
White-label branding
Branding is intentionally minimal — logo and colors only, no code.
Set it per app (PATCH /v1/apps/{appId} with a branding
object) or per workspace at creation. Clients read it back with a session token via
GET /v1/apps/{appId}/branding to theme the UI. There is no HTML/JS
injection surface: the clone stays a NoPeek build, just re-skinned.
Session auto-logoff
For regulated deployments you can force sessions to expire. Set the app's
sessionTtlSeconds (PATCH /v1/apps/{appId}) to one of the
allowed presets — 48h, 7d, 30d, 60d, 90d, 180d
(172800, 604800, 2592000, 5184000,
7776000, 15552000); any other value is rejected. Every
messenger login mints a token with that lifetime, and clients read the current value
(and serverTime, to detect clock drift) from the public
GET /v1/app-config?appId=.
Passwords & recovery
The messenger endpoints cover the whole password lifecycle:
change-password (lossless, keeps history), forgot-password /
reset-password (emailed code), the admin reset
(POST …/messenger/users/{userId}/reset-password), and
email-recovery-key for saving the on-device recovery code. Because
messages are E2EE, which of these preserves old messages is a cryptographic
question, not a UX one — read Backup & recovery for
the exact rules.
The E2EE model
NoPeek channels are end-to-end encrypted with MLS (Messaging Layer Security, RFC 9420). NoPeek servers act as an MLS delivery service: they store, order and fan out opaque ciphertext. They never hold decryption keys.
Encrypted by default, opt-out is explicit
Channel types are created with e2ee: true unless you explicitly set
e2ee: false — and that opt-out is possible only at channel-type
creation, can never be toggled later, and is badged permanently in the
dashboard. Server-side content features (profanity filter, plaintext search,
server-rendered link previews) exist only for opted-out types, because they are the
only place plaintext ever reaches NoPeek.
What NoPeek can and cannot see
| Servers CAN see (routing metadata) | Servers CANNOT see (E2EE channels) |
|---|---|
| Sender and channel ids | Message text |
| Timestamps | Attachments (encrypted client-side before upload) |
Message seq (ordering) | Reactions (carried inside ciphertext) |
| MLS epoch numbers | Link previews (rendered on-device) |
| Channel membership | GIFs and any other media content |
| Delivery/read receipts |
Receipts, typing indicators and membership are metadata by design — that is what lets NoPeek order messages, compute unread counts and fan out efficiently without reading content.
Devices and key packages
Every device holds its own MLS signature credential (registered via
POST /v1/apps/{appId}/users/{userId}/devices) and maintains a pool of
single-use key packages — we recommend keeping at least 20
published:
POST /v1/apps/{appId}/users/{userId}/devices/{deviceId}/key-packages
GET /v1/apps/{appId}/users/{userId}/devices/{deviceId}/key-packages # pool count
Key package payloads are opaque TLS-serialized MLS structures; NoPeek stores
bytes and a hash, nothing more. The @nopeek/chat SDK tops the pool up
automatically.
Adding members
When a member adds userId to an E2EE channel, the adding device
claims one key package per active device of that user:
POST /v1/apps/{appId}/users/{userId}/key-packages/claim
The claim is atomic; each package is consumed exactly once. If any of the user's
devices has an empty pool the call fails with NO_KEY_PACKAGES (409) —
the user's devices need to come online and republish. The adding device then posts
an MLS Add commit plus a Welcome for the new devices. Until that
commit lands, the new member is in the roster but cannot decrypt anything.
Commits, epochs and the single-writer rule
All group changes (add, remove, key rotation) travel as MLS handshake messages through:
POST /v1/apps/{appId}/mls/groups/{mlsGroupId}/handshakes
GET /v1/apps/{appId}/mls/groups/{mlsGroupId}/handshakes?sinceEpoch=41 # catch-up
The server enforces a single-writer rule: a commit is accepted
only if it targets the group's current epoch. On success the epoch increments and
the blob fans out to member devices over the WebSocket. If two devices race, one
wins and the other gets 409 STALE_EPOCH — it must fetch the backlog
with sinceEpoch, merge, and re-issue its commit against the new epoch.
The server validates only the epoch number; the handshake payload itself stays
opaque.
Removal and revocation semantics
Removing a member (or revoking a device) excludes it cryptographically from the next epoch once a remaining member posts the Remove commit — MLS provides forward secrecy and post-compromise security going forward; it does not rewrite the past. Recall (see Messages) is the tool for erasing stored ciphertext.
Backup & recovery
Because NoPeek can never read messages, it can never restore them for you. Recovery is therefore a client-side key-management problem: everything hinges on who can unwrap the E2EE data key. This guide is the source of truth for exactly when messages survive a password change or reset — and when they don't.
The wrapped-data-key model
Each user's history is encrypted under a single symmetric data key. That data key is never sent to the server in the clear; instead the client stores wrapped copies of it — one per unlock method:
| Wrapped by | Unwraps when… | Where it lives |
|---|---|---|
| Password | the user enters their current password | derived on-device (argon2id → HKDF) |
| Recovery key | the user supplies their saved recovery code | shown/saved on-device; a last-4 hint is stored server-side |
| Org escrow (optional) | an admin action, if the workspace enabled it | escrowed per workspace policy |
NoPeek stores the wrapped blobs and the argon2id derivation parameters — never the data key, never a password, never a recovery code. The encrypted archive itself is uploaded via history backups; this guide is about the key that opens it.
Forced first-login password change
Admin-created and admin-reset accounts come back with
mustChangePassword: true. On first login the client forces a change; that
is the moment it generates the user's data key, wraps it under the new password, and
(if configured) prompts the user to save a recovery key. Until this completes the
account has no history of its own to lose.
Saving & emailing the recovery key
The recovery code is generated on-device — it directly wraps the data key. To register it, the client posts the code so NoPeek can store a hint and, if the workspace opted in, email the full code:
POST /v1/apps/{appId}/messenger/email-recovery-key
{ "recoveryCode": "…" }
# → { "ok": true, "emailed": true, "hint": "…4f2a" }
Emailing is escrow. Emailing a recovery key that
can unwrap backups is a deliberate convenience/security trade-off, so it is
opt-in per workspace (email_recovery_key). If your threat model
can't accept a key transiting email, leave it off and have users save the code
manually.
When are messages preserved?
This is the table that matters. "Lossless" means old messages remain readable on this device after the operation; otherwise the data key must be re-obtained.
| Operation | Endpoint | Old messages? |
|---|---|---|
| Change password (knows current password) | POST …/messenger/change-password | Lossless. The client unwraps with the old password and re-wraps under the new one, then re-runs the backup. |
| Forgot password (reset by code) | POST …/messenger/reset-password | Needs the recovery key. A forgotten password can't unwrap the data key. If recoveryRequired is true, the client must supply the saved recovery key to keep history; otherwise it starts fresh. |
| Admin reset | POST …/messenger/users/{userId}/reset-password | Needs the recovery key OR org-escrow. Same as forgot-password from the key's point of view — the admin never had the data key. |
The reset response tells the client what to do next:
POST /v1/apps/{appId}/messenger/reset-password
{ "username": "amina@acme.dev", "code": "A1B2C3", "newPassword": "…" }
# → { "ok": true, "userId": "user_…",
# "recoveryRequired": true, "recoveryKeyHint": "…4f2a" }
When recoveryRequired is true the app prompts for the recovery key
(the recoveryKeyHint helps the user pick the right one); it unwraps the
data key, re-wraps it under the new password, and history is restored. With no
recovery key and no org-escrow, the account keeps working but its prior ciphertext
stays sealed — by design, not by outage.
Why it works this way
Every branch above is a direct consequence of NoPeek holding zero key material. The upside is absolute: a compromise of NoPeek's servers or database yields opaque ciphertext and wrapped blobs, never plaintext and never a usable key. The cost is that account recovery is only as good as the recovery key your users keep (or the escrow your workspace chooses to enable).
Workspaces & communities
Workspaces are the organizations end users join. They gate who can create an account, group conversations for the multi-org inbox, carry their own branding, and hold the roles that decide who can administer them.
Organizations, workspaces, communities
- Org — your NoPeek tenant (billing, apps, keys). One org, many apps.
- App — one product/environment. All users and channels live inside an app and are fully isolated from other apps.
- Workspace — an organization your end users join (the "community root"). An app can host many workspaces; a user can belong to several.
- Community — a sub-grouping inside a workspace (a team or space).
Creating a workspace
Server/org-admin keys create workspaces. Each one is minted with a join code and a referral code, returned in the admin view:
curl -X POST "https://api.nopeekapi.com/v1/apps/app_7f3kq2c9/workspaces" \
-H "Authorization: Bearer npk_live_SERVER_KEY" \
-H "Content-Type: application/json" \
-d '{ "name": "Acme", "allowReferralJoin": true }'
# → { "workspaceId": "ws_…", "name": "Acme", "slug": null, "branding": null,
# "joinCode": "JOIN-…", "referralCode": "REF-…", "allowReferralJoin": true, … }
Listing/fetching reflects the caller: a session token sees only the
workspaces it belongs to, each annotated with myRole; a
server/org-admin key sees every workspace with its codes.
Join & referral codes
| Join code | Referral code | |
|---|---|---|
| Purpose | Admit new users (e.g. printed/shared by an admin) | Member-shareable invite |
| Gate | Always valid | Only when allowReferralJoin is true |
| Redeemed at | POST …/messenger/join (new account) or POST …/messenger/join-workspace (existing user) | |
Compromised a code? Rotate both at once — the old codes stop working immediately:
POST /v1/apps/{appId}/workspaces/{workspaceId}/rotate-codes # admin/owner or server key
Multi-org membership
A user can hold membership in many workspaces at once. New accounts join one
workspace at sign-up; an existing user redeems more codes via
POST …/messenger/join-workspace. Every workspace they belong to feeds the
unified inbox, tagged by workspace so conversations
never blur together.
Members & roles
Membership carries a role: member, admin, or
owner. Admin and owner may manage the workspace (add/remove members,
change roles, rotate codes, create communities); a server key can do all of this
regardless of role.
GET …/workspaces/{workspaceId}/members # list members (+ role)
POST …/workspaces/{workspaceId}/members # add { userId, role }
PATCH …/workspaces/{workspaceId}/members/{userId} # set { role }
DELETE …/workspaces/{workspaceId}/members/{userId} # remove
Role change is a PATCH. Setting a member's role is
PATCH …/members/{userId} with { "role": "admin" } — not a
separate sub-resource. Added users must already exist in the app, or the call returns
NOT_FOUND.
Communities
Communities live inside a workspace and let you carve it into teams or spaces:
POST …/workspaces/{workspaceId}/communities # admin/owner or server key: { name, description? }
GET …/workspaces/{workspaceId}/communities # members list them
Channels reference their communityId, which is surfaced on each inbox
item so clients can group a workspace's conversations by community.
Realtime WebSocket protocol
Realtime traffic runs over a single WebSocket per device. Frames
are JSON objects with a type field. Requests may carry a
reqId; the server echoes it on the matching ack,
auth.ok, error or pong so you can correlate
responses.
Connecting
wss://api.nopeekapi.com/v1/ws?token=SESSION_TOKEN&deviceId=DEVICE_ID
Authenticate via the query string, or connect bare and send an
auth frame as the first message. Unauthenticated sockets are closed
after a short grace period.
Client → server frames
auth
{ "type": "auth", "reqId": "r1",
"token": "nps_live_9tKw…", "deviceId": "dev_5r8s2mv6" }
subscribe / unsubscribe
Opt in/out of realtime events for a channel you are a member of. Answered with
ack.
{ "type": "subscribe", "reqId": "r2", "channelId": "chan_4t9wq1z7" }
{ "type": "unsubscribe", "reqId": "r3", "channelId": "chan_4t9wq1z7" }
message.send
Equivalent to POST …/messages, without an extra HTTP round trip.
E2EE channels: send ciphertext + mlsEpoch. Opted-out
channels: send plaintext. clientMessageId makes the send
idempotent per sender.
{ "type": "message.send", "reqId": "r4",
"channelId": "chan_4t9wq1z7",
"clientMessageId": "01J29ZQ8V2-1",
"mlsEpoch": 42,
"ciphertext": "bWxzLWFwcGxpY2F0aW9uLW1lc3NhZ2U…" }
typing.start / typing.stop
{ "type": "typing.start", "channelId": "chan_4t9wq1z7" }
{ "type": "typing.stop", "channelId": "chan_4t9wq1z7" }
receipt.mark
High-water-mark semantics, same as POST …/receipts.
{ "type": "receipt.mark", "reqId": "r5",
"channelId": "chan_4t9wq1z7", "kind": "read", "messageId": "msg_9k2p4dr2" }
mls.handshake
Submit MLS handshake traffic (same semantics as
POST …/mls/groups/{mlsGroupId}/handshakes, including the single-writer
epoch rule).
{ "type": "mls.handshake", "reqId": "r6",
"mlsGroupId": "mlsg_2v6b8nt3", "epoch": 42, "kind": "commit",
"payload": "bWxzLWhhbmRzaGFrZQ…" }
ping
{ "type": "ping", "reqId": "r7" }
Server → client frames
auth.ok
{ "type": "auth.ok", "reqId": "r1",
"userId": "user_8m2n1xq4", "deviceId": "dev_5r8s2mv6",
"serverTime": "2026-07-06T12:00:00Z" }
ack
Confirms a client request. For message.send it carries the
authoritative envelope (server-assigned seq and timestamps).
{ "type": "ack", "reqId": "r4",
"message": { "messageId": "msg_9k2p4dr2", "channelId": "chan_4t9wq1z7",
"senderUserId": "user_8m2n1xq4", "seq": 129,
"mlsEpoch": 42, "createdAt": "2026-07-06T12:00:01Z" } }
error
Same envelope as REST errors; carries the failed request's reqId
when there is one.
{ "type": "error", "reqId": "r6",
"error": { "code": "STALE_EPOCH",
"message": "Commit targets epoch 42; group is at 43." } }
message.new / message.updated / message.recalled
{ "type": "message.new", "channelId": "chan_4t9wq1z7",
"message": { "messageId": "msg_9k2p4dr2", "senderUserId": "user_3p9dq5w2",
"seq": 130, "mlsEpoch": 42,
"ciphertext": "bWxzLWFwcGxpY2F0aW9uLW1lc3NhZ2U…",
"plaintext": null, "threadParentId": null,
"createdAt": "2026-07-06T12:00:05Z" } }
{ "type": "message.updated", "channelId": "chan_4t9wq1z7",
"message": { "messageId": "msg_9k2p4dr2", "seq": 130,
"ciphertext": "bmV3LWNpcGhlcnRleHQ…",
"editedAt": "2026-07-06T12:02:00Z" } }
{ "type": "message.recalled", "channelId": "chan_4t9wq1z7",
"messageId": "msg_9k2p4dr2", "seq": 130,
"recalledAt": "2026-07-06T12:03:00Z" }
reaction
Opted-out channels only — in E2EE channels reactions travel inside message ciphertext and never appear as their own frame.
{ "type": "reaction", "channelId": "chan_4t9wq1z7", "op": "added",
"reaction": { "reactionId": "rxn_7q1m3z", "messageId": "msg_9k2p4dr2",
"userId": "user_3p9dq5w2", "emoji": "👍",
"createdAt": "2026-07-06T12:04:00Z" } }
typing
{ "type": "typing", "channelId": "chan_4t9wq1z7",
"userId": "user_3p9dq5w2", "isTyping": true }
receipt
{ "type": "receipt", "channelId": "chan_4t9wq1z7",
"userId": "user_3p9dq5w2", "kind": "read", "lastReadSeq": 130 }
presence
{ "type": "presence", "userId": "user_3p9dq5w2",
"status": "online", "lastSeenAt": "2026-07-06T12:05:00Z" }
channel.updated
{ "type": "channel.updated",
"channel": { "channelId": "chan_4t9wq1z7", "name": "Design crew",
"isFrozen": false, "memberCount": 4 } }
member.joined / member.left
{ "type": "member.joined", "channelId": "chan_4t9wq1z7",
"member": { "userId": "user_6w2k9r", "role": "member",
"joinedAt": "2026-07-06T12:06:00Z" } }
{ "type": "member.left", "channelId": "chan_4t9wq1z7",
"userId": "user_6w2k9r" }
mls.handshake
Fan-out of accepted handshake traffic. Commits arrive on every member device; Welcomes only on the listed recipient devices.
{ "type": "mls.handshake",
"mlsGroupId": "mlsg_2v6b8nt3", "epoch": 43, "kind": "commit",
"senderDeviceId": "dev_5r8s2mv6",
"payload": "bWxzLWhhbmRzaGFrZQ…",
"createdAt": "2026-07-06T12:06:01Z" }
pong
{ "type": "pong", "reqId": "r7" }
Reconnecting
After a reconnect, resubscribe and catch up: fetch missed messages with
GET …/messages?afterSeq=<last seen seq> per channel, and missed
MLS traffic with GET …/handshakes?sinceEpoch=<last epoch> per
group, then process realtime frames as they arrive.
Audio & video calls (beta)
Beta. NoPeek supports peer-to-peer audio and video calls. Media never touches NoPeek: it flows directly between participants over WebRTC. NoPeek only relays the tiny signaling frames needed to set a call up.
Strictly peer-to-peer
There are no media servers — no SFU, no MCU, no TURN relay run by NoPeek. Audio and video are negotiated with WebRTC and sent directly device-to-device, encrypted by WebRTC's mandatory DTLS-SRTP. NoPeek's role is limited to passing the SDP offer/answer and ICE candidates between peers; it never sees or stores a media stream. This keeps calls consistent with the platform's core promise: the servers can't read your content.
For NAT traversal, clients use public STUN only (to discover their own reachable address). Because there is no TURN relay, connectivity depends on the two peers being able to reach each other directly once addresses are exchanged.
The signal WebSocket frame
Signaling rides the same authenticated WebSocket as the rest of the SDK (see
the WebSocket protocol). To relay an offer, answer, or
ICE candidate to another user, send a signal frame:
// client → server
{
"type": "signal",
"to": "user_3p9dq5w2", // target user id
"channelId": "chan_4t9wq1z7", // a channel you are BOTH members of
"signal": { "kind": "offer", "sdp": "…" } // opaque to the server
}
The server relays it to every connected device of the target user as:
// server → target user's devices
{
"type": "signal",
"from": "user_8m2n1xq4",
"fromDeviceId": "dev_5r8s2mv6",
"channelId": "chan_4t9wq1z7",
"signal": { "kind": "offer", "sdp": "…" }
}
The signal body is passed through verbatim — the server never parses
your SDP or ICE payloads. Put whatever your WebRTC layer needs inside it (offer,
answer, candidate, hang-up).
Authorization: shared-channel only
A signal frame is relayed only if the sender is currently
joined to the channelId it names. That single rule scopes call
setup to people who already share a conversation and prevents arbitrary fan-out or
using signaling to probe strangers. Frames that reference a channel you haven't joined
are silently dropped.
Group calls: full mesh
Group calls are a mesh: each participant establishes a direct
WebRTC connection to every other participant and sends a signal to each
peer's user id in turn. With no SFU, every participant uploads their media to every
other participant, so practical group size is bounded by each device's uplink — mesh
is great for small groups and degrades as the group grows.
Network limitation & beta caveat
Symmetric NAT. With public STUN and no TURN relay, two peers that are both behind symmetric NATs often cannot form a direct path, and the call will fail to connect. Most home and mobile networks connect fine; some carrier-grade and enterprise NATs do not. A future release may add a relay for these cases.
Calls are in beta: the signal relay and its
shared-channel authorization are stable, but the surrounding call UX, group-scaling
behavior, and connectivity fallbacks may change. Build against the frame contract
above and treat reliability as best-effort for now.
Messages
Messages are stored as envelopes: routing metadata
the server can read (ids, sender, seq, thread parent, MLS epoch,
timestamps) plus content it cannot — ciphertext in E2EE channels,
plaintext only in explicitly opted-out channel types.
Sending
POST /v1/apps/{appId}/channels/{channelId}/messages (or the
WebSocket message.send frame).
- E2EE channels —
ciphertext(an MLS application message) is required andplaintextis rejected. Include themlsEpochthe message was encrypted under. - Opted-out channels —
plaintextis required:{ "type": "text" | "file" | "system", "text": …, "attachments": […], "metadata": {…} }. Sendingciphertextthere is rejected. - A server key can only send into opted-out channels — it holds
no MLS credential. Bots that need to speak in E2EE channels join as ordinary
member devices with
platform: "server".
The server assigns a per-channel, strictly increasing seq, stamps
createdAt, fans out message.new over WebSocket, and
dispatches push notifications with encrypted payloads.
Idempotency via clientMessageId
Every send requires a clientMessageId (≤ 64 chars) that you
generate — a ULID/UUID works well. Retrying a send with the same
clientMessageId returns the original envelope instead of creating a
duplicate. Queue unsent messages locally with their clientMessageId
and retry safely across reconnects and app restarts.
Editing
PATCH …/messages/{messageId} — sender only, within the channel
type's editWindowSeconds. After the window closes the call fails with
EDIT_WINDOW_CLOSED; a non-sender gets NOT_MESSAGE_SENDER.
In E2EE channels you supply new ciphertext, and clients also embed
an edit operation inside the encrypted payload so peers verify the
edit cryptographically — a swapped envelope alone is not trusted. In opted-out
channels you send new plaintext. The envelope gains
editedAt and peers receive message.updated.
Recall (delete for everyone)
DELETE …/messages/{messageId} — the sender may recall within
recallWindowSeconds; an operator or server key may recall at any time
(the moderation path). Recall is deliberately stronger than deletion in most chat
APIs:
- The stored ciphertext/plaintext is erased server-side — not flagged, erased.
- A tombstone (
messageId,seq,recalledAt) remains so offline devices converge on the same history. - Peers receive
message.recalledand remove the local copy.
Threads
Set threadParentId on a send to reply in a thread (channel types
with threads: true). Thread replies get channel-level
seq values like any other message; fetch one thread with
GET …/messages?threadParentId=msg_….
History and seq-based pagination
GET …/messages returns envelopes ordered by seq —
ciphertext envelopes in E2EE channels (clients decrypt locally).
# newest page
GET /v1/apps/{appId}/channels/{channelId}/messages?limit=50
# scroll back
GET …/messages?beforeSeq=130&limit=50
# catch up after reconnect
GET …/messages?afterSeq=130&limit=200
seq is dense per channel, so afterSeq catch-up is
gap-free: if the highest seq you have is 130 and the first message in
the response is 131, you missed nothing.
Webhooks
Webhooks deliver app events to your backend over HTTPS. For E2EE channels, payloads contain envelopes — metadata plus opaque ciphertext — never plaintext. Your servers learn that something happened, not what was said.
Configuring
Set the endpoint and the events you want on the app:
curl -X PATCH "https://api.nopeekapi.com/v1/apps/app_7f3kq2c9" \
-H "Authorization: Bearer npk_live_SERVER_KEY" \
-H "Content-Type: application/json" \
-d '{
"webhookUrl": "https://example.com/hooks/nopeek",
"webhookEvents": ["message.new", "member.joined", "report.created"]
}'
The webhook signing secret is shown in the dashboard when you
configure the endpoint (and rotated there). Setting webhookUrl to
null disables delivery.
Events
| Event | Fires when |
|---|---|
message.new | A message is stored. E2EE: envelope with ciphertext. Opted-out: includes plaintext. |
message.updated | A message is edited. |
message.recalled | A message is recalled; payload is the tombstone. |
reaction.added / reaction.removed | Opted-out channels only — E2EE reactions are inside ciphertext. |
channel.created / channel.updated / channel.deleted | Channel lifecycle. |
member.joined / member.left | Membership changes. |
user.created / user.deleted | User lifecycle. |
report.created | A member files a report (may include reporter-disclosed content — see Moderation). |
moderation.applied | Ban/mute/freeze actions. |
Delivery shape
POST https://example.com/hooks/nopeek
Content-Type: application/json
X-NoPeek-Signature: sha256=6e9c1f0b57…
{
"id": "evt_01J29ZT4M8",
"type": "message.new",
"appId": "app_7f3kq2c9",
"createdAt": "2026-07-06T12:00:05Z",
"data": {
"message": {
"messageId": "msg_9k2p4dr2",
"channelId": "chan_4t9wq1z7",
"senderUserId": "user_8m2n1xq4",
"seq": 130,
"mlsEpoch": 42,
"ciphertext": "bWxzLWFwcGxpY2F0aW9uLW1lc3NhZ2U…",
"plaintext": null,
"createdAt": "2026-07-06T12:00:05Z"
}
}
}
Verifying signatures
Every delivery is signed: X-NoPeek-Signature: sha256=<hex>,
where <hex> is the HMAC-SHA256 of the raw request
body keyed with your webhook secret. Always verify before trusting a
payload — and compute the HMAC over the raw bytes, not a re-serialized object.
import { createHmac, timingSafeEqual } from 'node:crypto';
function verify(rawBody, signatureHeader, secret) {
const expected = 'sha256=' +
createHmac('sha256', secret).update(rawBody).digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(signatureHeader ?? '');
return a.length === b.length && timingSafeEqual(a, b);
}
Retries
A delivery counts as successful on any 2xx response within 10 seconds. Anything
else is retried with exponential backoff, up to 6 attempts total
(roughly 30 s, 2 m, 10 m, 40 m and 2.5 h after the first
attempt). Deliveries can arrive out of order and, rarely, more than once — treat
handlers as idempotent and use the event id for de-duplication.
Respond 2xx quickly and process asynchronously.
Encrypted history sync
E2EE has a hard problem: a brand-new device has no keys, so it can decrypt nothing that happened before it joined. NoPeek solves new-device history with encrypted history backups — client-keyed archives that NoPeek stores but can never open.
The easy way: SDK backup & restore
The SDK wraps the whole flow in two calls, so restore is a WhatsApp-style experience (one recovery code — no per-key management). NoPeek never sees the code or the derived key.
import { NoPeek, generateRecoveryCode } from "@nopeek/chat";
// --- on the user's current device: turn on backup ---
const code = generateRecoveryCode(); // e.g. "K7QP-3M9X-8ATV-2WShow"
showToUserOnce(code); // your UI stores/shows it once
await client.backup(code); // encrypts channel keys, uploads ciphertext
// --- on a NEW device: restore everything ---
const client = await NoPeek.connect({ apiUrl, sessionToken, appId, userId });
if (await client.hasBackup()) {
const channels = await client.restore(userEnteredCode); // rehydrates keys
// now channel.history() decrypts the user's past messages
}
Under the hood backup() derives an AES-256-GCM key from the
recovery code (PBKDF2-SHA-256, 210k iterations — no argon2 wasm needed),
encrypts the user's channel keys + device identity, and uploads only ciphertext.
restore() re-derives the key from the code, downloads the latest
backup, and rehydrates the keys so server-stored history decrypts. A wrong code
simply fails to decrypt — nothing leaks.
The backup key: argon2id-hkdf-v1
The user holds a recovery code (generated on-device, shown once, never sent to NoPeek). The backup key is derived entirely on-device:
stretched = argon2id(recoveryCode, salt, memoryKib, iterations)
backupKey = HKDF-SHA256(stretched, info = "nopeek-history-v1")
NoPeek stores only the derivation parameters — scheme
(argon2id-hkdf-v1), salt, memory and iteration counts — so another
device holding the recovery code can re-derive the same key.
NoPeek never sees the recovery code or any key material. Lose the
recovery code and the backup is unrecoverable by anyone, including us.
Creating a backup (client)
The device serializes decrypted history plus current group state, encrypts the archive with the backup key, then registers it:
curl -X POST "https://api.nopeekapi.com/v1/apps/app_7f3kq2c9/users/user_8m2n1xq4/history-backups" \
-H "Authorization: Bearer nps_SESSION_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"keyDerivation": {
"scheme": "argon2id-hkdf-v1",
"salt": "c2FsdC1ieXRlcw==",
"memoryKib": 65536,
"iterations": 3
},
"sizeBytes": 1048576,
"manifestHash": "sha256:9f2b…"
}'
The response contains the backup record and a pre-signed
uploadUrl; the client PUTs the encrypted archive there
directly. manifestHash lets a restoring device verify integrity after
download.
Restoring on a new device (client)
GET …/history-backups— newest first, each with a pre-signeddownloadUrland itskeyDerivationparameters.- Ask the user for the recovery code; re-derive the backup key on-device.
- Download, verify
manifestHash, decrypt, import history. - Register the device and rejoin live E2EE groups the normal MLS way (the backup restores the past; MLS Welcomes cover the future).
The @nopeek/chat SDK wraps this whole flow, including scheduled
incremental backups.
Notes
- Backups are per-user; any of the user's devices may create them.
- Pre-signed URLs are short-lived; re-list to get fresh ones.
- Attachments referenced in history remain encrypted blobs — their content keys travel inside message ciphertext and therefore inside the archive.
Moderation & HIPAA
You can moderate what NoPeek cannot read. Moderation acts on metadata and membership — who may speak, where, and when — while a reporter-disclosure flow handles content review in E2EE channels without breaking encryption.
Freeze, ban, mute
One endpoint, six actions, callable with a server key or by channel operators:
POST /v1/apps/{appId}/channels/{channelId}/moderation/{action}
# action ∈ ban | unban | mute | unmute | freeze | unfreeze
- freeze / unfreeze — channel-wide read-only mode. Sends into a
frozen channel fail with
CHANNEL_FROZEN. - mute / unmute — requires
userId; optionallydurationSeconds. A muted member can read but sends fail withUSER_MUTED. - ban / unban — requires
userId; optionallydurationSeconds. Removes the member; sends and rejoins fail withUSER_BANNED. In E2EE channels a remaining member device follows up with an MLS Remove commit so the banned device is excluded from the next epoch.
curl -X POST "https://api.nopeekapi.com/v1/apps/app_7f3kq2c9/channels/chan_4t9wq1z7/moderation/mute" \
-H "Authorization: Bearer npk_live_SERVER_KEY" \
-H "Content-Type: application/json" \
-d '{ "userId": "user_3p9dq5w2", "durationSeconds": 3600 }'
Operators and server keys can also recall any message at any time
(DELETE …/messages/{messageId} — server-side ciphertext erase plus
tombstone).
Reporting in E2EE channels: reporter-disclosed content
NoPeek cannot show you the plaintext of an E2EE message — but the reporter can. That is the entire E2EE moderation model: a member who can already read a message may voluntarily disclose it when reporting.
POST /v1/apps/{appId}/reports # session token (client)
{
"channelId": "chan_4t9wq1z7",
"messageId": "msg_9k2p4dr2",
"reportedUserId": "user_3p9dq5w2",
"category": "harassment",
"disclosedContent": "the decrypted text, disclosed by the reporter"
}
The reporting device decrypts the offending message locally and includes the
plaintext in disclosedContent. Encryption is never broken — disclosure
is a member exercising their own right to content they can already read. The report
fires a report.created webhook; your trust-and-safety tooling lists and
resolves reports with GET /v1/apps/{appId}/reports?status=open (server
key) and acts via the moderation endpoints above.
HIPAA posture
NoPeek is built for regulated messaging (telehealth, care coordination, patient support):
- Ciphertext-only storage — in E2EE channels, message content, attachments and reactions are stored encrypted with keys NoPeek never holds. PHI in message content is unreadable to NoPeek and to anyone who compromises NoPeek.
- BAA — a Business Associate Agreement is available on the
$499/mo plan; your signed BAA status appears on the org record
(
hipaaBaaSignedAt). - Audit logging — administrative and moderation actions (key usage, membership changes, moderation, exports, report handling) are recorded in an audit log available in the dashboard.
- Keep the metadata NoPeek can see out of PHI scope: don't put clinical information in user nicknames, channel names or metadata fields.
Audit & compliance
NoPeek keeps a HIPAA-grade, tamper-evident audit trail of every security-relevant event — authentication, authorization decisions, admin actions, and key/config/backup operations — while never logging a single byte of message content.
What is (and isn't) logged
The audit log records metadata about access: who did what, to what, when, from where, and whether it succeeded. It never records PHI or message bodies — those are E2EE and never reach the server in the clear in the first place. A typical entry:
{
"auditId": "aud_…", "seq": 10432,
"orgId": "org_…", "appId": "app_…", "workspaceId": null,
"actorType": "app_server", "actorId": null,
"action": "admin.user.create",
"targetType": "user", "targetId": "user_…",
"outcome": "success",
"ip": "203.0.113.7", "userAgent": "…",
"metadata": { "username": "amina@acme.dev" },
"hash": "9f2b…", "createdAt": "2026-07-06T18:00:00Z"
}
Actions include auth.login.success, auth.login.failure,
auth.password.change, auth.password.reset_requested,
auth.password.reset_failed, admin.user.create,
admin.user.password_reset, and more. Outcomes are
success, failure, or denied.
The tamper-evident hash chain
Every row carries a SHA-256 hash computed over the previous
row's hash plus this row's canonical content (ids, actor, action, target, outcome,
scope, timestamp, metadata). The rows therefore form an append-only chain: modifying,
deleting, or re-ordering any entry breaks every hash after it, and the break is
detectable. Writes are serialized so the chain is deterministic, and logging is
best-effort — an audit failure never blocks the originating request.
Verifying integrity
Superadmins can recompute and verify the chain at any time, optionally for a single app:
GET /v1/admin/audit/verify
# → { "intact": true, "verifiedEntries": 10432 }
GET /v1/admin/audit/verify?appId=app_7f3kq2c9
# → { "intact": false, "brokenAt": "aud_…" } # first broken link
An intact: true result is a cryptographic attestation that the trail
has not been altered since it was written.
Org trail vs. superadmin trail
| Org-scoped | Platform (superadmin) | |
|---|---|---|
| Endpoint | GET /v1/apps/{appId}/audit-logs | GET /v1/admin/audit |
| Sees | Only that app's events | Every org's events |
| Auth | Org admin (or superadmin) | Superadmin only |
| Filters | action, actorId, before, limit | + appId, orgId |
Both return entries newest-first; page backward with before=<seq>.
The chain verification runs across the whole log (or one app), independent of these
filtered reads.
Usage & metrics for compliance reporting
Alongside the trail, orgs can pull their own usage against plan limits
(GET /v1/orgs/usage), and superadmins can pull any org's usage
(GET /v1/admin/orgs/{orgId}/usage) or platform-wide metrics
(GET /v1/admin/metrics) — including a 24h authentication-failure counter
derived from the audit log.
Security headers
The API is JSON-only and ships a strict, no-content-embedding header posture on every response as part of the HIPAA hardening:
x-content-type-options: nosniff
x-frame-options: DENY
referrer-policy: no-referrer
strict-transport-security: max-age=63072000; includeSubDomains; preload
content-security-policy: default-src 'none'; frame-ancestors 'none'
permissions-policy: geolocation=(), microphone=(), camera=()
Server-identifying headers (x-powered-by, server) are
stripped. Combined with E2EE content and the audit chain, the posture is designed so
that neither a network observer nor a database compromise yields readable PHI.
MCP server
The NoPeek MCP server (@nopeek/mcp) lets an AI agent — Claude
Desktop, Claude Code, or any Model Context
Protocol client — provision and operate NoPeek infrastructure with a single API
key. It speaks MCP over stdio and wraps the NoPeek REST API.
What it is
One binary that exposes NoPeek as MCP tools: create orgs and apps, issue keys, and
manage users, workspaces, channels and messages — all from natural-language prompts.
It never sees plaintext of E2EE channels; on those, sending a message means passing
opaque ciphertext, exactly like any other client.
Install
# From npm
npx @nopeek/mcp
# Or from the monorepo
pnpm install
pnpm --filter @nopeek/mcp build
node packages/mcp-server/dist/index.js
Configuration
Configured entirely through environment variables:
| Variable | Required | Description |
|---|---|---|
NOPEEK_API_KEY | yes* | Bearer token. An org key manages apps and keys; an app server key manages users, workspaces, channels and messages within one app. Both share the npk_ prefix — scope is set by the key's role, not its prefix. |
NOPEEK_API_URL | no | API base URL. Defaults to the production CloudFront host. |
* create_org and get_app_config are public and work with no
key; every other tool returns UNAUTHENTICATED without one. Typical
bootstrap: run create_org with no key, save the returned
apiKey.secret, then restart with it set as NOPEEK_API_KEY.
Claude Desktop setup
Add an entry to claude_desktop_config.json (macOS:
~/Library/Application Support/Claude/claude_desktop_config.json), then
restart Claude Desktop — the NoPeek tools appear in the tool picker:
{
"mcpServers": {
"nopeek": {
"command": "npx",
"args": ["@nopeek/mcp"],
"env": {
"NOPEEK_API_KEY": "npk_live_your_key_here"
}
}
}
}
The 16 tools
| Tool | Key scope | Description |
|---|---|---|
create_org | none (public) | Create an org; returns a new org-admin key secret. |
create_app | org | Create an app (+ default channel types + a server key). |
list_apps | org | List apps in the org. |
get_app | org / server | Fetch one app. |
issue_api_key | org | Mint a new app server key. |
create_user | server | Create/upsert a core-API user (by externalId). |
list_users | server | List app users (paginated). |
create_workspace | server | Create a workspace (returns join/referral codes). |
list_workspaces | server | List workspaces. |
add_workspace_member | server | Add an app user to a workspace. |
create_channel | server | Create a channel of a channel type (memberIds for members). |
list_channels | server | List channels. |
send_message | server | Send a message as a user (via actAs); E2EE needs ciphertext. |
list_messages | server | List channel messages. |
get_audit_log | org / server | Fetch the app audit trail. |
get_app_config | none (public) | Force-update policy, session settings, server time. |
Notes on the message model
- Default channel types (
direct,group,broadcast) are E2EE. On an E2EE channelsend_messagerequiresciphertext(opaque to the server); non-E2EE channels requireplaintext. - With a server key the sender is set via the
actAsparameter, so thesenderUserIdmust already be a member of the channel. send_messageis idempotent onclientMessageId(auto-generated if omitted).
Example prompts
- "Create a NoPeek org called Acme, then create a
liveapp named Support, and show me the server key." - "In app
app_…, create two users (external idsaliceandbob), create agroupchannel named General with both as members, and make alice an operator." - "Create a workspace named Engineering in app
app_…, add useruser_…as an admin, and list the workspace members."
Errors & authentication
Every request authenticates with
Authorization: Bearer <secret>. Every failure returns one JSON
envelope. Learn both once, use them everywhere.
Three credential classes
| Credential | Format | Lives | Use for |
|---|---|---|---|
| Org admin key | npk_live_… / npk_test_… (role org_admin) |
Dashboard / org tooling only | Org and app management, minting server keys. Never on a server that handles chat traffic, never in a client. |
| App server key | npk_live_… / npk_test_… (role app_server) |
Your backend | User provisioning, sessions, channel admin, moderation, webhooks, exports. |
| Session token | nps_…, short-lived (default 24 h) |
End-user clients | All client endpoints and the WebSocket. Minted by your backend via POST …/users/{userId}/sessions. |
Key secrets are returned exactly once at creation; only prefixes are listable
afterwards. Apps are live or test — using a test key
against a live app (or vice versa) fails with WRONG_ENVIRONMENT.
The error envelope
HTTP/1.1 409 Conflict
Content-Type: application/json
{
"error": {
"code": "STALE_EPOCH",
"message": "Commit targets epoch 42; group is at 43."
}
}
Branch on error.code (stable), not on message
(human-readable, may change).
Error codes
| Code | HTTP | Meaning |
|---|---|---|
UNAUTHENTICATED | 401 | Missing or malformed Authorization header. |
INVALID_API_KEY | 401 | Key unknown or revoked. |
SESSION_EXPIRED | 401 | Session token past expiresAt — mint a new one server-side. |
DEVICE_REVOKED | 401 | This device was revoked; re-registration required. |
FORBIDDEN | 403 | Credential class lacks rights for this operation. |
WRONG_ENVIRONMENT | 403 | Test key against a live app, or vice versa. |
NOT_A_MEMBER | 403 | Caller is not a member of the channel. |
CHANNEL_FROZEN | 403 | Channel is frozen; sends rejected. |
USER_MUTED | 403 | Sender is muted in this channel. |
USER_BANNED | 403 | Sender is banned from this channel. |
NOT_MESSAGE_SENDER | 403 | Only the sender may edit (or recall inside the sender window). |
NOT_FOUND | 404 | Resource does not exist (or is invisible to this credential). |
INVALID_REQUEST | 400 | Body or parameters failed validation. |
E2EE_REQUIRED | 400 | Plaintext features used on an E2EE channel (e.g. plaintext body, REST reactions). |
PAYLOAD_TOO_LARGE | 413 | Body or attachment exceeds limits. |
ALREADY_EXISTS | 409 | Unique constraint hit (e.g. duplicate channel-type key). |
CONFLICT | 409 | Generic state conflict. |
EDIT_WINDOW_CLOSED | 409 | Past the channel type's editWindowSeconds. |
RECALL_WINDOW_CLOSED | 409 | Past recallWindowSeconds (operators/server keys are exempt). |
NO_KEY_PACKAGES | 409 | A target device's key-package pool is empty; cannot add to E2EE channel. |
STALE_EPOCH | 409 | MLS commit lost the single-writer race — catch up with sinceEpoch and retry. |
RATE_LIMITED | 429 | Too many requests; honor Retry-After. |
QUOTA_EXCEEDED | 429 | Plan quota reached (MAU, storage, or message volume). |
INTERNAL | 500 | NoPeek-side failure; safe to retry with backoff. |
Retry guidance
- Retry with backoff:
RATE_LIMITED,INTERNAL, network failures. Message sends are safe to retry thanks toclientMessageIdidempotency. - Retry after catching up:
STALE_EPOCH— fetch the handshake backlog, merge, re-commit at the new epoch. - Don't retry unchanged: 4xx validation and permission errors — fix the request or the credential.
