Metadata-Version: 2.5
Name: voltkit-rtc-server
Version: 0.1.0
Summary: VoltKit RTC server SDK for Python backends (import voltkit_rtc.server): rooms, tokens, chat, broadcast, usage, billing and verified webhooks. Standard library only.
Project-URL: Homepage, https://voltkit.in/rtc
Project-URL: Documentation, https://voltkit.in/docs/python-sdk
Author: VoltKit
License-Expression: MIT
License-File: LICENSE
Keywords: backend,chat,live,rtc,sdk,server,streaming,video,voltkit,webhooks,webrtc
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Communications :: Conferencing
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.9
Description-Content-Type: text/markdown

# voltkit-rtc-server

**Python (server).** The [VoltKit RTC](https://voltkit.in/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.

```bash
pip install voltkit-rtc-server
```

```python
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](#rooms-and-tokens) | `ensure_room` `create_room` `create_token` `get_room` `list_rooms` `list_participants` `delete_room` |
| [Chat](#chat) | `create_chat_token` `send_message` `list_messages` `delete_message` `mute_chat` `unmute_chat` `list_chat_mutes` `get_chat_info` |
| [Usage](#usage) | `get_usage` `get_usage_series` |
| [Billing](#billing) | `get_balance` `get_rates` |
| [Webhooks](#webhooks) | `parse_webhook` `verify_webhook` |
| [Broadcast](#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.0, 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

```python
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](#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. |
| `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`](#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](#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.

```python
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

```python
# 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.

```python
# 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

```python
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

```python
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:

```python
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):

```python
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

```python
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_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:

```python
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](#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](#webhooks). Every request and reply is in the
[API reference](https://voltkit.in/docs/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](#mistakes-caught-before-a-request)).

## Timeouts and retries

```python
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
  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.
- **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. |

```python
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:

```python
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](#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](#mistakes-caught-before-a-request). Every
code from every VoltKit SDK, and what to do about it, is on
[Errors](https://voltkit.in/docs/errors).

### Handling a failed call

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

```python
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`](examples/fastapi_token_server.py)),
the endpoint is a plain `def`, so the blocking call runs in the thread pool:

```python
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](#calls-that-are-never-repeated).

| 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](#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](#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`, `\&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.

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 URL, or `timeout` / `retries` that is not a number, such as a string read from the environment | `TypeError` or `ValueError` from the constructor. 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 is fine: it is trimmed before sending. A
`timeout` above 2147483.647 seconds (2^31-1 ms, about 24.8 days), `inf`
included, is held there.

### Error codes

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

```python
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:

```python
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`](examples/webhook_receiver.py).

```python
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:

```python
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)
```

```python
# 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).

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:

```python
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:

```python
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.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](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.

```python
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.
