Skip to content

Guide · Developer

Build a Video Call Token Server in 5 Languages

Mint room tokens for video calls from your backend in Node.js, Spring Boot, Python, Go and PHP: ensure the room, sign the role, return token and wsUrl.

Author
VoltKit
Published
Reading time
12 min read

A video call app never holds 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 guide builds that endpoint for VoltKit RTC in Node.js, Next.js, Spring Boot, Python, Go and PHP with the server SDKs, then covers the app side, ending a call, a security checklist and what it all costs. The code comes from the SDK guides linked in each section.

What a token server does

Two calls to the VoltKit RTC REST API, with your secret in the X-API-Key header:

  1. Make sure the room exists: PUT /v1/rooms/{roomId} with hostIdentity. 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.
  2. Mint a token for each user: POST /v1/rooms/{roomId}/tokens with identity and role. It returns token and wsUrl, 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, with your session or your JWT, and you decide the room and the role.

ChoiceDecided byWhy
identityyour backend: the signed-in user's stable idit is what others see, what webhooks report and what moderation acts on
roleyour backendit is signed into the token; the app cannot change it
roomIdyour backendfor a private call, an id nobody can guess, such as 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. Roles and their rights are on Authentication, and rooms, admitting people and limits on Rooms. For a private room id, a random UUID is enough:

Make a room id with theUUID Generator

Node.js (Express 5)

npm install @voltkit-rtc/server
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 });
});

Express 5 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. Retries, timeouts and the error codes are in the Node.js server SDK.

Next.js route handler

In a Next.js app the endpoint can be a route handler that runs on your server only, as the React SDK guide shows:

// app/api/calls/[callId]/token/route.ts: runs on your server only
import { StreamClient } from "@voltkit-rtc/server";
import { currentUser, findCall } from "@/lib/calls"; // your own auth and data

const rtc = new StreamClient({
  baseUrl: process.env.VOLTKIT_RTC_URL!,
  apiKey: process.env.VOLTKIT_RTC_SECRET!, // never sent to a browser
});

export async function POST(request: Request, { params }: { params: Promise<{ callId: string }> }) {
  const user = await currentUser(request);
  if (!user) return Response.json({ error: "sign in first" }, { status: 401 });
  const { callId } = await params;
  const call = await findCall(callId);
  if (!call || ![call.callerId, call.calleeId].includes(user.id)) {
    return Response.json({ error: "not found" }, { status: 404 });
  }

  await rtc.ensureRoom({ roomId: call.id, hostIdentity: call.callerId });
  const role = user.id === call.callerId ? "host" : "cohost";
  const { token, wsUrl } = await rtc.createToken({ roomId: call.id, identity: user.id, role });
  return Response.json({ token, wsUrl }, { headers: { "Cache-Control": "no-store" } });
}

Spring Boot (Java)

The Java server SDK and its Spring Boot starter, with Gradle:

dependencies {
    implementation("in.voltkit:voltkit-rtc-server:0.1.0")
    // Spring Boot: the starter brings the SDK in and makes the beans for you
    implementation("in.voltkit:voltkit-rtc-server-spring-boot-starter:0.1.0")
}

The starter makes the StreamClient bean from application.yml, with the secret read from the environment:

voltkit:
  rtc:
    base-url: ${VOLTKIT_RTC_URL}             # required: your API base URL
    api-key: ${VOLTKIT_RTC_SECRET}           # required: the secret, from the environment, never in this file
    rates-base-url: ${VOLTKIT_API_URL:}      # optional, for getRates(); empty means unset
    timeout: 5s                              # default 10s: each attempt, headers and body
    retries: 1                               # default 2
    webhook:
      secrets: ${VOLTKIT_RTC_WEBHOOK_SECRETS:}   # whsec_current, or "whsec_new,whsec_old" during a rotation; a YAML list works too
      tolerance: 300s                        # default 300s, either way

Then the controller:

import in.voltkit.rtc.Role;
import in.voltkit.rtc.RoomToken;
import in.voltkit.rtc.StreamClient;
import in.voltkit.rtc.StreamException;
import java.security.Principal;
import java.util.Map;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class CallTokenController {
    private final StreamClient rtc;
    private final CallRepository calls;

    public CallTokenController(StreamClient rtc, CallRepository calls) {
        this.rtc = rtc;
        this.calls = calls;
    }

    public record TokenReply(String token, String wsUrl, long expiresAt) {}

    // POST /calls/{callId}/token; `user` comes from your own authentication, never from the request body
    @PostMapping("/calls/{callId}/token")
    public ResponseEntity<?> token(@PathVariable String callId, Principal user) {
        Call call = calls.find(callId);
        if (call == null || !(user.getName().equals(call.callerId()) || user.getName().equals(call.calleeId()))) {
            return ResponseEntity.notFound().build();
        }
        String role = user.getName().equals(call.callerId()) ? Role.HOST : Role.COHOST;
        try {
            rtc.ensureRoom(call.id(), call.callerId());                       // PUT /v1/rooms/{roomId}
            RoomToken t = rtc.createToken(call.id(), user.getName(), role);   // POST /v1/rooms/{roomId}/tokens
            return ResponseEntity.ok()
                .header("Cache-Control", "no-store")
                .body(new TokenReply(t.token(), t.wsUrl(), t.expiresAt()));
        } catch (StreamException e) {
            // e.getCode() and e.getMessage() never contain the secret; map the codes as the checklist below says
            HttpStatus status = e.isRetryable() ? HttpStatus.SERVICE_UNAVAILABLE : HttpStatus.BAD_GATEWAY;
            return ResponseEntity.status(status).body(Map.of("error", "rtc unavailable"));
        }
    }
}

