Token v2
Sign short-lived join tokens on your server with the App Certificate.
Token v2 is optional. Your server decides who the user is, which room they may join, their role and whether they may publish, then signs that with the project's App Certificate. The app joins with the signed token, and never holds a credential that can mint for another identity.
The App Certificate and the token mode are in the Token v2 cards under Project Information in the console. The certificate only signs Token v2, and it must stay on your server.
Modes
Every project starts in direct mode, so nothing changes until you switch.
| Mode | X-App-Key mint | X-App-Secret mint | POST /api/v2/join |
|---|---|---|---|
direct (default) | Works | Works | 403 token_v2_disabled |
v2_optional | Works | Works | Works |
v2_required | 403 token_v2_required | Works | Works |
To move a live app without breaking old versions:
- Copy the certificate and switch to
v2_optional. - Ship the app version that joins with Token v2.
- Switch to
v2_requiredonce old versions are gone.
Sign the token
v2.<payload>.<signature>
payload = base64url(JSON), no padding
signature = base64url(HMAC-SHA256(key = App Certificate, message = "v2." + payload)), no paddingThe key is the certificate string (64 hex characters) as UTF-8. Don't hex-decode it.
| Field | Type | Description |
|---|---|---|
app_id | number | Your App ID |
identity | string, 1 to 200 characters | The user |
room | string | One room name, or "*" for any room |
role | host, admin or audience | host needs can_publish: true. admin needs a specific room. |
can_publish | boolean | false means this session never publishes media |
iat, exp | number | Unix seconds. exp - iat can be at most 24 hours. |
device_id | string, optional | If set, the join must send the same device_id |
Node.js
import crypto from 'node:crypto'
export function buildTokenV2({ appId, appCertificate, identity, room = '*', role = 'audience',
canPublish = false, ttlSeconds = 3600, deviceId, now = Math.floor(Date.now() / 1000) }) {
if (!(ttlSeconds >= 1 && ttlSeconds <= 86400)) throw new Error('ttlSeconds must be between 1 and 86400')
const payload = { app_id: Number(appId), identity: String(identity), room, role,
can_publish: !!canPublish, iat: now, exp: now + ttlSeconds }
if (deviceId) payload.device_id = String(deviceId)
const body = 'v2.' + Buffer.from(JSON.stringify(payload)).toString('base64url')
const signature = crypto.createHmac('sha256', appCertificate).update(body).digest('base64url')
return `${body}.${signature}`
}PHP
function voxera_build_token_v2(int $appId, string $appCertificate, string $identity,
string $room = '*', string $role = 'audience', bool $canPublish = false,
int $ttlSeconds = 3600, ?string $deviceId = null, ?int $now = null): string
{
if ($ttlSeconds < 1 || $ttlSeconds > 86400) {
throw new InvalidArgumentException('ttlSeconds must be between 1 and 86400');
}
$now = $now ?? time();
$payload = ['app_id' => $appId, 'identity' => $identity, 'room' => $room, 'role' => $role,
'can_publish' => $canPublish, 'iat' => $now, 'exp' => $now + $ttlSeconds];
if ($deviceId !== null) {
$payload['device_id'] = $deviceId;
}
$b64u = fn (string $s): string => rtrim(strtr(base64_encode($s), '+/', '-_'), '=');
$body = 'v2.' . $b64u(json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
return $body . '.' . $b64u(hash_hmac('sha256', $body, $appCertificate, true));
}A token can be used for any number of joins until exp, with 60 seconds of clock leeway. To prefetch for a room list, fetch a "*" token valid for one hour when the user opens the list, and use it when they tap.
Join
POST https://stream.zavryn.online/api/v2/joinSend no X-App-* headers. The body takes token and the same fields as the Token API, except identity, role and room_owner_id, because the token decides those.
{ "token": "v2.eyJhcHBf...", "device_id": "phone-1", "room_name": "party_room-1", "type": "audio_room" }room_name can be left out if the token names a room. It's required with "*".
The response is the same as the Token API's, and the same rules apply: bans, single session and owner checks.
role: hostmakes the user the owner of a new room. For a room someone else owns, they join like any non-owner.role: adminmakes the user an admin of that room.
exp only limits when a join may start. It doesn't end a session already in progress.
POST /api/v2/session takes the same token and returns only a user_token, for apps that need one without joining a room.
can_publish: false
The session never publishes, whatever the room would otherwise allow. Taking a seat, requesting to speak, joining a stage or starting a PK battle returns 403 with publish_not_allowed. The user can still become an admin, because that only grants moderation. This lasts until the user joins again.
Rotate the certificate
Rotate from the console. The previous certificate keeps working for 24 hours. Rotating again within that window ends the older one immediately.
Errors
All errors have the shape { "message": "...", "code": "..." }.
| Code | Status | When |
|---|---|---|
token_v2_malformed | 401 | Not v2.<payload>.<sig>, bad base64url or JSON, a missing or wrong field, or over 4 KB |
token_v2_bad_signature | 401 | No certificate matches, or the app_id is unknown |
token_v2_expired | 401 | exp has passed, plus 60 seconds |
token_v2_not_yet_valid | 401 | iat is more than 60 seconds ahead |
token_v2_too_long | 401 | exp - iat is over 24 hours |
token_v2_invalid_claims | 422 | A bad role, host without publish, admin with "*", a bad room, or exp not after iat |
token_v2_room_mismatch | 403 | room_name differs from the token's room |
token_v2_device_mismatch | 403 | The token's device_id differs from the body's |
token_v2_disabled | 403 | The project is in direct mode |
token_v2_required | 403 | An X-App-Key mint in a v2_required project |
publish_not_allowed | 403 | A publish action in a can_publish: false session |