Skip to content

SDKs

Node.js Server SDK

Use @voltkit-rtc/server in your backend: create rooms, mint tokens, moderate chat, manage broadcasts, read usage and balance, and verify webhooks.

@voltkit-rtc/server wraps the REST API for a Node.js backend. Your backend does two things: decide who may do what, and hand the client a short-lived token. It is never in the media path.

Install

npm install @voltkit-rtc/server

Node.js 18 or newer, no dependencies. It ships both CommonJS and ES modules, each with its own TypeScript types, so use whichever your project does:

import { StreamClient } from "@voltkit-rtc/server";          // ES modules / TypeScript
const { StreamClient } = require("@voltkit-rtc/server");

const rtc = new StreamClient({
  baseUrl: process.env.VOLTKIT_RTC_URL,
  apiKey: process.env.VOLTKIT_RTC_SECRET,   // server-side only
});

Both values are in the credentials file your browser downloaded when you created the project or the key. The API base URL is also on your project's Credentials tab in the dashboard; the secret is shown only once. See Quickstart.

Rooms and tokens

// Create the room, or get it back if it already exists.
const room = await rtc.ensureRoom({ roomId: "show-42", hostIdentity: "user-7" });
// -> { roomId, hostToken, wsUrl, expiresAt, existed }

// Let someone in, with the role YOU decide.
const { token, wsUrl } = await rtc.createToken({ roomId: "show-42", identity: "user-99", role: "viewer" });

const { room: live, participants } = await rtc.getRoom("show-42");
await rtc.listRooms();
await rtc.listParticipants("show-42");
await rtc.deleteRoom("show-42");   // ends it for everyone

createRoom is the strict version of ensureRoom: it fails if the id is taken. Roles are covered in Rooms.

Chat

// Admit someone to a channel's chat with no media rights.
const chat = await rtc.createChatToken({ channel: "show-42", identity: "user-99" });

// Take part without holding a connection.
await rtc.sendMessage({ channel: "show-42", text: "The show starts in 5 minutes", type: "system" });
await rtc.sendMessage({ channel: "show-42", type: "gift", from: "user-7", metadata: { gift: "rose" } });

const messages = await rtc.listMessages("show-42", { limit: 100 });
await rtc.deleteMessage("show-42", messageId);
await rtc.muteChat("show-42", "user-13");
await rtc.unmuteChat("show-42", "user-13");
await rtc.listChatMutes("show-42");
await rtc.getChatInfo("show-42");   // -> { channel, members }

A message sent from your backend can be trusted in a way a client-sent one cannot, which is what makes it the right place for gifts, purchases and system notices. See Chat.

Broadcast

const stream = await rtc.createStream({ streamId: "show-42", playback: "public" });
// -> { streamKey, state, playbackUrls: { llhls, webrtc }, publish: { whipUrl, token, expiresAt } }

// The publish URL is returned only at creation. Mint another when you need one:
const pub = await rtc.createStreamToken({ streamId: "show-42", action: "publish" });

// With playback: "token", each viewer needs a URL minted for them:
const play = await rtc.createStreamToken({ streamId: "show-42", action: "play", identity: "user-99" });

await rtc.getStream("show-42");                              // state, viewers, peakViewers
await rtc.listStreams();
await rtc.updateStream("show-42", { playback: "token" });
await rtc.deleteStream("show-42");                           // takes it off air

See Live broadcast.

Usage

const { minutesRemaining, today, month, lifetime } = await rtc.getUsage();
const days = await rtc.getUsageSeries(30);   // one entry per day, oldest first

minutesRemaining is -1 on an unlimited plan. See Usage and billing.

Billing in 0.4.0

@voltkit-rtc/server 0.4.0 is on npm (npm install @voltkit-rtc/server@0.4.0). Everything in 0.3.0 works unchanged in it. The balance it reads is the VoltKit Wallet a project spends from, after its free and bought minutes.

Balance

const balance = await rtc.getBalance();
// -> { tenantId, billingMode: "wallet", canJoin: true, blocked: null,
//      legacy: { unlimited, freeSeconds, ownSeconds, sharedSeconds, totalSeconds },
//      wallet: { spendOrder, scopes, paidCu, promoCu, availableCu: "186000000", availableCredits: "31" },
//      burn: { liveParticipants, projectCuPerSecond, walletCuPerSecond },
//      remainingSeconds, walletLow: { active, graceSeconds }, pricingVersion, updatedAt }

