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.1, which does everything @voltkit-rtc/server 0.4.8
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, a trailing / and whitespace around it are dropped; a query, a fragment, a scheme other than http(s) or a port that is not a number is refused. |
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 sending the request 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_roomcreates the room, or returns the live one with a fresh host token.existedsays which, so you can tell a new "go live" from a host reconnecting after a dropped call.create_roomis the strict version: it fails withroom-exists(409) if the id is taken, and makes up an id when you leaveroom_idout.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_roomon a room that exists returns the live one and ignores it.roleishost,cohost,moderatororviewer. 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_roomfor 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
legacyare the minutes spent first, in participant-seconds. On an unlimited planunlimitedisTrue, andownSecondsandtotalSecondsare-1.walletisNoneunless 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), thenunallocated. Never another project's.burnis what this project's rooms and broadcasts cost per second now, and what every project drawing the same wallet costs.remainingSecondsestimates how long the funds last at that rate (-1when nothing is being spent or nothing limits it).walletLow["active"]isTruewhile the project's rooms are being warned.pricingVersionis the price list new sessions start on:Nonefor 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):
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", "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 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_ratesfails at once withrates-unconfigured(status0) and sends nothing. - A
rates_base_urlthat names something other than the website API gets a reply with no rates in it. The call fails withhttp-<status>and is not retried. - The website API's own error codes are passed through, such as
RATE_LIMITED(429, retried) orNOT_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). An id of "", "." or ".." is refused before anything is
sent, since a URL reads those as steps through the path (see
Mistakes caught before a request).
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 sending the request to reading the last byte of the reply. A service that does not answer, or a reply that stalls after its headers, fails withnetworkand "timed out after 5000ms". - What is retried. A call that is safe to repeat is tried again after a
networkfailure (no answer, a refused or dropped connection, the timeout), a429or a5xx. 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 a2xxreply that is not JSON. - 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
networkerror or a5xxmay 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_ratessends nothing torates_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 (http-200). 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, \"). 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__.
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 is not an absolute http(s) URL with a host, or that carries a query, a fragment or a port that is not a number (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 not a str is a TypeError |
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, or a bad tolerance_seconds |
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.
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. 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.
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
410disables the endpoint. - Deduplicate on
event["id"]. Delivery is at-least-once by design; theX-Stream-Deliveryheader carries the same id, andX-Stream-Attemptthe attempt number. - Do not assume ordering. A
participant.leftcan arrive before theparticipant.joinedit followed; pair them byidentityandjoinedAt.
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. 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 the deadline checked before the connect, before the send and
before each read of the body. 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.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.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| voltkit_rtc_server-0.1.1.tar.gz | 107.0 kB | Without provenance | Details for voltkit_rtc_server-0.1.1.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| voltkit_rtc_server-0.1.1-py3-none-any.whl | Python 3 | none | any | Details for voltkit_rtc_server-0.1.1-py3-none-any.whl |
Total release size: 158.1 kB
Release files / voltkit_rtc_server-0.1.1.tar.gz
| Download URL | voltkit_rtc_server-0.1.1.tar.gz |
|---|---|
| Size | 107.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
9698e63364222c45fc9c66601bc88c8e920a160c689b9a91746434fb1124ad31
|
|
BLAKE2b-256 checksum How to use checksums |
22af27dee360f10aa05b81a299250a45745eb60953a7ad5c3d27ee5945c79817
|
| 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.1-py3-none-any.whl
| Download URL | voltkit_rtc_server-0.1.1-py3-none-any.whl |
|---|---|
| Size | 51.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
91c81a88d8bbff7c82f88d83fa72bc8fee09ea0636a9c808528cd34ffe17a66c
|
|
BLAKE2b-256 checksum How to use checksums |
8280b113aee0caede7314ef7c27e5a865c82f2f787cf4169cb386ffa8cebc1c7
|
| 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.1
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.