With Spring Security, keep CSRF protection off for this route, because the app is not a browser form; otherwise the app's POST gets 403 before your code runs. Without Spring, StreamClient.fromEnv() reads VOLTKIT_RTC_URL and VOLTKIT_RTC_SECRET, and in Kotlin without the SDK, java.net.http.HttpClient alone makes the two calls (the Kotlin version). Everything else is in the Java server SDK.

Python

pip install voltkit-rtc-server

The two calls with the Python server SDK:

# Create the room, or get it back with a fresh host token if it already exists.
room = rtc.ensure_room("show-42", host_identity="user-7")
# -> {"roomId", "originNode", "hostToken", "wsUrl", "expiresAt", "existed", ...}

# Let someone in, with the role YOU decide.
grant = rtc.create_token("show-42", identity="user-99", role="viewer")
# -> {"token", "wsUrl", "role", "identity", "roomId", "expiresAt"}

A token endpoint as a Django view, with each failure mapped to an answer for the app:

from django.http import HttpRequest, JsonResponse
from django.views.decorators.http import require_POST

from voltkit_rtc.server import StreamClient, StreamError

rtc = StreamClient.from_env()  # at import: one client for the process


@require_POST
def join(request: HttpRequest, show_id: str) -> JsonResponse:
    if not request.user.is_authenticated:
        return JsonResponse({"error": "sign in first"}, status=401)
    try:
        grant = rtc.create_token("show-%s" % show_id, identity="user-%s" % request.user.pk, role="viewer")
    except StreamError as err:
        if err.is_not_found:
            return JsonResponse({"error": "This show has ended"}, status=404)
        if err.is_out_of_minutes:
            return JsonResponse({"error": "This show cannot take viewers right now"}, status=402)
        if err.code == "viewer-quota":
            return JsonResponse({"error": "This show is full"}, status=429)
        if err.retryable:
            return JsonResponse({"error": "Try again in a moment"}, status=503, headers={"Retry-After": "10"})
        return JsonResponse({"error": "Something went wrong"}, status=500)
    response = JsonResponse({"token": grant["token"], "wsUrl": grant["wsUrl"], "expiresAt": grant["expiresAt"]})
    response["Cache-Control"] = "no-store"
    return response

StreamClient.from_env() reads VOLTKIT_RTC_URL and VOLTKIT_RTC_SECRET. The same endpoint in FastAPI and Flask is in the Python server SDK. The SDK is synchronous, so in FastAPI make the endpoint a plain def and FastAPI runs the call in its thread pool.

Go

go get github.com/voltkit-rtc/voltkit-rtc-server-go
client, err := voltkit.NewStreamClient(voltkit.Config{
	BaseURL: os.Getenv("VOLTKIT_RTC_URL"),    // your API base URL, e.g. https://api.voltkit.in
	APIKey:  os.Getenv("VOLTKIT_RTC_SECRET"), // your API secret: server-side only
})
if err != nil {
	log.Fatal(err) // a missing or malformed value, caught at startup; the error never quotes the key
}

A token endpoint on net/http:

func join(client *voltkit.StreamClient) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		identity := currentUser(r) // your session middleware knows who is asking
		ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second)
		defer cancel()
		tok, err := client.CreateToken(ctx, voltkit.CreateTokenParams{
			RoomID:   "show-" + r.PathValue("showId"),
			Identity: identity,
			Role:     voltkit.RoleViewer, // your rules decide; it is signed into the token
		})
		if err != nil {
			writeError(w, err)
			return
		}
		w.Header().Set("Cache-Control", "no-store") // a token is a credential for one room
		writeJSON(w, http.StatusOK, map[string]any{"token": tok.Token, "wsUrl": tok.WSURL, "expiresAt": tok.ExpiresAt})
	}
}

Register it with mux.HandleFunc("POST /shows/{showId}/join", join(client)). With Gin, wrap it with gin.WrapH, or read c.Param("showId") and write with c.JSON. writeError, which maps each failure to an answer for the app, and EnsureRoom for calls are in the Go server SDK.

PHP and Laravel

composer require voltkit/rtc-server

A Laravel controller, with the client bound as a singleton in a service provider and the route behind auth:sanctum:

final class RtcTokenController extends Controller
{
    public function __construct(private readonly StreamClient $rtc)
    {
    }

