Worked examples for every feature. See the protocol reference for the full message catalog these build on.
The relay never decrypts anything - encryption is entirely the client's job. The convention used by this relay's own Python client and all demos on this site: X25519 for key exchange, NaCl Box (XSalsa20-Poly1305 under the hood) for the actual encryption, one keypair per session, trust-on-first-use for pinning a peer's key.
# Python (pip install pynacl)
from nacl.public import PrivateKey, PublicKey, Box
from nacl.utils import random as nacl_random
import base64, json
my_sk = PrivateKey.generate()
my_pk = my_sk.public_key
# --- first contact: announce your pubkey, empty ciphertext ---
send({"t": "send", "to": "bob",
"pub": base64.b64encode(bytes(my_pk)).decode(),
"nonce": base64.b64encode(nacl_random(24)).decode(),
"ct": ""})
# --- on receiving bob's pubkey (from his own first-contact ping) ---
bob_pk = PublicKey(base64.b64decode(incoming["pub"]))
box = Box(my_sk, bob_pk)
# --- encrypt and send a real message ---
nonce = nacl_random(24)
ct = box.encrypt(b"hello bob", nonce).ciphertext
send({"t": "send", "to": "bob",
"pub": base64.b64encode(bytes(my_pk)).decode(),
"nonce": base64.b64encode(nonce).decode(),
"ct": base64.b64encode(ct).decode()})
# --- decrypt an incoming deliver frame ---
plaintext = box.decrypt(base64.b64decode(msg["ct"]), base64.b64decode(msg["nonce"]))
// Browser (any WebCrypto/libsodium.js/tweetnacl-js binding works the same way)
// using libsodium.js' sodium.crypto_box_* primitives (X25519 + XSalsa20-Poly1305,
// the libsodium equivalent of NaCl Box):
const kp = sodium.crypto_box_keypair();
ws.send(JSON.stringify({
t: "send", to: "bob",
pub: sodium.to_base64(kp.publicKey),
nonce: sodium.to_base64(sodium.randombytes_buf(24)),
ct: "",
}));
// on deliver, with the peer's pubkey pinned from their first-contact ping:
const plaintext = sodium.crypto_box_open_easy(
sodium.from_base64(msg.ct), sodium.from_base64(msg.nonce),
peerPubKey, kp.privateKey
);
// sender
const id = "msg_" + Date.now();
ws.send(JSON.stringify({ t: "send", to: "bob", ct: "are you there?", id }));
// relay replies either nothing (delivered live) or {"t":"queued","to":"bob"}
ws.onmessage = (ev) => {
const m = JSON.parse(ev.data);
if (m.t === "ack" && m.id === id) console.log("bob has it now");
};
// recipient, on receiving a deliver frame with an id:
ws.onmessage = (ev) => {
const m = JSON.parse(ev.data);
if (m.t === "deliver" && m.id) {
ws.send(JSON.stringify({ t: "ack", to: m.from, id: m.id }));
}
};
// on (re)connect: registered.queued tells you how many backlog messages
// were just flushed as deliver frames immediately before the registered ack.
ws.send(JSON.stringify({ t: "find_match", game: "chess" }));
ws.onmessage = (ev) => {
const m = JSON.parse(ev.data);
if (m.t === "waiting") console.log("queued, waiting for an opponent");
if (m.t === "matched") console.log("paired with", m.peer); // now ws.send(..., to: m.peer, ...)
};
ws.send(JSON.stringify({ t: "join_room", room: "lobby" }));
ws.onmessage = (ev) => {
const m = JSON.parse(ev.data);
if (m.t === "joined_room") console.log(m.members, "people here");
if (m.t === "room_deliver") console.log(m.from, ":", m.ct);
};
ws.send(JSON.stringify({ t: "room_send", room: "lobby", ct: "hello everyone" }));
// subscriber
ws.send(JSON.stringify({ t: "subscribe", topic: "sensors/+/temperature" }));
ws.onmessage = (ev) => {
const m = JSON.parse(ev.data);
if (m.t === "message") console.log(m.topic, "=", m.ct, m.retained ? "(retained)" : "(live)");
};
// publisher - retained means late subscribers still get the current value
ws.send(JSON.stringify({
t: "publish", topic: "sensors/kitchen/temperature", ct: "21.4", retain: true
}));
// clearing a retained value: publish empty ct with retain still true
ws.send(JSON.stringify({ t: "publish", topic: "sensors/kitchen/temperature", ct: "", retain: true }));
addr = base32(sha256(pubkey))[:16]
// Browser - derive and register with no human-readable alias
const kp = sodium.crypto_box_keypair();
const pubB64 = sodium.to_base64(kp.publicKey);
ws.send(JSON.stringify({ t: "register", pubkey: pubB64 }));
// registered.addr is now base32(sha256(pubkey))[:16] - verify it yourself:
// deriving it client-side with WebCrypto:
async function deriveId(pubKeyBytes) {
const digest = await crypto.subtle.digest("SHA-256", pubKeyBytes);
const b32 = base32Encode(new Uint8Array(digest)); // any base32 lib
return b32.toLowerCase().slice(0, 16);
}
// with a human-readable alias bound to the same key:
ws.send(JSON.stringify({ t: "register", addr: "alice", pubkey: pubB64 }));
// first registration of "alice" binds it to this key's hash for this relay
// process's lifetime; a different key registering "alice" later gets:
// {"t":"error","msg":"alias bound to a different key"}
The relay has no idea what's inside ct - so SDP offers/answers and ICE candidates are just another payload, sent the same way as any chat message. Actual media never touches this server for a direct connection; see the screen share demo for the complete version.
A TURN server is available for strict/symmetric NAT pairs that STUN alone can't connect. Add it to iceServers alongside the STUN entry. The media/data itself stays DTLS-encrypted end-to-end regardless of whether TURN is in the path, so this doesn't weaken the "relay never sees your payload" property - a TURN relay forwards encrypted packets it can't read, same as this project's own relay forwards ct it can't decrypt.
There's no permanent TURN password sitting in any page's source - a turn_credentials request mints one fresh, valid for 5 minutes. Ask for one right after registering, before you need it:
ws.send(JSON.stringify({ t: "turn_credentials" }));
// response: {"t":"turn_credentials","urls":[...],"username":"","credential":"","ttl":300}
const pc = new RTCPeerConnection({
iceServers: [
{ urls: "stun:stun.l.google.com:19302" },
{ urls: resp.urls, username: resp.username, credential: resp.credential },
],
});
pc.onicecandidate = (ev) => {
if (ev.candidate) ws.send(JSON.stringify({ t: "send", to: peer, ct: JSON.stringify({ kind: "ice", candidate: ev.candidate }) }));
};
// caller
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
ws.send(JSON.stringify({ t: "send", to: peer, ct: JSON.stringify({ kind: "offer", sdp: offer }) }));
// callee, on receiving the offer via a deliver frame
const sig = JSON.parse(deliverMsg.ct);
if (sig.kind === "offer") {
await pc.setRemoteDescription(sig.sdp);
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
ws.send(JSON.stringify({ t: "send", to: peer, ct: JSON.stringify({ kind: "answer", sdp: answer }) }));
}
A relay-wide count of currently registered addresses, nothing more - there's deliberately no way to list who they are, only how many. Room membership counts (broadcast to a room's joined_room.members) already give you a scoped version of this where knowing who's in your own room is meaningful; this is the unscoped, anonymous version for "is anyone else here at all."
ws.send(JSON.stringify({ t: "list_online" }));
ws.onmessage = (ev) => {
const m = JSON.parse(ev.data);
if (m.t === "online_count") console.log(m.count, "addresses registered right now");
};
// poll on an interval if you want a live counter - see /chat.html for a
// working example (it polls every 5s alongside its own room count)
Every other message type above is JSON, rate-limited per-message, and (for send) store-and-forward tolerant of the recipient being offline. A pipe is the opposite tradeoff: both ends must be connected right now, there's no queuing, and - the actual point of it - no app-level ack/retry layer on top, because it leans entirely on the underlying transport's own guarantee of in-order, lossless delivery (WebSocket and TCP already provide that; there's nothing left to reimplement). It's the thing to reach for whenever you're moving a real stream of bytes - a file, a tunneled protocol, anything - rather than discrete chat-sized messages.
// opener
ws.send(JSON.stringify({ t: "open_pipe", to: "bob" }));
ws.onmessage = (ev) => {
const m = JSON.parse(ev.data);
if (m.t === "pipe_open") pipeId = m.pipe_id; // your own open_pipe succeeded
if (m.t === "pipe_data") handleBytes(m); // bob sent you something
if (m.t === "pipe_closed") console.log("bob closed it, or disconnected");
};
// bob's side - never called open_pipe himself, just reacts to the push
ws.onmessage = (ev) => {
const m = JSON.parse(ev.data);
if (m.t === "pipe_incoming") pipeId = m.pipe_id; // alice opened one to you
};
// either side, once pipeId is known - whatever fields you put in here
// (besides t/pipe_id) are forwarded to the other side verbatim:
ws.send(JSON.stringify({ t: "pipe_data", pipe_id: pipeId, kind: "chunk", b64: "..." }));
// tear down when done
ws.send(JSON.stringify({ t: "close_pipe", pipe_id: pipeId }));
Both file drop and screen share use Pipe as their main fallback when a direct WebRTC connection can't be established - see the demos catalog for how each one layers it in. A pipe is capped at 2000 open relay-wide / 20 per address, so it's bounded the same way everything else here is - just without an app-level retry loop on top of a transport that's already reliable.
UDP has no connection and no TLS, so every message after register must carry the session token the relay handed back, proving you're the one who registered that address:
register: {"t":"register","addr":"carol"}
reply: {"t":"registered","addr":"carol","token":"9f2a...","queued":0}
send: {"t":"send","from_addr":"carol","token":"9f2a...","to":"bob","ct":"..."}
ack: {"t":"ack","from_addr":"carol","token":"9f2a...","to":"bob","id":"..."}
subscribe: {"t":"subscribe","from_addr":"carol","token":"9f2a...","topic":"home/+/temp"}
publish: {"t":"publish","from_addr":"carol","token":"9f2a...","topic":"home/kitchen/temp","ct":"...","retain":true}
A datagram with a missing or wrong token is silently dropped - no error is sent back, since that would itself be a spoofable amplification vector.
Every demo on this site is a browser page, but the protocol itself doesn't care - secure_relay_client.py is a second, independent reference implementation of the E2E recipe above, in Python instead of JavaScript, talking to this same production relay over TLS. A thin REPL wrapper, chat_cli.py, turns it into a real terminal chat client:
pip install pynacl websockets # save both files in the same directory, then: python chat_cli.py relay.nowwx7.work 8444 alice bob # in another terminal/machine: python chat_cli.py relay.nowwx7.work 8444 bob alice
Both sides generate an X25519 keypair on startup, exchange public keys on first contact exactly like the whisper demo, and from then on every line you type is NaCl-Box-encrypted before it leaves your machine. A complete, minimal implementation of the whole registration → key exchange → encrypted send/receive loop in under 150 lines, with no browser APIs or build step involved.