Skip to main content

voltkit-rtc-server

Python (server). The VoltKit RTC SDK for your backend: rooms, tokens, chat, usage, billing and verified webhooks. Python 3.9 or newer, no dependencies, typed.

This is the server-side package: it holds your API secret, so it runs on your servers and nowhere else. Apps and web pages use a client SDK instead (JavaScript and React in the browser, Flutter for iOS and Android apps, Android for Kotlin apps): https://voltkit.in/docs.

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; there is no stream to proxy and nothing that scales with viewers.

pip install voltkit-rtc-server
import os
from voltkit_rtc.server import StreamClient

rtc = StreamClient(
    os.environ["VOLTKIT_RTC_URL"],  # your API base URL, from the project's Credentials tab
    os.environ["VOLTKIT_RTC_SECRET"],  # your API secret: server-side only, never sent to a client
    rates_base_url=os.environ.get("VOLTKIT_API_URL"),  # optional, for get_rates: the website API origin
)

The import path is voltkit_rtc.server. voltkit_rtc itself is a namespace shared by VoltKit's Python packages and holds nothing of its own, so import voltkit_rtc alone gives no StreamClient: always import from voltkit_rtc.server.

The API secret is tenant-wide, so it stays on your server, and the SDK attaches it only to requests for base_url, never across a redirect. A 3xx from base_url fails with redirected instead of being followed, so the key cannot be handed to whatever host the redirect names: point base_url at the controller itself, with its exact scheme and host (https://, no www. rewrite in front of it).

Rooms ensure_room create_room create_token get_room list_rooms list_participants delete_room
Chat create_chat_token send_message list_messages delete_message mute_chat unmute_chat list_chat_mutes get_chat_info
Usage get_usage get_usage_series
Billing get_balance get_rates
Webhooks parse_webhook verify_webhook
Broadcast create_stream create_stream_token get_stream list_streams update_stream delete_stream (not offered on VoltKit cloud today)

This README describes 0.1.3, which does everything @voltkit-rtc/server 0.4.10 does, the same way: the same requests, replies, retries, errors and redaction, pinned by a shared set of test vectors.

Create a client

rtc = StreamClient("https://api.voltkit.in", os.environ["VOLTKIT_RTC_SECRET"])

Make one client when your server starts and use it everywhere: it holds no per-call state and is safe to share between threads. The constructor checks every option, so a missing or malformed value fails at startup instead of on a user's first call (see Mistakes caught before a request).

Option Default What it does
base_url required Your API base URL, with the exact scheme and host the Credentials tab shows. The API secret is sent only here. A path prefix is kept, whitespace around it and then a trailing / are dropped; a URL no request can use is refused when the client is made: no http:// or https://, no host, a query, a fragment, a port that is not a number from 1 to 65535, or a space, a control character, a backslash or a character outside ASCII inside it (percent-encode it, or use the host's ASCII form).
api_key required Your API secret. Whitespace around it is ignored.
rates_base_url None VoltKit's website API, https://api.voltkit.in, where get_rates reads the price list. It is never sent the secret.
timeout 10.0 The deadline for each attempt, in seconds, from connecting to reading the last byte of the reply.
retries 2 How many more attempts a call that is safe to repeat gets after a temporary failure. 0 turns retries off.
transport HttpClientTransport() Your own Transport: a proxy, instrumentation, an httpx client, or a test double. See Testing.

StreamClient.from_env() reads the same three values from VOLTKIT_RTC_URL, VOLTKIT_RTC_SECRET and the optional VOLTKIT_API_URL, and fails at once, naming the variable, when one is missing. The plain constructor reads nothing from the environment: a key should be a value the code names.

rtc = StreamClient.from_env(timeout=5, retries=1)

Never ship the API secret in a web page or a mobile app. Anyone who has it can create rooms and mint tokens as you. If it leaks, revoke it from the Credentials tab and issue a new one.

Rooms and tokens

# Create the room, or get it back 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"}

view = rtc.get_room("show-42")  # {"room", "participants", "producers"}: the authoritative view
rooms = rtc.list_rooms()["rooms"]  # every live room; list_rooms returns the object, not a list
people = rtc.list_participants(
    "show-42"
)  # [{"identity", "role", "handRaised", "muted", "joinedAt", "metadata"}, ...]
rtc.delete_room("show-42")  # ends it for everyone
  • ensure_room creates the room, or returns the live one with a fresh host token. existed says which, so you can tell a new "go live" from a host reconnecting after a dropped call. create_room is the strict version: it fails with room-exists (409) if the id is taken, and makes up an id when you leave room_id out.
  • metadata (any JSON) on either call is room state that every participant receives when they join. It is applied only when the room is created: ensure_room on a room that exists returns the live one and ignores it.
  • role is host, cohost, moderator or viewer. It is signed into the token, so the client cannot change it. A token lasts ten minutes; a connected client is sent fresh ones, so a call can run for hours.
  • Poll get_room for a live count rather than adding up webhook joins and leaves: delivery is at-least-once and unordered, so a counter built from events drifts.

Replies are the controller's JSON as plain dicts and lists, camelCase keys included, typed for your editor (RoomWithHostToken, RoomToken, Participant and the rest in voltkit_rtc.server.types). A field a newer controller adds comes through as it is.

Chat

Every room has a chat channel of the same name, and a channel does not need a room: show-42 can be chat for a broadcast, a lobby, or nothing but chat.

# Admit someone to a channel's chat with no media rights (role defaults to viewer).
chat = rtc.create_chat_token("show-42", identity="user-99")
# -> {"token", "wsUrl", "role", "identity", "channel", "expiresAt"}      wsUrl ends in /ws/chat

# Take part without holding a connection.
rtc.send_message("show-42", text="The show starts in 5 minutes", type="system")
rtc.send_message("show-42", type="gift", from_="user-7", metadata={"gift": "rose", "count": 5})
rtc.send_message("show-42", type="reaction", metadata={"emoji": "heart"}, ephemeral=True)  # not stored
rtc.send_message("show-42", to="user-13", text="Please keep it friendly")  # a direct message

messages = rtc.list_messages("show-42", limit=100)  # oldest first
older = rtc.list_messages("show-42", before=messages[0]["id"])  # the page before that
rtc.delete_message("show-42", message_id)
rtc.mute_chat("show-42", "user-13")  # survives reconnecting; they can still read
rtc.unmute_chat("show-42", "user-13")
rtc.list_chat_mutes("show-42")  # -> ["user-13"]
rtc.get_chat_info("show-42")  # -> {"channel", "members"}

A message sent from your backend can be trusted in a way a client-sent one cannot, which makes it the right place for gifts, purchases and system notices. from_ (the wire's from) defaults to system, and you may also speak as one of your own users. Backend messages skip the per-connection rate limit and the mute list, which exist to restrain clients. A message needs text or metadata; text is capped at 4096 bytes.

list_messages returns the newest limit messages (50 by default, at most 100), oldest first, direct messages included: your backend owns the conversation. Pass the oldest id you hold as before to page back.

send_message is never retried: a lost reply would post the message twice.

Usage

usage = rtc.get_usage()
# -> {"minutesRemaining", "today", "month", "lifetime", "allowance", ...}
days = rtc.get_usage_series(30)  # one entry per UTC day, oldest first, ending today

Each period carries participantSeconds, chatMessages, broadcastSeconds, broadcastViewerSeconds and more. minutesRemaining is -1 on an unlimited plan. allowance breaks it down: free minutes left today and this month, the paid balance, the daily and monthly limits, and blocked with the reason when the project cannot be used. get_usage_series goes back up to 90 days (days above 90 reads as 90, 0 or less as 30), and a day with no usage is present and zero, so a chart drawn from it has no gaps.

When an account has nothing left, new rooms, tokens and streams fail with is_out_of_minutes (quota-exhausted, daily-limit-reached or monthly-limit-reached), and anything live is warned and then ended. The usage.limit_reached webhook tells your backend when that starts.

Billing

Balance

balance = rtc.get_balance()
# -> {"tenantId", "billingMode": "wallet", "canJoin": True, "blocked": None,
#     "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"}

What your project can spend right now: 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 and hand a browser only what it needs to show.

canJoin says whether a newcomer would be admitted now. When it is False, blocked is the code a join or a token would be refused with, so you can say why before anyone tries:

balance = rtc.get_balance()
if not balance["canJoin"]:
    if balance["blocked"] == "tenant-suspended":
        return error(403, "This account is suspended")
    if balance["blocked"] == "billing-unavailable":
        return error(503, "Billing is being set up")
    return error(402, "Recharge to go live")  # quota-exhausted, daily- or monthly-limit-reached
  • legacy are the minutes spent first, in participant-seconds. On an unlimited plan unlimited is True, and ownSeconds and totalSeconds are -1.
  • wallet is None unless the project is billed from the wallet (billingMode: "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 estimates 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: None for a legacy project, and while no price list is active.

Amounts are strings. Money is in credit units (cu): 1 credit = 6,000,000 cu, and every cu amount (paidCu, availableCu, projectCuPerSecond and the rest) is a decimal string, because a JSON number is a float and rounds a large balance without telling you. Add them up with int, which has no limit; show availableCredits, which is already formatted (at most four decimals, rounded down). The SDK converts nothing: every field comes back exactly as the API sent it, so an amount is a str, and int() of one refuses anything but a whole number (int("1000.5") is a ValueError). Call int() on the string only: int() of a float, which no VoltKit reply holds in these fields, drops the fraction without a word.

CU_PER_CREDIT = 6_000_000
cu = int(balance["wallet"]["availableCu"]) if balance["wallet"] else 0  # "186000000" -> 186000000
whole_credits, remainder_cu = divmod(cu, CU_PER_CREDIT)  # 31 credits, 0 cu over

On a controller without wallet billing, get_balance fails with billing-unavailable (err.is_billing_unavailable), which is not retried.

Rates

rtc = StreamClient.from_env()  # with VOLTKIT_API_URL=https://api.voltkit.in, the website API's origin
rates = rtc.get_rates()
# -> {"version", "kind": "standard", "effectiveAt", "unit",
#     "tiers": [{"id": "video_30", "credits": "99.00", "cuPerParticipantSecond": "9900", ...}, ...],
#     "surcharges", "addons": {"music": {"price": "10.00", "cuPerParticipantSecond": "1000", "unit", "appliesTo", "includedIn"}},
#     "idleTier", "modes", "conversion", "notes"}

The Music add-on (0.1.2). rates["addons"]["music"] prices in-app music (publishMusic in the client SDKs): Music is an add-on on standard and HD voice (₹10 per 1,000 participant-minutes while music plays, enabled in the project's Settings → Media and quality); included with video. price is in credits per 1,000 participant-minutes and cuPerParticipantSecond the same as an exact integer; appliesTo names the voice tiers it is billed on top of and includedIn the video tiers that include it. addons is optional: a price list from before the add-on has no such key. music is an add-on id, not a tier: a usage line with tier: "music" (the settlement feed, the console's usage, the invoice) is the add-on's participant-minutes next to the participant's own tier line. Types: RtcAddons, RtcAddonRate.

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 get_rates reads GET /api/v1/billing/rtc/rates/current from the website API named by rates_base_url, and sends it no credential: no API key and no Authorization header. The secret only ever goes to base_url. The website caches the list for 30 seconds; cache it on your side too if you read it often.

  • Without rates_base_url, get_rates fails at once with rates-unconfigured (status 0) and sends nothing.
  • A rates_base_url that names something other than the website API gets a reply with no rates in it. The call fails with http-<status> and is not retried.
  • The website API's own error codes are passed through, such as RATE_LIMITED (429, retried) or NOT_FOUND.
  • The rates carry no credential and may follow a redirect; a request with the key never does.

A rate's cuPerParticipantSecond is exact, so a cost is integer arithmetic:

video = next(tier for tier in rates["tiers"] if tier["id"] == "video_30")
cost = int(video["cuPerParticipantSecond"]) * 4 * 30 * 60  # 4 people for 30 minutes: 71280000 cu, 11.88 credits
affordable = int(balance["wallet"]["availableCu"]) >= cost  # rough: free and bought minutes are spent first

Every method

"Retried" means the SDK tries the call again after a temporary failure (see Timeouts and retries). Keyword-only arguments are optional; None leaves a field out of the request.

Method Request Retried Returns
ensure_room(room_id, host_identity, *, metadata) PUT /v1/rooms/{roomId} Yes the room, with hostToken, wsUrl, expiresAt and existed
create_room(host_identity, *, room_id, metadata) POST /v1/rooms No the same, with existed: False
create_token(room_id, identity, role) POST /v1/rooms/{roomId}/tokens Yes {"token", "wsUrl", "role", "identity", "roomId", "expiresAt"}
get_room(room_id) GET /v1/rooms/{roomId} Yes {"room", "participants", "producers"}
list_rooms() GET /v1/rooms Yes {"rooms": [...]}
list_participants(room_id) GET /v1/rooms/{roomId}/participants Yes a list of participants
delete_room(room_id) DELETE /v1/rooms/{roomId} Yes None
create_chat_token(channel, identity, *, role) POST /v1/chat/{channel}/tokens Yes {"token", "wsUrl", "role", "identity", "channel", "expiresAt"}
get_chat_info(channel) GET /v1/chat/{channel} Yes {"channel", "members"}
list_messages(channel, *, limit, before) GET /v1/chat/{channel}/messages Yes a list of messages, oldest first
send_message(channel, *, text, type, from_, to, metadata, ephemeral) POST /v1/chat/{channel}/messages No the message
delete_message(channel, message_id) DELETE /v1/chat/{channel}/messages/{messageId} Yes None
mute_chat(channel, identity) PUT /v1/chat/{channel}/mutes/{identity} Yes None
unmute_chat(channel, identity) DELETE /v1/chat/{channel}/mutes/{identity} Yes None
list_chat_mutes(channel) GET /v1/chat/{channel}/mutes Yes a list of identities
get_usage() GET /v1/usage Yes {"minutesRemaining", "today", "month", "lifetime", "allowance", ...}
get_usage_series(days=30) GET /v1/usage/series?days={days} Yes a list of days, oldest first
get_balance() GET /v1/billing/balance Yes the balance
get_rates() GET /api/v1/billing/rtc/rates/current on rates_base_url, with no key Yes the price list
create_stream(stream_id, *, playback, metadata) POST /v1/streams No the stream, with publish
create_stream_token(stream_id, *, action, identity, ttl_seconds) POST /v1/streams/{streamId}/tokens Yes {"token", "expiresAt"} with whipUrl, or with llhls and webrtc
get_stream(stream_id) GET /v1/streams/{streamId} Yes the stream
list_streams() GET /v1/streams Yes a list of streams
update_stream(stream_id, *, playback, metadata) PATCH /v1/streams/{streamId} Yes the stream
delete_stream(stream_id) DELETE /v1/streams/{streamId} Yes None

parse_webhook and verify_webhook are functions of their own: see Webhooks. Every request and reply is in the API reference.

Ids go into the path percent-encoded, as one segment (show 42 is sent as show%2042). A number is an id too, sent as its digits (42). An id of "", "." or "..", or one that is no string or number at all (None, a dict, a list, a bool), is refused before anything is sent (see Mistakes caught before a request).

Replies are checked

A 2xx reply must be what the call needs, or the call raises StreamError http-<status> (http-200), retryable False, after one attempt, with a message naming what is missing and quoting nothing of the reply, such as POST /v1/rooms/show-42/tokens: the reply is not the expected shape: "token" is not a non-empty string. VoltKit always sends these fields, so such a reply came from something else: a proxy, a stub, the wrong base_url.

Calls The reply must be
create_token, create_chat_token, create_stream_token an object with a non-empty string token
create_room, ensure_room an object with a non-empty string roomId (existed is added after the check)
get_room an object with an object room
list_rooms an object with a list rooms
get_chat_info an object with a string channel
send_message an object with a non-empty string id
create_stream, get_stream, update_stream an object with a non-empty string streamId
get_usage, get_balance an object
list_participants, list_messages, list_chat_mutes, list_streams, get_usage_series an object whose list (participants, messages, muted, streams, series) is a list, or missing for []; null is refused
deletes and mutes anything JSON, or nothing: they return None
get_rates the website API's {"success", "data"} with an object data

An empty body is no object. Fields the SDK does not name are kept and not checked: the API adds fields without notice.

Timeouts and retries

rtc = StreamClient.from_env(timeout=5, retries=1)  # each attempt: headers and body; at most one more attempt
  • Timeout. timeout (default 10 seconds) is the deadline for each attempt, from connecting to reading the last byte of the reply: the TLS handshake, the headers and the body each wait only for what is left of it, however slowly a server sends them. A service that does not answer, or a reply that stalls or drips, fails with network and "timed out after 5000ms".
  • What is retried. A call that is safe to repeat is tried again after a network failure (no answer, a refused or dropped connection, the timeout), a 429 or a 5xx. The waits are about 200 ms, then 400 ms, doubling each time, plus up to 100 ms at random, so a fleet of servers does not retry in step.
  • What is not. Any other 4xx (the service made a decision and will make it again), billing-unavailable (a 503 that lasts until VoltKit acts), redirected, and a 2xx reply that is not JSON or not the shape the call needs (see Replies).
  • How long a call can take. Up to (retries + 1) × timeout, plus the waits between attempts: about 31 seconds with the defaults. In a request a user is waiting on, lower both.

When an error reaches you with retryable True, the SDK has already spent its retries. Try again later, from a job queue or the user's next action, rather than at once in a loop.

Calls that are never repeated

create_room, create_stream and send_message are sent once, however they fail. If a reply is lost, repeating them would turn a success into a spurious room-exists or stream-exists, or post the message twice. Every other call is safe to repeat: tokens are independent, reads change nothing, ensure_room returns the room that exists, and a mute or a delete done twice ends the same way.

When one of the three fails with retryable True, you decide:

  • A 429, whatever its code, is a refusal: nothing was done. Send it again after a pause.
  • A network error or a 5xx may have done the work before the reply was lost. Check first:
Call Check, then repeat
create_room Call ensure_room with the same room_id: it returns the room if your call made it, or makes it. Use ids nobody else uses, such as a UUID.
create_stream Call get_stream(stream_id). If the stream is there, mint a publish URL with create_stream_token(stream_id, action="publish"), since only create_stream returns one. If it fails with is_not_found, create it again.
send_message Put your own id in metadata, and look for it in list_messages(channel) before sending again. An ephemeral message is never stored, so it cannot be found: send it again or drop it.
import time
from voltkit_rtc.server import StreamError


def announce_gift(channel: str, gift_id: str, sender: str, name: str) -> dict:
    """Announce a gift once, even when a reply is lost."""
    args = dict(type="gift", from_=sender, metadata={"giftId": gift_id, "gift": name})
    try:
        return rtc.send_message(channel, **args)
    except StreamError as err:
        if not err.retryable:
            raise
        if err.status != 429:
            # It may have been stored before the reply was lost: look before sending again.
            for message in rtc.list_messages(channel, limit=100):
                if (message.get("metadata") or {}).get("giftId") == gift_id:
                    return message
        time.sleep(1)
        return rtc.send_message(channel, **args)

Deletes that lost their reply

delete_room, delete_message, unmute_chat and delete_stream hold across a lost reply. When an attempt fails in a way that may have reached the service (a network error or a 5xx) and the retry answers 404, the first attempt did the deleting, so the call returns normally. A 404 on the first attempt still raises, as does one after a 429 (which deleted nothing) or one saying chat or broadcast is switched off (chat-disabled, broadcast-disabled).

To treat "already gone" as done, catch it:

try:
    rtc.delete_room(room_id)
except StreamError as err:
    if not err.is_not_found:  # it had already ended
        raise

Where your API secret goes

The SDK sends your API secret in the X-API-Key header of requests to base_url, and nowhere else:

  • get_rates sends nothing to rates_base_url.
  • A redirect is never followed with it (below).
  • It never appears in an error or in the client object: see Safe to log.

Every request also carries User-Agent: voltkit-rtc-python/<version>, and nothing more: no OS, runtime version or hostname.

Redirects are never followed

A request that carries your API secret never follows a redirect: following one would hand X-API-Key to whatever host it named. A 3xx answer fails at once with the code redirected: status is the 3xx, the message names where it pointed, and it is not retried, because the same base_url redirects the same way.

If your base_url reached the service through a redirect (an http:// address upgraded to https://, or a www. rewrite in front), set it to the exact scheme and host of your API base URL, as the Credentials tab shows it. get_rates sends no secret and may follow redirects, as a website API behind a www. or HTTPS upgrade needs.

Errors

A call that fails raises a StreamError, a subclass of Exception. Branch on code, and log message. A mistake in your own setup is a TypeError or ValueError instead, raised before anything is sent: see Mistakes caught before a request. Every code from every VoltKit SDK, and what to do about it, is on Errors.

Handling a failed call

With Flask (a complete version is in examples/flask_token_server.py):

from flask import Flask, jsonify, request
from voltkit_rtc.server import StreamClient, StreamError

app = Flask(__name__)
rtc = StreamClient.from_env()


@app.post("/shows/<show_id>/join")
def join(show_id):
    user = current_user()  # your login
    try:
        grant = rtc.create_token("show-%s" % show_id, identity="user-%s" % user.id, role="viewer")
    except StreamError as err:
        app.logger.error("create_token failed: %s", err)  # safe: the API secret is never in it
        if err.is_not_found:
            return jsonify(error="This show has ended"), 404  # no-room
        if err.is_out_of_minutes:
            return jsonify(error="This show cannot take viewers right now"), 402
        if err.code == "viewer-quota":
            return jsonify(error="This show is full"), 429
        if err.retryable:
            return jsonify(error="Try again in a moment"), 503, {"Retry-After": "10"}
        raise  # 401, 403, 400: a problem on your side, answered as a 500 by your error handler
    return (
        jsonify(token=grant["token"], wsUrl=grant["wsUrl"], expiresAt=grant["expiresAt"]),
        200,
        {"Cache-Control": "no-store"},
    )

With FastAPI (examples/fastapi_token_server.py), the endpoint is a plain def, so the blocking call runs in the thread pool:

from fastapi import FastAPI
from fastapi.responses import JSONResponse
from voltkit_rtc.server import StreamClient, StreamError

app = FastAPI()
rtc = StreamClient.from_env()


@app.post("/shows/{show_id}/join")
def join(show_id: str):
    try:
        grant = rtc.create_token("show-%s" % show_id, identity=current_user_identity(), role="viewer")
    except StreamError as err:
        if err.is_not_found:
            return JSONResponse({"error": "This show has ended"}, status_code=404)
        if err.is_out_of_minutes:
            return JSONResponse({"error": "This show cannot take viewers right now"}, status_code=402)
        if err.retryable:
            return JSONResponse({"error": "Try again in a moment"}, status_code=503, headers={"Retry-After": "10"})
        raise
    return JSONResponse(
        {"token": grant["token"], "wsUrl": grant["wsUrl"], "expiresAt": grant["expiresAt"]},
        headers={"Cache-Control": "no-store"},
    )

What a StreamError carries

Attribute Meaning
code The service's code, such as no-room or quota-exhausted. network when no reply arrived; http-<status> when a reply named no code.
status The HTTP status. 0 when nothing reached the service or the SDK refused the call itself; the 3xx for redirected.
retryable True for network, 429 and 5xx (except billing-unavailable): trying again later could work.
message The method and path, then what went wrong: POST /v1/rooms/show-42/tokens: room not found. Also what str(err) gives.
is_not_found status is 404: no such room, stream or message, or not yours. broadcast-disabled and chat-disabled are 404s too, so test is_broadcast_disabled first.
is_out_of_minutes status is 402, or the code is quota-exhausted, daily-limit-reached or monthly-limit-reached.
is_quota The code is room-quota, or status is 429: rate-limited, viewer-quota and stream-quota included.
is_capacity_exhausted capacity-exhausted or no-nodes: no capacity for a new room right now.
is_broadcast_disabled broadcast-disabled: broadcast is not switched on for this deployment.
is_billing_unavailable billing-unavailable: the project is billed from the wallet and no price list is active yet, or the service has no wallet billing.

Messages name things as the REST API and every VoltKit SDK do (roomId, baseUrl), so a log line reads the same whichever backend wrote it.

What to do, code by code

Every code a call can fail with. "Retried" means on a call that is safe to repeat; create_room, create_stream and send_message are sent once.

Code Status Retried What happened, and what to do
unauthorized 401 No The API secret is missing, wrong or revoked. Check VOLTKIT_RTC_SECRET, and that base_url is this project's. Alert yourself, and tell the user only that something failed. A cheap read at startup, such as get_usage(), catches it before your users do.
tenant-suspended 403 No The account is suspended. Contact support.
operator-only 403 No That route describes the service's own infrastructure; a project key cannot read it. Not something a backend should hit.
quota-exhausted 402 No The project's funds are spent. The project owner recharges; get_balance() says so before anyone tries (is_out_of_minutes).
daily-limit-reached 402 No The project's daily usage cap is reached. It resets with the UTC day; the owner can raise it (is_out_of_minutes).
monthly-limit-reached 402 No The project's monthly usage cap is reached. It resets with the month; the owner can raise it (is_out_of_minutes).
billing-unavailable 503 No The project is billed from its wallet and no price list is active yet, or the controller has no wallet billing. Contact support; a retry does not help (is_billing_unavailable).
bad-request 400 No The request was malformed: the message names the field (an id outside [A-Za-z0-9._-]{1,128}, an empty message, a bad before cursor, an unknown body field). Fix the call. With status 0 the SDK refused an id of "", "." or ".." itself.
bad-role 400 No role must be host, cohost, moderator or viewer. Fix the call.
no-room 404 No The room does not exist, or is another project's. Treat it as ended (is_not_found).
no-message 404 No That chat message does not exist, or is not yours. Treat it as gone (is_not_found).
no-stream 404 No No such broadcast stream. Treat it as gone (is_not_found).
not-found 404 No No such resource, such as a mute that was never set. Treat it as gone (is_not_found).
chat-disabled 404 No Chat is not switched on for this deployment. Not available here.
broadcast-disabled 404 No Broadcast is not switched on for this deployment (VoltKit cloud today). Not available here (is_broadcast_disabled).
room-exists 409 No That room id is already yours. Use ensure_room, or another id.
stream-exists 409 No That stream id is already in use. Use the existing stream, or another id.
rate-limited 429 Yes The project's API rate limit (600 requests a minute by default), or too many chat messages. Slow down; the SDK has already paused and retried (is_quota).
room-quota 429 Yes You are at your concurrent-room limit. End rooms you no longer need, or ask for a higher limit (is_quota).
viewer-quota 429 Yes The room's viewer limit is reached. Tell the viewer the room is full (is_quota).
stream-quota 429 Yes You are at your stream limit. End streams you no longer need (is_quota).
internal 500 Yes A failure on the service's side. Try again later; tell us if it repeats. A malformed room id on create_room or ensure_room currently answers this instead of bad-request.
unavailable 503 Yes A dependency could not be checked. Try again later.
no-nodes 503 Yes No media node is registered. Ask the user to try again shortly, and alert: new rooms are failing (is_capacity_exhausted).
capacity-exhausted 503 Yes Every media node is over its threshold. The one to alert on: it is about capacity, not correctness (is_capacity_exhausted).
bad-signature 401 No Raised by parse_webhook: the delivery was not signed with any secret you passed. Answer 401; see Webhooks.
network 0 Yes The request never reached the service, or the reply was lost: DNS, a refused or dropped connection, the timeout. Try again later. If it persists, check your server's outbound network.
rates-unconfigured 0 No get_rates() was called without rates_base_url. Pass the website API origin.
redirected 3xx No The service answered a key-carrying request with a redirect, which is never followed. Fix base_url: see Redirects are never followed.
http-<status> that status Only a 429 or 5xx A reply that named no code: a proxy's error page, or a success that is not JSON or not the shape the call needs (http-200, never retried). Check base_url, and anything between your server and VoltKit.

A code this release does not know (a newer controller's, or the website API's own RATE_LIMITED or NOT_FOUND from get_rates) is passed through unchanged; its retryable follows the status.

Safe to log

An error never carries your API secret, or the credentials of a URL (user:pass@ in base_url or rates_base_url), so logger.error("%s", err) and logger.exception(...) can take it whole. Wherever the transport, a dropped connection, a redirect or the service's reply quotes either one, message and code say [redacted] instead, however it was encoded: as is, URL-encoded by any encoder in either hex case, form-encoded, JSON-escaped (\/, /), as HTML character references (named, decimal or hex), or any of those escaped once more (%252F, \&quot;). A key over 256 characters is not looked for: words at least as long as it are shown as [not shown: it could not be checked for the API key]. A long message is cut only after that, so a cut cannot leave part of the secret behind.

The client object keeps them out of sight too. repr(client), str(client) and vars(client) never show the secret, and show base_url and rates_base_url with any credentials as [redacted]; requests still go to the URLs as given. pickle and copy refuse the client with a TypeError: a client is made where its key is, not moved. A TransportRequest prints with X-API-Key, Authorization and URL credentials as [redacted] too, for a transport of your own that logs what it sends. A network error keeps nothing of the transport's own exception, not even as __context__, and a reply that is not JSON keeps nothing of the decoder's (a json.JSONDecodeError holds the whole reply it could not read as .doc, key and all): the StreamError has no __cause__ and no __context__. Neither does the ValueError for a base_url no request can use, nor the TransportError the default transport raises for such a URL.

Error reporters that read the variables of each frame an exception passes through (Sentry by default, traceback with capture_locals=True) find neither the secret nor the reply as it came there either: every StreamError of a call is raised from a frame that holds neither, and parse_webhook drops its secrets before it raises bad-signature. Your own frames hold whatever you gave the client, and a mistake in its options (a TypeError or ValueError from the constructor) is raised with those options as its arguments.

A StreamError itself pickles and copies, so one raised in a worker process (multiprocessing, ProcessPoolExecutor, a task queue) reaches the parent with its code, status, message and retryable.

What a call returns is another matter: token, hostToken and publish["whipUrl"] are credentials for one room or stream. Hand each to the one client that needs it, and never log them.

The SDK logs nothing unless you ask: the logger voltkit_rtc.server (a child of voltkit_rtc, so a handler on either name receives it) gets one DEBUG line per attempt with the method, path, status or code, elapsed time and attempt number, and never a header, a body, a token or a URL.

Mistakes caught before a request

These fail at once, are never retried, and never quote your secret:

Mistake How it fails
base_url or api_key missing or empty TypeError from the constructor
api_key that is only whitespace, such as a bare line break from a file TypeError from the constructor
api_key holding a character no HTTP header can carry: a line break or a NUL inside it, another control character, or one above U+00FF TypeError from the constructor. Check how the secret is loaded
api_key holding a backslash or a character outside ASCII, such as an accented letter (no VoltKit key holds one) TypeError from the constructor
base_url or rates_base_url that no request can use: no http:// or https://, no host, a query, a fragment, a port that is not a number from 1 to 65535, or a space, a control character, a backslash or a character outside ASCII inside it (each would fail every call instead); timeout / retries that is not a number, such as a string read from the environment TypeError or ValueError from the constructor, never quoting the URL. Convert with float() / int(), and keep a default for an unset variable
timeout of 0, below 0 or NaN; retries below 0, fractional, NaN or infinite ValueError from the constructor
A from_env() without VOLTKIT_RTC_URL or VOLTKIT_RTC_SECRET TypeError naming the variable
An id of "", "." or "..", for a room, channel, message, identity or stream The call raises StreamError bad-request, status 0, and sends nothing: a URL reads those as steps through the path. Every other id is encoded and sent as given
An id that is no string or number: None, a dict, a list, a bool, a function, a NaN The call raises StreamError bad-request, status 0, roomId None: an id must be a string; nothing was sent, and sends nothing (a missing request parameter must not become a room called None). A number is sent as its digits
A body JSON cannot carry: a structure that refers to itself, a set, a datetime, a NaN in metadata The call raises a TypeError naming it, such as PUT /v1/rooms/show-42: the body cannot be sent as JSON: ..., with the encoder's error as its __cause__
get_rates() without rates_base_url The call raises StreamError rates-unconfigured, status 0
A webhook body that is not the raw bytes, no secret (empty or only whitespace), a bad tolerance_seconds, or a now that is not a finite number TypeError or ValueError from verify_webhook / parse_webhook

Whitespace around the secret, and around the URLs, is fine: it is trimmed before sending. A timeout above 2147483.647 seconds (2^31-1 ms, about 24.8 days), inf or an int too large for a float included, is held there; the wait between attempts is held at the same cap, however large retries is.

Error codes

Every code a VoltKit RTC SDK can report is in one shared registry, exported here in its order:

from voltkit_rtc.server import ERROR_CODES, REST_ERROR_CODES, error_info, is_terminal

ERROR_CODES["capacity-exhausted"]  # ErrorInfo(origin="server", terminal=False, retryable=True)
REST_ERROR_CODES  # the codes a call from this SDK can fail with, in order
error_info("no-room")  # the same lookup, None for a code this release does not know
is_terminal("no-room")  # True: a client SDK must not retry a join refused with it

REST_ERROR_CODES bounds a metrics label:

known = set(REST_ERROR_CODES)
label = err.code if err.code in known else "other"

Webhooks

Flask hands over the raw body with request.get_data(); the signature header is X-Stream-Signature. A complete receiver, with a worker thread and deduplication, is in examples/webhook_receiver.py.

import os
from flask import Flask, request
from voltkit_rtc.server import StreamError, parse_webhook

app = Flask(__name__)
SECRETS = [os.environ["VOLTKIT_RTC_WEBHOOK_SECRET"]]


@app.post("/hooks/voltkit")
def hook():
    try:
        event = parse_webhook(request.get_data(), request.headers.get("X-Stream-Signature"), SECRETS)
    except StreamError:
        return "", 401  # bad-signature
    queue.put(event)  # ack first, work after: a slow handler is retried
    return "", 200

With FastAPI or Starlette the raw body is await request.body(); with Django, request.body. Both must be read before anything parses the JSON:

from fastapi import FastAPI, Request, Response


@app.post("/hooks/voltkit")
async def hook(request: Request):
    raw = await request.body()
    try:
        event = parse_webhook(raw, request.headers.get("x-stream-signature"), SECRETS)
    except StreamError:
        return Response(status_code=401)
    await queue.put(event)
    return Response(status_code=200)
# Django
from django.http import HttpResponse
from django.views.decorators.csrf import csrf_exempt


@csrf_exempt
def hook(request):
    try:
        event = parse_webhook(request.body, request.headers.get("X-Stream-Signature"), SECRETS)
    except StreamError:
        return HttpResponse(status=401)
    handle_later(event)
    return HttpResponse(status=200)

The raw body matters. parse_webhook refuses a parsed object outright (a TypeError) rather than returning False: verifying re-serialised JSON can never match, and a silent False looks like an attack instead of the wiring mistake it is. The body may be bytes, a bytearray, a memoryview or a str (taken as UTF-8). The header may be a str, the bytes a raw WSGI or ASGI layer hands over, or a list of either (joined with commas); secrets a str or any iterable of them, a set or a generator included. The secret is used exactly as given, as the dashboard shows it: whitespace around it is not trimmed, so strip an environment variable yourself. A secret that is empty or only whitespace is no secret: it never verifies anything, and with no other secret given it is the "needs the signing secret" TypeError.

Verification is HMAC-SHA256 over <timestamp>.<raw body>, compared in constant time with every secret against every signature, with a freshness window (tolerance_seconds, default 300, in both directions, inclusive) that stops a captured request being replayed forever. The window is in whole seconds, the clock rounded down: 0 accepts the current second, and 300.9 seconds old passes the default. tolerance_seconds must be a number of 0 or more: nan or a negative raises a ValueError rather than switching the window off or refusing every delivery. verify_webhook is available when you want the boolean rather than the exception; parse_webhook raises StreamError with code bad-signature and status 401 and returns the decoded event: {"id", "type", "tenantId", "roomId"?, "createdAt", "data"?} (StreamEvent). For tests, now= replaces the clock: a number of seconds, where nan or an infinity is a ValueError and a string a TypeError, never a verdict (a nan clock used to switch the window off).

Rotating the secret. When you rotate the signing secret from the dashboard, VoltKit signs every delivery with both the new and the old secret for the overlap you choose (24 hours by default): t=<ts>,v1=<new>,v1=<old>. Both functions accept a delivery when any signature matches, and secrets may be a list, so you can deploy the new secret while the old one still works:

SECRETS = [s for s in (os.environ.get("VOLTKIT_RTC_WEBHOOK_SECRETS") or "").split(",") if s]
event = parse_webhook(request.get_data(), request.headers.get("X-Stream-Signature"), SECRETS)

Three things to get right on your side:

  • Ack immediately. A slow handler is retried (at 5 s, 30 s, 2 min, 10 min and 30 min), so your latency becomes duplicate deliveries. Answering 410 disables the endpoint.
  • Deduplicate on event["id"]. Delivery is at-least-once by design; the X-Stream-Delivery header carries the same id, and X-Stream-Attempt the attempt number.
  • Do not assume ordering. A participant.left can arrive before the participant.joined it followed; pair them by identity and joinedAt.

Every event type and its fields: https://voltkit.in/docs/webhooks.

Testing

Pass a transport to test your own code without a network. A transport is any object with send(request: TransportRequest) -> TransportResponse; the retries, redaction and parsing sit above it and behave as they do in production:

import json
from voltkit_rtc.server import StreamClient, TransportRequest, TransportResponse


class FakeTransport:
    def __init__(self):
        self.requests = []

    def send(self, request: TransportRequest) -> TransportResponse:
        self.requests.append(request)  # method, url, headers, body (bytes or None), timeout, follow_redirects
        body = {
            "token": "eyJ.fake",
            "wsUrl": "wss://fake",
            "role": "viewer",
            "identity": "u",
            "roomId": "r",
            "expiresAt": 1,
        }
        return TransportResponse(200, {}, json.dumps(body).encode())


fake = FakeTransport()
rtc = StreamClient("https://x.test", "sk_test", transport=fake, sleep=lambda seconds: None)
assert rtc.create_token("r", "u", "viewer")["token"] == "eyJ.fake"
assert fake.requests[0].headers["X-API-Key"] == "sk_test"

A transport that raises TimeoutError stands for the deadline passing; an OSError, http.client.HTTPException or voltkit_rtc.server.TransportError for any other failure in transit, and so does anything else it raises (an httpx.ConnectError, say): each is a network error with its words redacted. Only what is not an Exception, such as KeyboardInterrupt, passes through. sleep= replaces the wait between attempts and jitter= its randomness (a function returning a float in [0, 1)), so a test of your retry handling runs in no time.

The default HttpClientTransport uses http.client, one connection per exchange, with one deadline over all of it: each address tried, the TLS handshake, the send, and every receive of the status line, the headers, each chunk's framing and the body. Only the name lookup is the system's own. HttpClientTransport(ssl_context=...) takes your own ssl.SSLContext (a private CA, say).

The package's own test suite runs before every release: the shared vectors generated from the Node SDK (every request, reply, retry, redaction and webhook case), unit tests against a fake transport for every behaviour, real-server tests for the deadline, broken bodies and redirects, a thread-safety check, a packaging check and a live run against a VoltKit controller.

Versions

Version What changed
0.1.3 Prepared 2026-10-31, not published. The testers' report of 2026-10-31. No frame an error reporter reads (Sentry's locals, traceback with capture_locals=True) holds the API secret or the reply as it came when a StreamError is raised, and parse_webhook holds no secret when it raises bad-signature. A 2xx that is not the shape the call needs is http-<status>, not retried, instead of a success ({} passed as a token; see Replies are checked). The deadline covers the connect, the TLS handshake, the headers and chunk framing, not only the body: a server dripping its headers held a call for as long as it liked. Whatever a transport raises is a network error, never a raw ValueError. A base_url no request can use (a character outside ASCII, port 0) is refused when the client is made, quoting nothing of it. An id that is no string (None) is bad-request, a number is sent as its digits. Webhooks: whole seconds, a now that is not a finite number is an argument error, a whitespace-only secret is no secret. Redaction: URL passwords holding @ / ? #, references with leading zeros (&#x005F;). See CHANGELOG.md.
0.1.2 Published 2026-10-28. The Music add-on in get_rates: addons.music (price, cuPerParticipantSecond, unit, appliesTo, includedIn, id, label; absent from an older price list), types RtcAddons and RtcAddonRate; music is an add-on id, not a tier. A reply that is not JSON keeps no __context__ (the decoder's error held the whole reply, key and all, as .doc), nor does the error for a URL whose port is not a number. The examples' 502 on an incomplete token reply and 1 MB body cap (below). No method changed.
0.1.1 The adversarial review's fixes, no method changed: StreamError pickles and copies (a worker pool can return it); the wait between attempts is held at 2^31-1 ms however large retries is; a base_url with a query, a fragment, another scheme or a bad port is refused at startup and whitespace around a URL is dropped; a reply nested too deep for the decoder is http-<status>, not a RecursionError; a network error keeps no __context__; the signature header may be bytes and secrets any iterable; an int too large for a float is held or infinite, never an OverflowError; the TLS context is built once per transport; repr(TransportRequest) is redacted. See CHANGELOG.md.
0.1.0 The first release under this name: everything @voltkit-rtc/server 0.4.8 does, with the same behaviour. The voltkit-rtc 0.1.0 published on PyPI earlier the same day was the same code under the package's first name and is yanked. See CHANGELOG.md.

voltkit_rtc.server.__version__ is the installed version, and every request says it in User-Agent: voltkit-rtc-python/<version>.

Broadcast

Not offered on VoltKit cloud today: on a deployment without a media server, every stream call fails with broadcast-disabled (err.is_broadcast_disabled). The methods are here for parity with the API and the other SDKs, for a deployment that has one.

A broadcast is one publisher and an audience of any size, served as LL-HLS through a CDN. Use it when the audience watches and does not talk back; use a room when they do.

live = rtc.create_stream("show-42", playback="public")
# -> {"streamId", "streamKey", "state": "idle", "playback", "playbackUrls": {"llhls", "webrtc"},
#     "publish": {"whipUrl", "token", "expiresAt"}}

# The publish URL is returned only at creation. Mint another when you need one:
pub = rtc.create_stream_token("show-42", action="publish")  # -> {"whipUrl", "token", "expiresAt"}

# With playback="token", each viewer needs URLs minted for them. action defaults to "play".
play = rtc.create_stream_token("show-42", identity="user-99", ttl_seconds=3600)
# -> {"llhls", "webrtc", "token", "expiresAt"}

rtc.get_stream("show-42")  # state, viewers, peakViewers
rtc.list_streams()  # a list of streams
rtc.update_stream("show-42", playback="token")  # who may watch, or metadata
rtc.delete_stream("show-42")  # takes it off air; its URLs stop working

publish["whipUrl"] carries the publish token, so treat it like a password. ttl_seconds is capped at 24 hours. viewers counts the playback sessions the media server serves itself, not an audience behind a CDN. create_stream is never retried, for the same reason as create_room.

Help

Documentation: https://voltkit.in/docs/python-sdk. Questions and bug reports: contact@voltkit.in.

Metadata

Release files for voltkit-rtc-server 0.1.3

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for voltkit-rtc-server 0.1.3
File Size Uploaded Provenance Actions
voltkit_rtc_server-0.1.3.tar.gz 148.6 kB Without provenance Details for voltkit_rtc_server-0.1.3.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for voltkit-rtc-server 0.1.3
File Interpreter ABI Platform Actions
voltkit_rtc_server-0.1.3-py3-none-any.whl Python 3 none any Details for voltkit_rtc_server-0.1.3-py3-none-any.whl

Total release size: 209.2 kB

Release files / voltkit_rtc_server-0.1.3.tar.gz

Download URL voltkit_rtc_server-0.1.3.tar.gz
Size 148.6 kB
Tags Source
SHA-256 checksum
How to use checksums
0e3e8591f6250a0ab0de5faffbccc764344d0313378c998678098065a02e4869
BLAKE2b-256 checksum
How to use checksums
828c64ab35e8b9ae4a5d478a2b7adabd276840e10bea4e6e1a51c343ef859b60
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.14.8

Release files / voltkit_rtc_server-0.1.3-py3-none-any.whl

Download URL voltkit_rtc_server-0.1.3-py3-none-any.whl
Size 60.6 kB
Tags Python 3
SHA-256 checksum
How to use checksums
adb8fcb359a935ed36aa53e96ce5b85fcf334550bdae2ab7df8055c098f57661
BLAKE2b-256 checksum
How to use checksums
2fbc5d492f61455e963d66bded993ea569a86a0429c5560a16d49219f906069a
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.14.8

Security for voltkit-rtc-server 0.1.3

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page