    public function __invoke(Request $request): JsonResponse
    {
        $user = $request->user();                             // the signed-in user: never an identity from the body
        $roomId = $request->input('roomId');
        if (!is_string($roomId) || $roomId === '') {
            return new JsonResponse(['error' => 'roomId is required'], 400);
        }
        $role = $this->isHostOf($roomId, $user) ? Role::Host : Role::Viewer;

        try {
            $token = $this->rtc->createToken($roomId, 'user-' . $user->getAuthIdentifier(), $role);
        } catch (StreamError $e) {
            Log::warning('createToken failed', ['code' => $e->getErrorCode(), 'message' => $e->getMessage()]);
            if ($e->isNotFound()) {
                return new JsonResponse(['error' => 'This show has ended'], 404);
            }
            if ($e->isOutOfMinutes()) {
                return new JsonResponse(['error' => 'This show cannot take viewers right now'], 402);
            }
            if ($e->getErrorCode() === 'viewer-quota') {
                return new JsonResponse(['error' => 'This show is full'], 429);
            }
            if ($e->isRetryable()) {
                return new JsonResponse(['error' => 'Try again in a moment'], 503, ['Retry-After' => '10']);
            }
            return new JsonResponse(['error' => 'Something went wrong'], 500);
        }

        return new JsonResponse([
            'token' => $token['token'],
            'wsUrl' => $token['wsUrl'],
            'expiresAt' => $token['expiresAt'],
            'role' => $token['role'],
        ], 200, ['Cache-Control' => 'no-store']);
    }
}

Register it with Route::post('/rtc/token', RtcTokenController::class)->middleware('auth:sanctum'); in routes/api.php. A plain PHP version without a framework, using StreamClient::fromEnv(), is in the PHP server SDK.

In the app

Ask your endpoint right before joining, with your app's own authentication. In Kotlin, as issue tokens from your backend shows:

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"))
    }
}

A token lives ten minutes by default and authorises the connection's handshake, not the whole session. While the app is connected, the service sends it a fresh token before the old one lapses, so a call can last for hours. If a fresh token does not arrive, the SDK warns a minute before expiry (onTokenExpiring on Android and Flutter, tokenExpiring in the browser): call your endpoint again and pass the new token to updateToken. The Flutter side is in add video calling to a Flutter app.

Ending a call from your backend

DELETE /v1/rooms/{roomId}, or rtc.deleteRoom(roomId) with the SDK, 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. Verify every delivery's signature, and during a rotation accept both the new and the old signing secret.

Security checklist

  • The secret is in your server's environment only: never in an app, a web page, a repository or a log.
  • Your endpoint authenticates your user and checks they belong in that room before minting.
  • Roles come from your data and the identity from your session, never from the request body.
  • Private rooms use ids nobody can guess.
  • Tokens are not logged, and the response is Cache-Control: no-store.
  • People outside a call get a 404, so nothing reveals that the call exists.
  • In Spring, CSRF protection is off for this API route.
  • The server SDKs never follow a redirect, so the secret goes only to the base URL you configured.
  • The API's errors are mapped for your app: 404 no-room means the call ended; 402 quota-exhausted and the daily or monthly limits concern the account; 429 room-quota, viewer-quota or rate-limited are limits; 503 means try again shortly. Every code is on Errors.

What a token costs

Nothing. API calls, tokens and webhooks are not billed; only participants connected to a room are, per second, at the rate of the media they use. The rules are on usage and billing.

Inspect a test token

Your code should treat the token as opaque and read expiresAt from the response. While you develop, the JWT Decoder shows a test token's expiry on your own device and never sends it anywhere; how to decode a JWT explains what you are looking at.

Inspect a test token with theJWT Decoder

Frequently asked questions

How do I generate room tokens in Node.js?

Install @voltkit-rtc/server and create one StreamClient with your API base URL and secret from the environment. In an endpoint behind your own authentication, call ensureRoom, then createToken with the user's identity and the role you decide, and return token, wsUrl and expiresAt with Cache-Control: no-store.

Why can't the app call the RTC API directly?

Because the API secret must never reach a device or a web page: anyone who has it can create rooms and mint tokens as you. Your backend keeps the secret and decides the identity, the role and the room.

How long is a token valid?

Ten minutes by default, and the response carries expiresAt. A token authorises the connection's handshake; the service sends a connected app fresh tokens before the old one lapses, so a call can last for hours.

Which role should the callee get?

In a one-to-one call, the caller gets host and the callee cohost, so both can speak. In an audio room, listeners get viewer, and the host promotes them to the stage during the room.

Do I need an SDK or can I call the REST API?

Either. The REST API is plain HTTPS with your secret in the X-API-Key header, and the token server docs show plain-HTTP versions in Python, Go, PHP and Kotlin. The server SDKs for Node.js, Python, Go, PHP and Java add retries, typed errors and webhook verification.

Does a Spring Boot app need the starter?

No, but it saves wiring: the starter registers a StreamClient and a WebhookVerifier from application.yml. Plain Java can build the client with StreamClient.fromEnv(), and Kotlin can make the two calls with java.net.http.HttpClient alone.

What does minting a token cost?

Nothing. API calls, tokens and webhooks are not billed. Only participants connected to a room are billed, per second, for the media they use.

← Back to Guides

You might also like

Something useful from another corner of VoltKit.