Metadata-Version: 2.5
Name: voltkit-rtc-server
Version: 0.1.4
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.4, which does everything `@voltkit-rtc/server` 0.4.11
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. A path prefix is kept, whitespace around it and then a trailing `/` are dropped; a URL no request can use is refused when the client is made: no `http://` or `https://`, no host, a query, a fragment, a port that is not a number from 1 to 65535, or a space, a control character, a backslash or a character outside ASCII inside it (percent-encode it, or use the host's ASCII form). |
| `api_key` | required | Your API secret. Whitespace around it is ignored. |
| `rates_base_url` | `None` | VoltKit's website API, `https://api.voltkit.in`, where [`get_rates`](#rates) reads the price list. It is never sent the secret. |
| `timeout` | `10.0` | The deadline for each attempt, in seconds, from connecting to reading the last byte of the reply. |
| `retries` | `2` | How many more attempts a call that is safe to repeat gets after a temporary failure. `0` turns retries off. |
| `transport` | `HttpClientTransport()` | Your own `Transport`: a proxy, instrumentation, an `httpx` client, or a test double. See [Testing](#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). The SDK converts nothing: every field comes back exactly as the API sent
it, so an amount is a `str`, and `int()` of one refuses anything but a whole
number (`int("1000.5")` is a `ValueError`). Call `int()` on the string only:
`int()` of a float, which no VoltKit reply holds in these fields, drops the
fraction without a word.

```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", "addons": {"music": {"price": "10.00", "cuPerParticipantSecond": "1000", "unit", "appliesTo", "includedIn"}},
#     "idleTier", "modes", "conversion", "notes"}
```

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

The public price list: each rate in credits per 1,000 participant-minutes, the
media modes that are switched on, and the conversion for new recharges. Prices
are set by VoltKit on the website, so `get_rates` reads
`GET /api/v1/billing/rtc/rates/current` from the website API named by
`rates_base_url`, and sends it **no credential**: no API key and no
`Authorization` header. The secret only ever goes to `base_url`. The website
caches the list for 30 seconds; cache it on your side too if you read it often.

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

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

```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`). A number is an id too, sent as its digits (`42`). An id of `""`,
`"."` or `".."`, or one that is no string or number at all (`None`, a `dict`,
a `list`, a `bool`), is refused before anything is sent (see
[Mistakes caught before a request](#mistakes-caught-before-a-request)).

### Replies are checked

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

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

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

## Timeouts and retries

```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 connecting to reading the last byte of the reply: the TLS
  handshake, the headers and the body each wait only for what is left of it,
  however slowly a server sends them. A service that does not answer, or a
  reply that stalls or drips, fails with `network` and "timed out after
  5000ms".
- **What is retried.** A call that is safe to repeat is tried again after a
  `network` failure (no answer, a refused or dropped connection, the timeout),
  a `429` or a `5xx`. The waits are about 200 ms, then 400 ms, doubling each
  time, plus up to 100 ms at random, so a fleet of servers does not retry in
  step.
- **What is not.** Any other `4xx` (the service made a decision and will make
  it again), `billing-unavailable` (a 503 that lasts until VoltKit acts),
  `redirected`, and a `2xx` reply that is not JSON or not the shape the call
  needs (see [Replies](#replies-are-checked)).
- **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 or not the shape the call needs (`http-200`, never retried). Check `base_url`, and anything between your server and VoltKit. |

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

### Safe to log

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

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

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

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

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

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

### Mistakes caught before a request

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

| Mistake | How it fails |
| --- | --- |
| `base_url` or `api_key` missing or empty | `TypeError` from the constructor |
| `api_key` that is only whitespace, such as a bare line break from a file | `TypeError` from the constructor |
| `api_key` holding a character no HTTP header can carry: a line break or a NUL inside it, another control character, or one above U+00FF | `TypeError` from the constructor. Check how the secret is loaded |
| `api_key` holding a backslash or a character outside ASCII, such as an accented letter (no VoltKit key holds one) | `TypeError` from the constructor |
| `base_url` or `rates_base_url` that no request can use: no `http://` or `https://`, no host, a query, a fragment, a port that is not a number from 1 to 65535, a host holding anything but letters, digits, `.`, `-`, `_` and `~` (a quote left from a `.env` file, `|`, `^`, `%zz`), or a space, a control character, a backslash or a character outside ASCII inside it (each would fail every call instead); `timeout` / `retries` that is not a number, such as a string read from the environment | `TypeError` or `ValueError` from the constructor, never quoting the URL. Convert with `float()` / `int()`, and keep a default for an unset variable |
| `timeout` of `0`, below `0` or NaN; `retries` below `0`, fractional, NaN or infinite | `ValueError` from the constructor |
| A `from_env()` without `VOLTKIT_RTC_URL` or `VOLTKIT_RTC_SECRET` | `TypeError` naming the variable |
| An id of `""`, `"."` or `".."`, for a room, channel, message, identity or stream | The call raises `StreamError` `bad-request`, `status` 0, and sends nothing: a URL reads those as steps through the path. Every other id is encoded and sent as given |
| An id that is no string or number: `None`, a `dict`, a `list`, a `bool`, a function, a NaN | The call raises `StreamError` `bad-request`, `status` 0, `roomId None: an id must be a string; nothing was sent`, and sends nothing (a missing request parameter must not become a room called `None`). A number is sent as its digits |
| An id, `before` or a body holding half a surrogate pair: what `json.loads('"\\ud83d"')` gives, which UTF-8 cannot carry | The call raises `StreamError` `bad-request`, `status` 0, `roomId holds half a surrogate pair (U+D800 to U+DFFF alone), which no URL or UTF-8 body can carry; nothing was sent`, and sends nothing (0.1.3: a raw `UnicodeEncodeError` for an id, a `TypeError` for a body). A whole pair is one character and goes out as given |
| A body JSON cannot carry for any other reason: a structure that refers to itself, a `set`, a `datetime`, a NaN in `metadata` | The call raises a `TypeError` naming it, such as `PUT /v1/rooms/show-42: the body cannot be sent as JSON: ...`, with the encoder's error as its `__cause__` |
| `get_rates()` without `rates_base_url` | The call raises `StreamError` `rates-unconfigured`, `status` 0 |
| A webhook body that is not the raw bytes, no secret (empty or only whitespace), a bad `tolerance_seconds`, or a `now` that is not a finite number | `TypeError` or `ValueError` from `verify_webhook` / `parse_webhook` |
| A delivery that verified but is not JSON, or nests deeper than 10,000 levels (nothing VoltKit sends; only the holder of the secret could) | `ValueError` from `parse_webhook` (`json.JSONDecodeError` is one), never a `RecursionError`. A body nested past what `json.loads` reads on your Python (about 1,000 levels on 3.9 to 3.11) is read all the same |

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:

```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). The header may be a `str`, the `bytes` a raw WSGI or
ASGI layer hands over, or a list of either (joined with commas); `secrets` a
`str` or any iterable of them, a set or a generator included. The secret is
used exactly as given, as the dashboard shows it: whitespace around it is not
trimmed, so strip an environment variable yourself. A secret that is empty or
only whitespace is no secret: it never verifies anything, and with no other
secret given it is the "needs the signing secret" `TypeError`.

Verification is HMAC-SHA256 over `<timestamp>.<raw body>`, compared in
constant time with every secret against every signature, with a freshness
window (`tolerance_seconds`, default 300, in both directions, inclusive) that
stops a captured request being replayed forever. The window is in whole
seconds, the clock rounded down: 0 accepts the current second, and 300.9
seconds old passes the default. `tolerance_seconds` must be
a number of 0 or more: `nan` or a negative raises a `ValueError` rather than
switching the window off or refusing every delivery. `verify_webhook` is
available when you want the boolean rather than the exception; `parse_webhook`
raises `StreamError` with `code` `bad-signature` and `status` 401 and returns
the decoded event: `{"id", "type", "tenantId", "roomId"?, "createdAt", "data"?}`
(`StreamEvent`). A delivery that verified but is not JSON, or nests deeper than
10,000 levels, raises `ValueError` (nothing VoltKit sends); one nested past what
`json.loads` itself reads on your Python (about 1,000 levels on 3.9 to 3.11) is
read all the same. For tests, `now=` replaces the clock: a number of seconds,
where `nan` or an infinity is a `ValueError` and a string a `TypeError`, never
a verdict (a `nan` clock used to switch the window off).

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

```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, and so does anything else it raises (an
`httpx.ConnectError`, say): each is a `network` error with its words redacted.
Only what is not an `Exception`, such as `KeyboardInterrupt`, passes through. `sleep=` replaces the wait between attempts
and `jitter=` its randomness (a function returning a float in `[0, 1)`), so a
test of your retry handling runs in no time.

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

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

## Versions

| Version | What changed |
| --- | --- |
| 0.1.4 | Prepared 2026-11-02, not published. The testers' report of 2026-11-01. Half a surrogate pair (`json.loads('"\\ud83d"')`) in an id, `before` or a body is `StreamError` `bad-request` with nothing sent, where 0.1.3 raised a raw `UnicodeEncodeError` or a `TypeError`. A `base_url` whose host holds anything but letters, digits, `.`, `-`, `_` and `~` (a quote left from a `.env` file, `|`, `^`, `%zz`) is refused when the client is made. `http://[::1]:8080` works on Python 3.9 and 3.10. A reply or webhook body nested past what `json.loads` reads (about 1,000 levels on 3.9 to 3.11) is read, to 10,000 levels; deeper is "the reply is not JSON" for a call and `ValueError` from `parse_webhook`, never a `RecursionError`. See [CHANGELOG.md](CHANGELOG.md). |
| 0.1.3 | Published 2026-11-01. The testers' report of 2026-10-31. No frame an error reporter reads (Sentry's locals, `traceback` with `capture_locals=True`) holds the API secret or the reply as it came when a `StreamError` is raised, and `parse_webhook` holds no secret when it raises `bad-signature`. A `2xx` that is not the shape the call needs is `http-<status>`, not retried, instead of a success (`{}` passed as a token; see [Replies are checked](#replies-are-checked)). The deadline covers the connect, the TLS handshake, the headers and chunk framing, not only the body: a server dripping its headers held a call for as long as it liked. Whatever a transport raises is a `network` error, never a raw `ValueError`. A `base_url` no request can use (a character outside ASCII, port 0) is refused when the client is made, quoting nothing of it. An id that is no string (`None`) is `bad-request`, a number is sent as its digits. Webhooks: whole seconds, a `now` that is not a finite number is an argument error, a whitespace-only secret is no secret. Redaction: URL passwords holding `@ / ? #`, references with leading zeros (`&#x005F;`). See [CHANGELOG.md](CHANGELOG.md). |
| 0.1.2 | Published 2026-10-28. The Music add-on in `get_rates`: `addons.music` (`price`, `cuPerParticipantSecond`, `unit`, `appliesTo`, `includedIn`, `id`, `label`; absent from an older price list), types `RtcAddons` and `RtcAddonRate`; `music` is an add-on id, not a tier. A reply that is not JSON keeps no `__context__` (the decoder's error held the whole reply, key and all, as `.doc`), nor does the error for a URL whose port is not a number. The examples' 502 on an incomplete token reply and 1 MB body cap (below). No method changed. |
| 0.1.1 | The adversarial review's fixes, no method changed: `StreamError` pickles and copies (a worker pool can return it); the wait between attempts is held at 2^31-1 ms however large `retries` is; a `base_url` with a query, a fragment, another scheme or a bad port is refused at startup and whitespace around a URL is dropped; a reply nested too deep for the decoder is `http-<status>`, not a `RecursionError`; a network error keeps no `__context__`; the signature header may be `bytes` and `secrets` any iterable; an int too large for a float is held or infinite, never an `OverflowError`; the TLS context is built once per transport; `repr(TransportRequest)` is redacted. See [CHANGELOG.md](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](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.
