Get started
Issue room tokens from your backend
Create rooms and mint VoltKit RTC tokens from your own backend, with Node.js, Python, Go, PHP and Kotlin examples, and fetch them safely from your app.
Apps never hold your API secret. Your backend holds it, decides who may join which room with which role, and hands each app a short-lived token. This page builds that endpoint. The concepts are on Authentication; every field is in the API reference.
What your backend does
Two calls to the VoltKit RTC REST API, with your secret in X-API-Key:
- Make sure the room exists:
PUT /v1/rooms/{roomId}withhostIdentity. It creates the room, or returns the live one; only a new room counts against your concurrent-room limit. Call it when a call or room starts. - Mint a token for each user:
POST /v1/rooms/{roomId}/tokenswithidentityandrole. It returnstokenandwsUrl, which you hand to the app.
curl -X PUT "$VOLTKIT_RTC_URL/v1/rooms/call-8f3a" \
-H "X-API-Key: $VOLTKIT_RTC_SECRET" -H "Content-Type: application/json" \
-d '{"hostIdentity":"user-7"}'
curl -X POST "$VOLTKIT_RTC_URL/v1/rooms/call-8f3a/tokens" \
-H "X-API-Key: $VOLTKIT_RTC_SECRET" -H "Content-Type: application/json" \
-d '{"identity":"user-99","role":"cohost"}'
# {"token":"eyJ...","wsUrl":"wss://.../ws","role":"cohost","identity":"user-99","roomId":"call-8f3a","expiresAt":1791048988}
Your endpoint wraps those two calls behind your own user authentication: the app proves who the user is to you (your session, your JWT), and you decide the room and the role.
| Choice | Decided by | Why |
|---|---|---|
identity | your backend: the signed-in user's stable id | it is what others see, what webhooks report and what moderation acts on |
role | your backend | it is signed into the token; the app cannot change it |
roomId | your backend | for a private call, an id nobody can guess (a random UUID) |
For a one-to-one call give the caller host and the callee cohost, so both speak. For an audio room give listeners viewer; the host brings them on stage during the room. See Group audio rooms.
Node.js
With the server SDK and Express 5, which passes a failed call to its error handler (on Express 4, wrap the handler's body in try { … } catch (err) { next(err) }, or a failed RTC call crashes the process):
const express = require("express");
const { StreamClient } = require("@voltkit-rtc/server");
const rtc = new StreamClient({ baseUrl: process.env.VOLTKIT_RTC_URL, apiKey: process.env.VOLTKIT_RTC_SECRET });
const app = express();
app.use(express.json());
// POST /calls/:callId/token (your auth middleware sets req.user)
app.post("/calls/:callId/token", requireUser, async (req, res) => {
const call = await db.calls.find(req.params.callId);
if (!call || ![call.callerId, call.calleeId].includes(req.user.id)) return res.sendStatus(404);
await rtc.ensureRoom({ roomId: call.id, hostIdentity: call.callerId });
const role = req.user.id === call.callerId ? "host" : "cohost";
const { token, wsUrl, expiresAt } = await rtc.createToken({ roomId: call.id, identity: req.user.id, role });
res.set("Cache-Control", "no-store").json({ token, wsUrl, expiresAt });
});
Python
import os, requests
from flask import Flask, jsonify, abort
RTC = os.environ["VOLTKIT_RTC_URL"]
HEADERS = {"X-API-Key": os.environ["VOLTKIT_RTC_SECRET"], "Content-Type": "application/json"}
app = Flask(__name__)
@app.post("/calls/<call_id>/token")
def call_token(call_id):
user = current_user() # your authentication
call = find_call(call_id)
if call is None or user.id not in (call.caller_id, call.callee_id):
abort(404)
requests.put(f"{RTC}/v1/rooms/{call.id}", json={"hostIdentity": call.caller_id}, headers=HEADERS, timeout=10).raise_for_status()
role = "host" if user.id == call.caller_id else "cohost"
r = requests.post(f"{RTC}/v1/rooms/{call.id}/tokens", json={"identity": user.id, "role": role}, headers=HEADERS, timeout=10)
r.raise_for_status()
body = r.json()
resp = jsonify(token=body["token"], wsUrl=body["wsUrl"], expiresAt=body["expiresAt"])
resp.headers["Cache-Control"] = "no-store"
return resp
Go
func callToken(w http.ResponseWriter, r *http.Request) {
user := currentUser(r) // your authentication
call, ok := findCall(r.PathValue("callID"))
if !ok || (user.ID != call.CallerID && user.ID != call.CalleeID) {
http.NotFound(w, r)
return
}
if _, err := rtc("PUT", "/v1/rooms/"+call.ID, map[string]any{"hostIdentity": call.CallerID}); err != nil {
http.Error(w, "rtc unavailable", http.StatusBadGateway)
return
}
role := "cohost"
if user.ID == call.CallerID {
role = "host"
}
body, err := rtc("POST", "/v1/rooms/"+call.ID+"/tokens", map[string]any{"identity": user.ID, "role": role})
if err != nil {
http.Error(w, "rtc unavailable", http.StatusBadGateway)
return
}
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Type", "application/json")
w.Write(body) // {"token","wsUrl","expiresAt",...}
}
func rtc(method, path string, payload any) ([]byte, error) {
b, _ := json.Marshal(payload)
req, _ := http.NewRequest(method, os.Getenv("VOLTKIT_RTC_URL")+path, bytes.NewReader(b))
req.Header.Set("X-API-Key", os.Getenv("VOLTKIT_RTC_SECRET"))
req.Header.Set("Content-Type", "application/json")
res, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
out, _ := io.ReadAll(res.Body)
if res.StatusCode >= 300 {
return nil, fmt.Errorf("rtc %s %s: %d %s", method, path, res.StatusCode, out)
}
return out, nil
}
PHP
function rtc(string $method, string $path, array $payload): array {
$ch = curl_init(getenv('VOLTKIT_RTC_URL') . $path);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_HTTPHEADER => ['X-API-Key: ' . getenv('VOLTKIT_RTC_SECRET'), 'Content-Type: application/json'],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
]);
$body = curl_exec($ch);
if ($body === false) throw new RuntimeException("rtc $method $path: " . curl_error($ch));
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status >= 300) throw new RuntimeException("rtc $method $path: $status $body");
return json_decode($body, true);
}
// POST /calls/{callId}/token
$user = current_user(); // your authentication
$call = find_call($callId);
if (!$call || !in_array($user->id, [$call->caller_id, $call->callee_id], true)) { http_response_code(404); exit; }
rtc('PUT', "/v1/rooms/{$call->id}", ['hostIdentity' => $call->caller_id]);
$role = $user->id === $call->caller_id ? 'host' : 'cohost';
$t = rtc('POST', "/v1/rooms/{$call->id}/tokens", ['identity' => $user->id, 'role' => $role]);
header('Cache-Control: no-store');
header('Content-Type: application/json');
echo json_encode(['token' => $t['token'], 'wsUrl' => $t['wsUrl'], 'expiresAt' => $t['expiresAt']]);
Kotlin (Spring Boot)
import com.fasterxml.jackson.databind.ObjectMapper // Spring Boot 4: tools.jackson.databind.ObjectMapper
import java.net.URI
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
import java.time.Duration
import org.springframework.http.CacheControl
import org.springframework.http.ResponseEntity
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RestController
@RestController
class CallTokenController(private val calls: CallRepository) {
private val http = HttpClient.newHttpClient()
private val base = System.getenv("VOLTKIT_RTC_URL")
private val secret = System.getenv("VOLTKIT_RTC_SECRET")
private val json = ObjectMapper()
@PostMapping("/calls/{callId}/token")
fun token(@PathVariable callId: String, @AuthenticationPrincipal user: AppUser): ResponseEntity<Map<String, Any>> {
val call = calls.find(callId)?.takeIf { user.id == it.callerId || user.id == it.calleeId }
?: return ResponseEntity.notFound().build()
rtc("PUT", "/v1/rooms/${call.id}", mapOf("hostIdentity" to call.callerId))
val role = if (user.id == call.callerId) "host" else "cohost"
val t = rtc("POST", "/v1/rooms/${call.id}/tokens", mapOf("identity" to user.id, "role" to role))
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
.body(mapOf("token" to t["token"]!!, "wsUrl" to t["wsUrl"]!!, "expiresAt" to t["expiresAt"]!!))
}
private fun rtc(method: String, path: String, payload: Map<String, Any>): Map<String, Any> {
val request = HttpRequest.newBuilder(URI.create(base + path))
.header("X-API-Key", secret).header("Content-Type", "application/json")
.timeout(Duration.ofSeconds(10))
.method(method, HttpRequest.BodyPublishers.ofString(json.writeValueAsString(payload)))
.build()
val response = http.send(request, HttpResponse.BodyHandlers.ofString())
check(response.statusCode() < 300) { "rtc $method $path: ${response.statusCode()}" }
@Suppress("UNCHECKED_CAST")
return json.readValue(response.body(), Map::class.java) as Map<String, Any>
}
}
With Spring Security, keep CSRF protection off for this route (the app is not a browser form), or the app's POST gets 403 before your code runs. In Java or Ktor, make the same two calls with java.net.http.HttpClient.
In the app
Ask your endpoint right before joining (tokens are short-lived), with your app's own authentication:
data class RtcCredentials(val token: String, val wsUrl: String)
suspend fun fetchCredentials(callId: String): RtcCredentials = withContext(Dispatchers.IO) {
val request = Request.Builder()
.url("https://api.example.com/calls/$callId/token")
.header("Authorization", "Bearer ${session.accessToken}") // YOUR auth, not the RTC secret
.post(ByteArray(0).toRequestBody())
.build()
okHttp.newCall(request).execute().use { res ->
check(res.isSuccessful) { "token endpoint: ${res.code}" }
val body = JSONObject(res.body.string())
RtcCredentials(body.getString("token"), body.getString("wsUrl"))
}
}
val creds = fetchCredentials(callId)
val client = StreamClient(context, creds.wsUrl, creds.token)
client.connect()
Lifetime and refresh
- A token lives ten minutes by default. It authorises the connection's handshake, not the whole session.
- While connected, the service sends the app a fresh token before the old one lapses, so a call can last for hours and reconnect at any point. You normally do nothing.
- If a fresh token did not arrive (an older server, or a project that is out of credit, at a usage limit or suspended), the SDK warns a minute before expiry:
onTokenExpiringon Android and Flutter,tokenExpiringin the browser. Call your endpoint again and pass the new token toupdateToken. - A token minted for one room, identity or role cannot be used for another.
updateTokenwith such a token fails withtoken-mismatch, and the session keeps its own.
Ending a call from your backend
DELETE /v1/rooms/{roomId} (rtc.deleteRoom(roomId)) ends the room for everyone: each app gets onRoomClosed, then onClosed(roomClosed). Use it when a caller cancels before the callee answers, or when your own rules end a call. Webhooks tell your backend when rooms start and end and who joined and left, for call records.
Checklist
- The secret is in your server's environment only, never in an app, a web page or a repository.
- Your endpoint authenticates your user and checks they belong in that room before minting.
- Roles come from your data, not from the request.
- Private rooms use ids nobody can guess.
- Tokens are not logged and the response is
Cache-Control: no-store. - Errors from the REST API are mapped to your app:
404 no-room(ended);402 quota-exhausted,daily-limit-reachedormonthly-limit-reachedand403 tenant-suspended(the account);429 room-quota,viewer-quotaorrate-limited(limits);503 no-nodesorcapacity-exhausted(try again shortly). Error bodies are{"error":"<code>","message":"…"}. See Errors.
Last updated . Something unclear or wrong? Tell us.