GET /v1/billing/balance: what this project can spend right now, to show a balance, warn before a call runs out, or decide whether to start one. It is read only (recharges happen in the dashboard) and authenticated with your API secret, so call it from your server.

  • canJoin says whether a newcomer would be admitted now. When it is false, blocked is the code a join or token would be refused with: quota-exhausted, daily-limit-reached, monthly-limit-reached, tenant-suspended or billing-unavailable.
  • legacy are the minutes spent first, in participant-seconds (-1 and unlimited: true on an unlimited plan).
  • wallet is null unless the project is billed from the wallet. It lists only the shares this project may spend, in the order it spends them: its own share (project:<id>), the RTC share (product:rtc), then unallocated. Never another project's.
  • burn is what this project's rooms and broadcasts cost per second now, and what every project drawing the same wallet costs. remainingSeconds is how long the funds last at that rate (-1 when nothing is being spent or nothing limits it).
  • walletLow.active is true while the project's rooms are being warned. pricingVersion is the price list new sessions start on.

Amounts are strings. Money is in credit units: 1 credit = 6,000,000 units, and every amount ending in Cu is a decimal string, because a JSON number is a float and rounds a large balance without telling you. Add them up with BigInt; show availableCredits, which is already formatted (at most four decimals, rounded down):

const CU_PER_CREDIT = 6_000_000n;
const cu = BigInt(balance.wallet?.availableCu ?? "0");
const wholeCredits = cu / CU_PER_CREDIT;

Rates

const rtc = new StreamClient({
  baseUrl: process.env.VOLTKIT_RTC_URL,
  apiKey: process.env.VOLTKIT_RTC_SECRET,
  ratesBaseUrl: process.env.VOLTKIT_API_URL,   // the VoltKit website API, for getRates()
});
const rates = await rtc.getRates();
// -> { version, effectiveAt, unit, tiers: [{ id: "video_30", credits: "99.00", cuPerParticipantSecond: "9900", ... }],
//      surcharges, idleTier, modes, conversion, notes }

The public price list: each rate in credits per 1,000 participant-minutes, the media modes that are switched on, and the conversion for new recharges. Prices are set by VoltKit on the website, so getRates() reads the website API named by ratesBaseUrl, and sends it no credential: the API secret only ever goes to baseUrl. Without ratesBaseUrl it fails with rates-unconfigured. A rate's cuPerParticipantSecond is exact, so a cost is integer arithmetic:

const video = rates.tiers.find((tier) => tier.id === "video_30");
const cost = BigInt(video.cuPerParticipantSecond) * 4n * 30n * 60n;   // 4 people for 30 minutes: 71280000n units, 11.88 credits

Redirects are never followed

From 0.4.0 a request that carries your API secret never follows a redirect. A 3xx answer fails at once with the code redirected; the message names where it pointed, and it is not retried. Before, a redirect to another host would have carried the X-API-Key header with it.

If your baseUrl reached the service through a redirect (an http:// address upgraded to https://, or a www. rewrite in front), it now fails: set baseUrl to the exact scheme and host of your API base URL, as the Credentials tab shows it. getRates() sends no secret and may still follow redirects.

Errors

Every failure is a StreamError with code, status and retryable. The API secret never appears in a message, so an error is safe to log.

try {
  await rtc.ensureRoom({ roomId, hostIdentity });
} catch (err) {
  if (err.isOutOfMinutes) return res.status(402).send("Top up to go live");
  if (err.isNotFound) return res.sendStatus(404);
  throw err;
}

Temporary failures are retried with backoff on calls where repeating is harmless. createRoom, createStream and sendMessage are never retried, because a lost response would otherwise become a duplicate.

From 0.4.0:

  • isBillingUnavailable is true for billing-unavailable (503): the project is billed from the wallet and no price list is active yet. It lasts until VoltKit fixes it, so it is not retried.
  • redirected is never retried either: the same baseUrl redirects the same way (see Redirects are never followed).
  • ERROR_CODES and REST_ERROR_CODES list the codes of the registry shared by every VoltKit SDK, and err.code is typed with them. A newer service may send a code a release does not know; it is passed through unchanged.
  • A successful reply that is not JSON fails with a StreamError (http-<status>) instead of a SyntaxError.

Webhooks

const { parseWebhook, StreamError } = require("@voltkit-rtc/server");

app.post("/hooks/voltkit", express.raw({ type: "*/*" }), (req, res) => {
  let event;
  try {
    event = parseWebhook(req.body, req.headers["x-stream-signature"], process.env.VOLTKIT_RTC_WEBHOOK_SECRET);
  } catch (err) {
    return res.sendStatus(err instanceof StreamError ? 401 : 400);
  }
  res.sendStatus(200);   // acknowledge first
  queue.push(event);     // then do the work
});

Pass the raw request body. The signature is computed over the exact bytes that were sent, so a parsed and re-serialised body can never verify. See Webhooks.

Every code is listed on the Errors page, and every call's request and response on the API reference.

Without the SDK

Nothing in the SDK is required. Any HTTP client in any language can make the same calls: see the API reference.

Help

Questions and bug reports: contact@voltkit.in.

Last updated . Something unclear or wrong? Tell us.