Skip to main content

Presence & Metadata

Two of the JWT claims look the same but behave very differently. This page is the canonical guide to which one to use where.

metadata vs peerMetadata — at a glance

ClaimWhere it goesWho sees itUse it for
metadatawelcome.metadata (delivered once at connect)Only the connecting peerTURN ICE servers, server-issued user-private config, one-time bootstrap data
peerMetadataStamped on presence (always), direct (always), message (opt-in via subscribe)Other peers in the same channel / direct conversationuserId, username, profilePic, role tags — anything OTHER peers should see

Rule of thumb: if it's only for the connecting user → metadata. If other peers need to see it → peerMetadata.

metadata — connection-private welcome bag

The metadata JWT claim is delivered ONCE in the welcome message and never reaches any other peer. The server does not inspect its shape; it's an opaque pass-through.

Typical use: TURN ICE servers for WebRTC

JWT claim — your backend signs this
{
"sub": "alice",
"exp": 1715539200,
"channels": ["call-xyz"],
"permissions": ["publish", "subscribe", "presence", "send"],
"metadata": {
"iceServers": [
{ "urls": ["stun:stun.relay.metered.ca:80"] },
{ "urls": ["turn:global.relay.metered.ca:80"], "username": "u", "credential": "c" },
{ "urls": ["turn:global.relay.metered.ca:443"], "username": "u", "credential": "c" },
{ "urls": ["turns:global.relay.metered.ca:443"], "username": "u", "credential": "c" }
]
}
}
welcome — what the client receives
{
"type": "welcome",
"peerId": "alice",
"expiresAt": 1715539200,
"serverTime": 1715535600,
"maxMessageSize": 65536,
"metadata": {
"iceServers": [
{ "urls": ["stun:stun.relay.metered.ca:80"] },
...
]
}
}
client — feed it straight into RTCPeerConnection
ws.on("message", (raw) => {
const msg = JSON.parse(raw.toString());
if (msg.type === "welcome") {
const pc = new RTCPeerConnection({
iceServers: msg.metadata?.iceServers ?? [],
});
// ... continue with WebRTC setup
}
});

The full TURN-credential-via-JWT flow — including how to mint the TURN credential from your backend with the Metered TURN REST API — is in the WebRTC Signalling guide.

Other things to put in metadata

  • App version compatibility checks ({ minClientVersion: "2.4.0" })
  • Feature flags scoped to this connection
  • Recording-policy hints ({ recordingEnabled: false })
  • Customer-issued one-time tokens for follow-on calls (e.g., an analytics-service token)

What NOT to put in metadata

Anything other peers should see. They won't; the field is connection-private. Put those in peerMetadata instead.

Size limits

  • The metadata claim is hard-capped at 8 KB serialized when minting via POST /v1/tokens. JWTs over a few KB can also trip WebSocket-upgrade-URL-length limits on some HTTP proxies — in practice, keep metadata (including iceServers) under ~6 KB to be safe.

peerMetadata — per-peer identity bag

The peerMetadata JWT claim is the customer's identity bag for THIS peer. The server stamps it onto outbound events that other peers see.

Typical use: user identity

JWT claim
{
"sub": "u_alice_123",
"exp": 1715539200,
"channels": ["app_abc/room-1"],
"peerMetadata": {
"userId": "u_alice_123",
"username": "Alice Anderson",
"profilePic": "https://cdn.example.com/u/alice.jpg",
"role": "presenter"
}
}

Where it gets stamped

There are three places peerMetadata reaches other peers. Two are automatic, one is opt-in.

1. Presence events — ALWAYS stamped

Every presence event carries the relevant peers' peerMetadata under each entry's metadata field:

server → all subscribers when alice joins
{
"type": "presence",
"channel": "app_abc/room-1",
"joined": [
{
"peerId": "u_alice_123",
"metadata": {
"userId": "u_alice_123",
"username": "Alice Anderson",
"profilePic": "https://cdn.example.com/u/alice.jpg",
"role": "presenter"
}
}
],
"left": []
}

This means as soon as a peer joins, every other peer in the room learns who they are. No need for a backend round-trip per peer to resolve display names / avatars.

2. Direct messages — ALWAYS stamped

When a peer sends a send (direct message), the recipient sees the sender's peerMetadata under fromMetadata:

bob receives a direct from alice
{
"type": "direct",
"from": "u_alice_123",
"fromMetadata": {
"userId": "u_alice_123",
"username": "Alice Anderson",
"profilePic": "https://cdn.example.com/u/alice.jpg",
"role": "presenter"
},
"data": { "kind": "offer", "sdp": "..." }
}

Direct messages are 1:1 so the byte cost of stamping is minimal — it's always on.

3. Channel messages — OPT-IN at subscribe time

By default, channel message frames do NOT carry peerMetadata. To opt in, set includeSenderMetadata: true when you subscribe:

subscribe with metadata stamping turned on
{
"type": "subscribe",
"channel": "app_abc/room-1",
"includeSenderMetadata": true,
"requestId": "sub-1"
}

Now every message you receive on that channel carries fromMetadata:

without includeSenderMetadata
{
"type": "message",
"channel": "app_abc/room-1",
"from": "u_alice_123",
"data": { "text": "hi" }
}
with includeSenderMetadata: true
{
"type": "message",
"channel": "app_abc/room-1",
"from": "u_alice_123",
"fromMetadata": {
"userId": "u_alice_123",
"username": "Alice Anderson",
"profilePic": "https://cdn.example.com/u/alice.jpg",
"role": "presenter"
},
"data": { "text": "hi" }
}

When to turn includeSenderMetadata on

