Skip to content
Server

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.

ModeX-App-Key mintX-App-Secret mintPOST /api/v2/join
direct (default)WorksWorks403 token_v2_disabled
v2_optionalWorksWorksWorks
v2_required403 token_v2_requiredWorksWorks

To move a live app without breaking old versions:

  1. Copy the certificate and switch to v2_optional.
  2. Ship the app version that joins with Token v2.
  3. Switch to v2_required once 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 padding

The key is the certificate string (64 hex characters) as UTF-8. Don't hex-decode it.

FieldTypeDescription
app_idnumberYour App ID
identitystring, 1 to 200 charactersThe user
roomstringOne room name, or "*" for any room
rolehost, admin or audiencehost needs can_publish: true. admin needs a specific room.
can_publishbooleanfalse means this session never publishes media
iat, expnumberUnix seconds. exp - iat can be at most 24 hours.
device_idstring, optionalIf 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/join

Send 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: host makes the user the owner of a new room. For a room someone else owns, they join like any non-owner.
  • role: admin makes 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": "..." }.

CodeStatusWhen
token_v2_malformed401Not v2.<payload>.<sig>, bad base64url or JSON, a missing or wrong field, or over 4 KB
token_v2_bad_signature401No certificate matches, or the app_id is unknown
token_v2_expired401exp has passed, plus 60 seconds
token_v2_not_yet_valid401iat is more than 60 seconds ahead
token_v2_too_long401exp - iat is over 24 hours
token_v2_invalid_claims422A bad role, host without publish, admin with "*", a bad room, or exp not after iat
token_v2_room_mismatch403room_name differs from the token's room
token_v2_device_mismatch403The token's device_id differs from the body's
token_v2_disabled403The project is in direct mode
token_v2_required403An X-App-Key mint in a v2_required project
publish_not_allowed403A publish action in a can_publish: false session