POST /v1/tokens
Mint a JWT for a peer. Alternative to your backend signing one itself — pass the desired scope, get a ready-to-use connect-token back.
Endpoint
POST https://rms.metered.ca/v1/tokens
Authorization: Bearer sk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Content-Type: application/json
Request body
{
"peerId": "u_alice_123",
"channels": ["app_abc/room-1", "app_abc/dm-alice-bob"],
"permissions": ["publish", "subscribe", "presence", "send"],
"expiresInSec": 3600,
"metadata": {
"iceServers": [
{ "urls": ["stun:stun.relay.metered.ca:80"] },
{ "urls": ["turn:global.relay.metered.ca:80"], "username": "u", "credential": "c" }
]
},
"peerMetadata": {
"userId": "u_alice_123",
"username": "Alice Anderson",
"profilePic": "https://cdn.example.com/u/alice.jpg",
"role": "presenter"
}
}
| Field | Required | Type | Notes |
|---|---|---|---|
peerId | ✅ | string | Printable ASCII, ≤128 chars. Becomes the JWT's sub claim and the peer's identity on the wire. |
channels | string[] | Channels to pre-authorize. MUST be a subset of the sk_'s channelPatterns. Cap: 100 entries. | |
permissions | string[] | Subset of publish / subscribe / presence / send. Defaults to the sk_'s full action set. Cap: 16 entries. | |
expiresInSec | int | Token lifetime in seconds. Default 3600 (1h); cap 86400 (24h). | |
metadata | object | Connection-private — delivered ONCE in the welcome message. Tuck iceServers here for WebRTC. Cap: 8 KB serialized. | |
peerMetadata | object | Per-peer identity bag — stamped on presence events, direct messages, and (opt-in via subscribe.includeSenderMetadata) channel messages. Use for userId / username / profilePic / role tags so other peers see the sender's identity without a backend lookup. Cap: 4 KB serialized. |
See Authentication for the JWT claim semantics and Presence & Metadata for the metadata vs peerMetadata distinction.
Response — 200 OK
{
"token": "eyJhbGciOiJIUzI1NiIsImtpZCI6InNrX2lkX2FiYy4uLiIsInR5cCI6IkpXVCJ9...",
"expiresAt": 1715539200
}
| Field | Type | Notes |
|---|---|---|
token | string | HS256-signed JWT. Pass as ?token=<...> on the WebSocket connect URL. |
expiresAt | int | Unix seconds when the token expires. |
Error responses
| HTTP | error | When |
|---|---|---|
| 400 | invalid_request | Missing / malformed peerId, oversize metadata, expiresInSec out of range |
| 401 | unauthorized | sk_ missing, invalid, or revoked |
| 403 | channel_not_authorized | A channels entry is outside the sk_'s channelPatterns |
| 403 | action_not_permitted | A permissions entry isn't in the sk_'s actions |
| 503 | internal_error | sk_ exists but has no decrypted signing material (infra misconfig) |
Example — curl
curl -X POST https://rms.metered.ca/v1/tokens \
-H "Authorization: Bearer sk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"peerId": "alice",
"channels": ["app_abc/room-1"],
"expiresInSec": 3600
}'
Example — Node.js (with TURN credentials embedded)
const fetch = require("node-fetch");
const METERED_APP = "your-app"; // your dashboard app name
const METERED_TURN_KEY = "..."; // dashboard → TURN → credential apiKey
const METERED_REALTIME_SK = "sk_live_..."; // Dashboard → Realtime Messaging → Keys
async function mintConnectToken(userId, roomId) {
// 1. Fetch fresh TURN ICE servers from Metered TURN REST API
const turnResp = await fetch(
`https://${METERED_APP}.metered.live/api/v1/turn/credentials?apiKey=${METERED_TURN_KEY}`,
);
const iceServers = await turnResp.json();
// 2. Mint the realtime-messaging JWT with iceServers in metadata
const tokenResp = await fetch("https://rms.metered.ca/v1/tokens", {
method: "POST",
headers: {
Authorization: `Bearer ${METERED_REALTIME_SK}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
peerId: userId,
channels: [`call-${roomId}`],
permissions: ["publish", "subscribe", "presence", "send"],
expiresInSec: 3600,
metadata: { iceServers }, // TURN delivered in welcome
peerMetadata: { // identity stamped on presence + direct
userId,
// ... username, profilePic, role, etc.
},
}),
});
const { token, expiresAt } = await tokenResp.json();
return { token, expiresAt };
}
The browser uses this token to open the WebSocket, then reads welcome.metadata.iceServers and feeds it straight into new RTCPeerConnection({ iceServers }) — no second round-trip for TURN credentials. End-to-end pattern documented in the WebRTC Signalling guide.
Self-mint alternative
You can also sign the JWT yourself with the sk's signing secret — see [Authentication → Secret keys (sk_live…) and JWTs](../protocol/authentication#secret-keys-sklive-and-jwts). The minted token is wire-equivalent to what POST /v1/tokens produces.
Reasons to call /v1/tokens instead:
- You don't want to embed a JWT library in your backend
- You want Metered to validate scope / claim bounds before the token is issued (the endpoint rejects mints that try to elevate above the sk_'s scope)
Reasons to self-mint:
- You don't want the per-mint REST round-trip latency
- You're behind a strict egress firewall