Minting a video room token on the server for a browser client

One call returns the token and the websocket URL. The four permission flags, the TTL, and why the client must never know your API key.

A browser joining a video call needs a token, and that token has to be minted by your backend — never by the client, which must never hold your API key. On Infrai it’s POST /v1/rtc/token/issue with a room, an identity, and four permission flags, and the response carries both the token and the ws_url the client connects to.

One call, and the client gets everything it needs without learning anything it shouldn’t.

The call

curl -sS -X POST "https://api.infrai.cc/v1/rtc/token/issue" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
    "room": "support-4821",
    "identity": "au_usr_lMmXGySJeGVM1xicqHGJaJbB",
    "display_name": "Ada L.",
    "ttl_s": 900,
    "can_publish": true,
    "can_subscribe": true,
    "can_publish_data": true,
    "is_admin": false
  }'
{
  "ok": true,
  "data": {
    "token": "rtct_9wQ1zV6pLkS3dHyBnMfE",
    "room": "support-4821",
    "identity": "au_usr_lMmXGySJeGVM1xicqHGJaJbB",
    "vendor": "tencent_rtc",
    "expires_at": "2026-09-21T03:40:19Z",
    "ws_url": "wss://rtc.example-region.tencent-rtc.com"
  }
}

ws_url is the part people miss. Don’t hardcode a connection endpoint in your client — read it from the token response, because it’s vendor- and region-specific and hardcoding it is how a regional change breaks your app.

The four flags

FlagGrantsGive it to
can_publishsend audio and videoparticipants in a call
can_subscribereceive others’ streamseveryone, including viewers
can_publish_datasend data messages on the channelparticipants who need chat or signalling
is_adminroom-level controlmoderators only

A webinar viewer gets can_subscribe and nothing else. A participant gets subscribe plus publish. A moderator gets is_admin too, and almost nobody should have that — is_admin on a token handed to a browser is a browser that can act on the room.

Set them per role, in your backend, from your own authorisation logic. A single mint function with a role argument is much safer than four call sites each choosing flags.

The endpoint that mints it

import express from "express";

const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");

const ROLES = {
  viewer:      { can_publish: false, can_subscribe: true,  can_publish_data: false, is_admin: false },
  participant: { can_publish: true,  can_subscribe: true,  can_publish_data: true,  is_admin: false },
  moderator:   { can_publish: true,  can_subscribe: true,  can_publish_data: true,  is_admin: true  },
};

const app = express();
app.use(express.json());

app.post("/rtc/join", async (req, res) => {
  const sessionId = req.get("x-session-id");
  if (!sessionId) return res.status(401).json({ error: "no session" });

  // Verify the caller, then decide the room and role from YOUR data — never from
  // the request body. A client that can name its own room and role is a client
  // that can join any call as a moderator.
  const verified = await fetch(`${API}/v1/auth/session/verify/${sessionId}`, {
    headers: { authorization: `Bearer ${KEY}` },
  });
  if (!verified.ok) return res.status(401).json({ error: "invalid session" });
  const { data: session } = await verified.json();

  const assignment = await roomForUser(session.user_id);
  if (!assignment) return res.status(403).json({ error: "no call assigned" });

  const minted = await fetch(`${API}/v1/rtc/token/issue`, {
    method: "POST",
    headers: { authorization: `Bearer ${KEY}`, "content-type": "application/json" },
    body: JSON.stringify({
      room: assignment.room,
      identity: session.user_id,
      display_name: assignment.displayName,
      ttl_s: 900,
      ...ROLES[assignment.role],
    }),
  });
  const payload = await minted.json();
  if (!payload.ok) return res.status(502).json({ error: payload.error?.code ?? "mint_failed" });

  const { token, ws_url, expires_at, room } = payload.data;
  res.json({ token, wsUrl: ws_url, expiresAt: expires_at, room });
});

async function roomForUser(userId) {
  // Your own assignment logic: which call is this person part of, and as what.
  return { room: "support-4821", role: "participant", displayName: "Ada L." };
}

app.listen(3000, () => console.log("listening on :3000"));

The comment in the middle is the security boundary of the whole feature. If the room name comes from the request body, anyone can join any call.

TTL, and what expiry means

ttl_s bounds the token, not the call. A fifteen-minute token can start a two-hour meeting — the token authorises the join, and the connection persists afterwards.

That makes short TTLs nearly free: mint on join, expire quickly, and a token that leaks is useless within minutes.

curl -sS "https://api.infrai.cc/v1/rtc/room/get/support-4821" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
{
  "ok": true,
  "data": {
    "room_id": "rtcr_2fVc8nRqLmT4xBzY",
    "name": "support-4821",
    "vendor": "tencent_rtc",
    "state": "active",
    "max_participants": 8,
    "num_participants": 2,
    "created_at": "2026-09-21T03:25:19Z",
    "metadata": {}
  }
}

num_participants against max_participants is the check to run before minting for a ninth person — a token for a full room is a join that fails on the client, which is a worse experience than a clear message from your own API.

Rejoining and reconnecting

A participant whose network drops reconnects, and depending on how long they were away the token may have expired. Have the client call your mint endpoint again rather than caching a token for the session — your endpoint re-checks authorisation, which is the behaviour you want anyway if their permission changed while they were gone.

Limitations

Token issue is a billable call rather than a free one — small, but it means a client re-minting in a tight reconnect loop costs something, so put a floor on how often your endpoint will mint for the same identity. The live figure is in the billing block of GET /v1/discovery/rtc.token.issue (verified 2026-09-21), and rates drift downward as vendor contracts improve.

There’s no client SDK here: the browser side uses the vendor’s own client library pointed at the ws_url, so you’re writing against tencent_rtc’s client rather than a platform-neutral one. That’s the real limitation, and it’s the reason LiveKit is a better fit if you want a single vendor’s SDK with a well-documented React component set, or Daily if you want an embeddable prebuilt call UI rather than building one. Twilio Video sits in the same bracket for an established, documented stack.

What’s already on the key is everything around the call. The session verify that authorises the join, POST /v1/errors/capture when a mint fails, the recording artefact you’d store with PUT /v1/storage/object/put/{bucket}/{key}, and the follow-up email through POST /v1/email/send — one credential, one invoice, one GET /v1/account/usage covering the video minutes and everything attached to them.

References

Browse more rtc developer guides