Channel patternRecommendedWhy
Chat messages (≤ a few per second per user)ONFrees the client from maintaining a peerId → display data lookup table — every message renders standalone.
Reaction events (likes, emoji bursts)ONSame reason. The metadata stamp is cheap relative to user-perceived latency.
Cursor sync (10–60 Hz per peer)OFFUse presence events to learn peerId → display data once; cursor messages carry just from to keep bytes minimal.
Game state ticks (10+ Hz)OFFSame — render from a cached peer table you populate on presence.joined.
WebRTC SDP/ICEOFF (direct messages already stamp)Use send instead of channel publishes for these.

What's a good shape for peerMetadata?

Keep it small (≤1 KB serialized) and focused on what every consumer of presence/direct/messages needs.

Good:

{
"userId": "u_alice_123",
"username": "Alice Anderson",
"profilePic": "https://cdn.example.com/u/alice.jpg",
"role": "presenter"
}

Bad (too much, mostly only useful to the connecting user):

{
"userId": "u_alice_123",
"username": "Alice Anderson",
"profilePic": "https://cdn.example.com/u/alice.jpg",
"email": "alice@example.com", // ← do other peers need this?
"phone": "+1-555-0100", // ← probably not
"subscriptionTier": "enterprise", // ← internal state
"billingAccountId": "acc_xyz", // ← internal state
"abTestVariants": { ... } // ← internal state
}

The 4 things you typically want stamped onto other peers' views:

  1. Stable user ID — for client-side peerId → user joins
  2. Display name — to render "Alice typed..."
  3. Avatar URL — to render the user's bubble
  4. Role / status tagpresenter / moderator / online etc.

If you need more, fetch it from your backend keyed by the stable user ID. Don't bloat peerMetadata.

Size limits

  • Per-peer peerMetadata is bounded by the JWT size envelope (same as metadata).
  • The server hard-caps the serialized peerMetadata field at 4 KB during JWT verify. Larger claims are rejected with invalid_token.

Worked example: chat with rich identity

A classroom chat where every message renders with the sender's name, avatar, and role tag.

backend — mint JWT with peerMetadata
const token = jwt.sign({
sub: user.id,
exp: Math.floor(Date.now()/1000) + 3600,
channels: [`class-${classId}`],
permissions: ["publish", "subscribe", "presence", "send"],
peerMetadata: {
userId: user.id,
username: user.displayName,
profilePic: user.avatarUrl,
role: user.role, // "student" | "teacher" | "moderator"
},
}, SECRET, { algorithm: "HS256", header: { alg: "HS256", kid: KEY_ID } });
client — subscribe with includeSenderMetadata so every message renders
ws.send(JSON.stringify({
type: "subscribe",
channel: `class-${classId}`,
includeSenderMetadata: true,
requestId: "sub-1",
}));

ws.on("message", (raw) => {
const msg = JSON.parse(raw.toString());
if (msg.type === "message") {
// Every message arrives already stamped — no backend lookup needed.
renderChatBubble({
author: msg.fromMetadata?.username ?? msg.from,
avatar: msg.fromMetadata?.profilePic,
role: msg.fromMetadata?.role,
text: msg.data.text,
});
}
if (msg.type === "presence") {
// Update the participant list using the same metadata shape.
for (const peer of msg.joined) addToRoster(peer.peerId, peer.metadata);
for (const peer of msg.left) removeFromRoster(peer.peerId);
}
});

This pattern is documented end-to-end in the Live Presence & Chat guide.


Worked example: high-frequency cursor sync (metadata via presence, not per-message)

A Figma-like collaborative editor where cursors update at ~30 Hz per peer.

backend — mint with peerMetadata for identity, NO includeSenderMetadata on subscribe
const token = jwt.sign({
sub: user.id,
exp: ...,
channels: [`doc-${docId}`],
peerMetadata: {
userId: user.id,
username: user.displayName,
cursorColor: user.cursorColor,
},
}, ...);
client — populate the roster from presence; render cursors from message.from alone
const peerRoster = new Map();  // peerId → { username, cursorColor }

ws.send(JSON.stringify({
type: "subscribe",
channel: `doc-${docId}`,
// includeSenderMetadata omitted → defaults to false
requestId: "sub-1",
}));

ws.on("message", (raw) => {
const msg = JSON.parse(raw.toString());
if (msg.type === "presence") {
for (const peer of msg.joined) peerRoster.set(peer.peerId, peer.metadata);
for (const peer of msg.left) peerRoster.delete(peer.peerId);
}
if (msg.type === "message" && msg.data.kind === "cursor") {
const peer = peerRoster.get(msg.from);
if (!peer) return; // they left before we got their cursor
renderCursor(peer.cursorColor, peer.username, msg.data.x, msg.data.y);
}
});

The chat message above is ~50 bytes per cursor update without metadata stamping vs ~250 bytes with — at 30 Hz × 20 peers × hours of session, the savings are real.

This pattern is documented end-to-end in the Collaborative Apps guide.


Cheat sheet

You want to ship XUse
TURN ICE servers to the browsermetadata.iceServers in the JWT — read from welcome.metadata.iceServers
The user's display name + avatar that every peer should seepeerMetadata.{ userId, username, profilePic }
Per-message identity for chat (every message renders standalone)Subscribe with includeSenderMetadata: true
Per-peer identity for high-frequency events (cursors / game state)Don't subscribe with includeSenderMetadata. Cache from presence events instead.
Sender identity on direct (WebRTC SDP) messagesAlways on — no opt-in. Use direct.fromMetadata.
Feature flags scoped to the connecting usermetadata.featureFlags in the JWT
Recording / moderation toggle for this sessionmetadata.recordingEnabled in the JWT

What's next