NoPeek owl logoNoPeek Docs

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.

Examples use 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() called POST …/devices with the device's Ed25519 credential key, then published single-use MLS key packages via POST …/devices/{deviceId}/key-packages.
  • channels.create() called POST …/channels; because direct is 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 a seq, 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 cloneAPI / 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/join with a workspace code plus a username and password creates the account and drops the user into that workspace as a member. Referral codes only work when the workspace has allowReferralJoin set.
  • 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 as initialPassword, and the account is flagged mustChangePassword.
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 idsMessage text
TimestampsAttachments (encrypted client-side before upload)
Message seq (ordering)Reactions (carried inside ciphertext)
MLS epoch numbersLink previews (rendered on-device)
Channel membershipGIFs 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 byUnwraps when…Where it lives
Passwordthe user enters their current passwordderived on-device (argon2id → HKDF)
Recovery keythe user supplies their saved recovery codeshown/saved on-device; a last-4 hint is stored server-side
Org escrow (optional)an admin action, if the workspace enabled itescrowed 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.

OperationEndpointOld messages?
Change password (knows current password)POST …/messenger/change-passwordLossless. 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-passwordNeeds 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 resetPOST …/messenger/users/{userId}/reset-passwordNeeds 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 codeReferral code
PurposeAdmit new users (e.g. printed/shared by an admin)Member-shareable invite
GateAlways validOnly when allowReferralJoin is true
Redeemed atPOST …/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 channelsciphertext (an MLS application message) is required and plaintext is rejected. Include the mlsEpoch the message was encrypted under.
  • Opted-out channelsplaintext is required: { "type": "text" | "file" | "system", "text": …, "attachments": […], "metadata": {…} }. Sending ciphertext there 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.recalled and 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

EventFires when
message.newA message is stored. E2EE: envelope with ciphertext. Opted-out: includes plaintext.
message.updatedA message is edited.
message.recalledA message is recalled; payload is the tombstone.
reaction.added / reaction.removedOpted-out channels only — E2EE reactions are inside ciphertext.
channel.created / channel.updated / channel.deletedChannel lifecycle.
member.joined / member.leftMembership changes.
user.created / user.deletedUser lifecycle.
report.createdA member files a report (may include reporter-disclosed content — see Moderation).
moderation.appliedBan/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)

  1. GET …/history-backups — newest first, each with a pre-signed downloadUrl and its keyDerivation parameters.
  2. Ask the user for the recovery code; re-derive the backup key on-device.
  3. Download, verify manifestHash, decrypt, import history.
  4. 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; optionally durationSeconds. A muted member can read but sends fail with USER_MUTED.
  • ban / unban — requires userId; optionally durationSeconds. Removes the member; sends and rejoins fail with USER_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-scopedPlatform (superadmin)
EndpointGET /v1/apps/{appId}/audit-logsGET /v1/admin/audit
SeesOnly that app's eventsEvery org's events
AuthOrg admin (or superadmin)Superadmin only
Filtersaction, 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:

VariableRequiredDescription
NOPEEK_API_KEYyes*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_URLnoAPI 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

ToolKey scopeDescription
create_orgnone (public)Create an org; returns a new org-admin key secret.
create_apporgCreate an app (+ default channel types + a server key).
list_appsorgList apps in the org.
get_apporg / serverFetch one app.
issue_api_keyorgMint a new app server key.
create_userserverCreate/upsert a core-API user (by externalId).
list_usersserverList app users (paginated).
create_workspaceserverCreate a workspace (returns join/referral codes).
list_workspacesserverList workspaces.
add_workspace_memberserverAdd an app user to a workspace.
create_channelserverCreate a channel of a channel type (memberIds for members).
list_channelsserverList channels.
send_messageserverSend a message as a user (via actAs); E2EE needs ciphertext.
list_messagesserverList channel messages.
get_audit_logorg / serverFetch the app audit trail.
get_app_confignone (public)Force-update policy, session settings, server time.

Notes on the message model

  • Default channel types (direct, group, broadcast) are E2EE. On an E2EE channel send_message requires ciphertext (opaque to the server); non-E2EE channels require plaintext.
  • With a server key the sender is set via the actAs parameter, so the senderUserId must already be a member of the channel.
  • send_message is idempotent on clientMessageId (auto-generated if omitted).

Example prompts

  • "Create a NoPeek org called Acme, then create a live app named Support, and show me the server key."
  • "In app app_…, create two users (external ids alice and bob), create a group channel named General with both as members, and make alice an operator."
  • "Create a workspace named Engineering in app app_…, add user user_… 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

CredentialFormatLivesUse 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

CodeHTTPMeaning
UNAUTHENTICATED401Missing or malformed Authorization header.
INVALID_API_KEY401Key unknown or revoked.
SESSION_EXPIRED401Session token past expiresAt — mint a new one server-side.
DEVICE_REVOKED401This device was revoked; re-registration required.
FORBIDDEN403Credential class lacks rights for this operation.
WRONG_ENVIRONMENT403Test key against a live app, or vice versa.
NOT_A_MEMBER403Caller is not a member of the channel.
CHANNEL_FROZEN403Channel is frozen; sends rejected.
USER_MUTED403Sender is muted in this channel.
USER_BANNED403Sender is banned from this channel.
NOT_MESSAGE_SENDER403Only the sender may edit (or recall inside the sender window).
NOT_FOUND404Resource does not exist (or is invisible to this credential).
INVALID_REQUEST400Body or parameters failed validation.
E2EE_REQUIRED400Plaintext features used on an E2EE channel (e.g. plaintext body, REST reactions).
PAYLOAD_TOO_LARGE413Body or attachment exceeds limits.
ALREADY_EXISTS409Unique constraint hit (e.g. duplicate channel-type key).
CONFLICT409Generic state conflict.
EDIT_WINDOW_CLOSED409Past the channel type's editWindowSeconds.
RECALL_WINDOW_CLOSED409Past recallWindowSeconds (operators/server keys are exempt).
NO_KEY_PACKAGES409A target device's key-package pool is empty; cannot add to E2EE channel.
STALE_EPOCH409MLS commit lost the single-writer race — catch up with sinceEpoch and retry.
RATE_LIMITED429Too many requests; honor Retry-After.
QUOTA_EXCEEDED429Plan quota reached (MAU, storage, or message volume).
INTERNAL500NoPeek-side failure; safe to retry with backoff.

Retry guidance

  • Retry with backoff: RATE_LIMITED, INTERNAL, network failures. Message sends are safe to retry thanks to clientMessageId idempotency.
  • 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.