SDKs
Go Server SDK: Room Tokens with net/http and Gin
The Go server SDK for VoltKit RTC: rooms, tokens, chat, usage, balance and webhook verification, with context handling and net/http and Gin examples.
github.com/voltkit-rtc/voltkit-rtc-server-go (package voltkit) wraps the REST API for a Go backend: the Go server SDK for the VoltKit RTC video call API. 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. Handling a failed call has a token endpoint on net/http, with what changes for chi and Gin.
Install
go get github.com/voltkit-rtc/voltkit-rtc-server-go
Go 1.22 or newer, standard library only: no dependencies, nothing to vendor. Import it under the name voltkit:
import voltkit "github.com/voltkit-rtc/voltkit-rtc-server-go"
Every reply is a typed struct (voltkit.RoomWithHostToken, voltkit.RoomToken, voltkit.ChatMessage, voltkit.UsageSummary, voltkit.ProjectBalance, voltkit.RtcRates, voltkit.StreamEvent and the rest), with json.RawMessage for metadata and strings for money. This page describes 0.1.0, at parity with the Node.js SDK 0.4.8; see Versions.
Create a client
client, err := voltkit.NewStreamClient(voltkit.Config{
BaseURL: os.Getenv("VOLTKIT_RTC_URL"), // your API base URL, e.g. https://api.voltkit.in
APIKey: os.Getenv("VOLTKIT_RTC_SECRET"), // your API secret: server-side only
})
if err != nil {
log.Fatal(err) // a missing or malformed value, caught at startup; the error never quotes the key
}
Both values are in the credentials file your browser downloaded when you created the project or the key. The API base URL is also on your project's Credentials tab in the dashboard; the secret is shown only once. See Quickstart.
Create one client when your server starts and share it: it is safe for concurrent use and holds no state outside a call. The constructor checks every field, so a wrong value fails at startup instead of on a user's first call (see Mistakes caught before a request). NewStreamClientFromEnv reads the same two variables, plus the optional VOLTKIT_API_URL for rates, and nothing else:
client, err := voltkit.NewStreamClientFromEnv() // VOLTKIT_RTC_URL, VOLTKIT_RTC_SECRET and, optionally, VOLTKIT_API_URL
| Field | Default | What it does |
|---|---|---|
BaseURL | required | Your API base URL, with the exact scheme and host the Credentials tab shows. The API secret is sent only here. A path prefix is kept; a trailing / is dropped; a query or fragment is refused. |
APIKey | required | Your API secret. Whitespace around it is ignored. |
RatesBaseURL | unset | VoltKit's website API, https://api.voltkit.in, where GetRates reads the price list. It is never sent the secret. "" and "/" mean unset. |
Timeout | 10 s | The deadline for each attempt, from sending the request to reading the last byte of the reply. Zero means the default; above 2^31−1 ms it is held there. |
Retries | nil = 2 | How many more attempts a call that is safe to repeat gets after a temporary failure. voltkit.Ptr(0) turns retries off. |
HTTPClient | a fresh http.Client | Your own client, for a proxy, custom TLS or instrumentation. It is copied, never modified: the copy for the controller never follows a redirect, the copy for the rates host keeps your redirect policy, and both leave the deadline to the context. |
Every method takes a context.Context first. Cancelling it, or its deadline passing, stops the call and its retries and returns the context's error: see Timeouts, retries and context.
Rooms and tokens
// Create the room, or get it back with a fresh host token if it already exists.
room, err := client.EnsureRoom(ctx, voltkit.EnsureRoomParams{RoomID: "show-42", HostIdentity: "user-7"})
// room.HostToken, room.WSURL, room.ExpiresAt (unix seconds), room.Existed, room.RoomID, room.OriginNode, ...
// Let someone in, with the role YOU decide.
tok, err := client.CreateToken(ctx, voltkit.CreateTokenParams{RoomID: "show-42", Identity: "user-99", Role: voltkit.RoleViewer})
// tok.Token, tok.WSURL, tok.ExpiresAt
view, err := client.GetRoom(ctx, "show-42") // the authoritative view: view.Room, view.Participants, view.Producers
list, err := client.ListRooms(ctx) // every live room, as list.Rooms: an object, not an array
people, err := client.ListParticipants(ctx, "show-42") // []voltkit.Participant: Identity, Role, HandRaised, Muted, JoinedAt, Metadata
err = client.DeleteRoom(ctx, "show-42") // ends it for everyone
EnsureRoomcreates the room, or returns the live one with a fresh host token.Existedsays which, so you can tell a new "go live" from a host reconnecting.CreateRoomis the strict version: it fails withroom-exists(409) if the id is taken, and makes up an id if you leaveRoomIDempty.Metadata(any valueencoding/jsoncan encode) on either call is room state that every participant receives when they join. It is applied only when the room is created, and comes back asjson.RawMessage. Left nil, it is left out of the body.Roleisvoltkit.RoleHost,RoleCohost,RoleModeratororRoleViewer. 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
GetRoomfor 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. - Room ids are
[A-Za-z0-9._-]{1,128}and scoped to your project: another project's room is a 404, never a 403.
Roles are covered in Rooms, and a complete token endpoint is below under Handling a failed call and on Token server.
Chat
// Admit someone to a channel's chat with no media rights (Role defaults to viewer).
chat, err := client.CreateChatToken(ctx, voltkit.CreateChatTokenParams{Channel: "show-42", Identity: "user-99"})
// chat.Token, chat.WSURL (ends in /ws/chat), chat.Role, chat.Identity, chat.Channel, chat.ExpiresAt
// Take part without holding a connection.
_, err = client.SendMessage(ctx, voltkit.SendMessageParams{Channel: "show-42", Text: "The show starts in 5 minutes", Type: "system"})
_, err = client.SendMessage(ctx, voltkit.SendMessageParams{Channel: "show-42", Type: "gift", From: "user-7", Metadata: map[string]any{"gift": "rose", "count": 5}})
_, err = client.SendMessage(ctx, voltkit.SendMessageParams{Channel: "show-42", Type: "reaction", Metadata: map[string]any{"emoji": "heart"}, Ephemeral: true}) // not stored
_, err = client.SendMessage(ctx, voltkit.SendMessageParams{Channel: "show-42", To: "user-13", Text: "Please keep it friendly"}) // a direct message
messages, err := client.ListMessages(ctx, "show-42", &voltkit.ListMessagesOptions{Limit: 100}) // oldest first
older, err := client.ListMessages(ctx, "show-42", &voltkit.ListMessagesOptions{Before: messages[0].ID}) // the page before that
err = client.DeleteMessage(ctx, "show-42", messageID)
err = client.MuteChat(ctx, "show-42", "user-13") // survives reconnecting; they can still read
err = client.UnmuteChat(ctx, "show-42", "user-13")
muted, err := client.ListChatMutes(ctx, "show-42") // []string{"user-13"}
info, err := client.GetChatInfo(ctx, "show-42") // info.Channel, info.Members
A channel shares its name with the room of the same name; a channel with no room behind it is chat on its own.
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 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.
ListMessages 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; opts may be nil. SendMessage is never retried. See Chat.
Usage
usage, err := client.GetUsage(ctx)
// usage.MinutesRemaining (-1 on an unlimited plan), usage.Today, usage.Month, usage.Lifetime, usage.Allowance
days, err := client.GetUsageSeries(ctx, 30) // one entry per UTC day, oldest first, ending today; 0 means 30, at most 90
Each period (voltkit.Usage) 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. A day with no usage is present and zero, so a chart drawn from the series has no gaps. See Reading your usage.
Balance and rates
The balance is the VoltKit Wallet a project spends from, after its free and bought minutes.
Balance
b, err := client.GetBalance(ctx)
// b.TenantID, b.BillingMode ("legacy" or "wallet"), b.CanJoin, b.Blocked (*string, nil when CanJoin),
// b.Legacy (minutes spent first), b.Wallet (*WalletFunds, nil for a legacy project: SpendOrder, Scopes, PaidCu, PromoCu, AvailableCu, AvailableCredits),
// b.Burn (LiveParticipants, ProjectCuPerSecond, WalletCuPerSecond), b.RemainingSeconds, b.WalletLow, b.PricingVersion (*int), b.UpdatedAt
GET /v1/billing/balance: what this project can spend right now, to show a balance, warn before a call runs out, or decide whether to start one. It is read only (recharges happen in the dashboard) and authenticated with your API secret, so call it from your server 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:
func admissionNotice(ctx context.Context, client *voltkit.StreamClient) (string, error) {
b, err := client.GetBalance(ctx)
if err != nil {
return "", err
}
if b.CanJoin {
return "", nil
}
switch *b.Blocked {
case "tenant-suspended": // answer 403
return "This account is suspended", nil
case "billing-unavailable": // answer 503
return "Billing is being set up", nil
default: // quota-exhausted, daily-limit-reached, monthly-limit-reached: answer 402
return "Recharge to go live", nil
}
}
Legacyare the minutes spent first, in participant-seconds. On an unlimited planUnlimitedistrue, andOwnSecondsandTotalSecondsare-1.Walletisnilunless the project is billed from the wallet (BillingMode"wallet"). It lists only the shares this project may spend, in the order it spends them: its own share (project:<id>), the RTC share (product:rtc), thenunallocated. Never another project's.Burnis what this project's rooms cost per second now, and what every project drawing the same wallet costs.RemainingSecondsestimates how long the funds last at that rate (-1when nothing is being spent or nothing limits it).WalletLow.Activeistruewhile the project's rooms are being warned.PricingVersionis the price list new sessions start on:nilfor a legacy project, and while no price list is active.
Amounts are strings. Money is in credit units: 1 credit = 6,000,000 units (voltkit.CuPerCredit), and every amount in units (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. Do arithmetic with voltkit.ParseCu and math/big; show AvailableCredits, which is already formatted (at most four decimals, rounded down):
if b.Wallet != nil {
cu, err := voltkit.ParseCu(b.Wallet.AvailableCu) // *big.Int: exact, however large
if err == nil {
wholeCredits := new(big.Int).Div(cu, big.NewInt(voltkit.CuPerCredit)) // 1 credit = 6,000,000 cu
fmt.Println(wholeCredits, b.Wallet.AvailableCredits) // 31 "31": AvailableCredits is already formatted
}
}
Rates
client, err := voltkit.NewStreamClient(voltkit.Config{
BaseURL: os.Getenv("VOLTKIT_RTC_URL"),
APIKey: os.Getenv("VOLTKIT_RTC_SECRET"),
RatesBaseURL: "https://api.voltkit.in", // VoltKit's website API: the origin, without /api/v1
})
rates, err := client.GetRates(ctx)
// rates.Version, rates.Kind ("standard"), rates.EffectiveAt, rates.Unit,
// rates.Tiers ([]RtcTierRate: ID "video_30", Credits "99.00", CuPerParticipantSecond "9900", ...),
// rates.Surcharges, rates.IdleTier, rates.Modes, rates.Conversion, rates.Notes
The public price list: each rate in credits per 1,000 participant-minutes, the media modes that are switched on, and the conversion for new recharges. Prices are set by VoltKit on the website, so GetRates reads GET /api/v1/billing/rtc/rates/current from the website API named by RatesBaseURL, and sends it no credential: no API key and no Authorization header. The secret only ever goes to BaseURL. The website caches the list for 30 seconds; cache it on your side too if you read it often.
- Without
RatesBaseURL,GetRatesfails at once withrates-unconfigured(Status0) and sends nothing. - A
RatesBaseURLthat names something other than the website API gets a reply with no rates in it. The call fails, usually withhttp-<status>, and is not retried. - The website API's own error codes are passed through, such as
RATE_LIMITED(429, retried) orNOT_FOUND. GetRatessends no secret, so it may follow redirects, with yourHTTPClient's policy (Go's default stops after ten hops, which comes back as anetworkerror).
A rate's CuPerParticipantSecond is exact, so a cost is integer arithmetic:
price, _ := voltkit.ParseCu(video30.CuPerParticipantSecond) // exact cu per participant-second
cost := new(big.Int).Mul(price, big.NewInt(4*30*60)) // 4 people for 30 minutes: 71280000 cu, 11.88 credits at "9900"
Every method
"Retried" means the SDK tries the call again after a temporary failure (see Timeouts, retries and context). Every method takes ctx context.Context first and returns an error last; optional fields are left out of the body when zero.
| Method | Request | Retried | Returns |
|---|---|---|---|
EnsureRoom(ctx, EnsureRoomParams{RoomID, HostIdentity, Metadata}) | PUT /v1/rooms/{roomId} | Yes | *RoomWithHostToken: the room with HostToken, WSURL, ExpiresAt and Existed |
CreateRoom(ctx, CreateRoomParams{RoomID, HostIdentity, Metadata}) | POST /v1/rooms | No | the same, with Existed false |
CreateToken(ctx, CreateTokenParams{RoomID, Identity, Role}) | POST /v1/rooms/{roomId}/tokens | Yes | *RoomToken: Token, WSURL, Role, Identity, RoomID, ExpiresAt |
GetRoom(ctx, roomID) | GET /v1/rooms/{roomId} | Yes | *RoomView: Room, Participants, Producers |
ListRooms(ctx) | GET /v1/rooms | Yes | *RoomList: Rooms |
ListParticipants(ctx, roomID) | GET /v1/rooms/{roomId}/participants | Yes | []Participant, empty rather than nil |
DeleteRoom(ctx, roomID) | DELETE /v1/rooms/{roomId} | Yes | nothing |
CreateChatToken(ctx, CreateChatTokenParams{Channel, Identity, Role}) | POST /v1/chat/{channel}/tokens | Yes | *ChatToken: Token, WSURL, Role, Identity, Channel, ExpiresAt |
GetChatInfo(ctx, channel) | GET /v1/chat/{channel} | Yes | *ChatInfo: Channel, Members |
ListMessages(ctx, channel, *ListMessagesOptions{Limit, Before}) | GET /v1/chat/{channel}/messages | Yes | []ChatMessage, oldest first |
SendMessage(ctx, SendMessageParams{Channel, Text, Type, From, To, Metadata, Ephemeral}) | POST /v1/chat/{channel}/messages | No | *ChatMessage |
DeleteMessage(ctx, channel, messageID) | DELETE /v1/chat/{channel}/messages/{messageId} | Yes | nothing |
MuteChat(ctx, channel, identity) | PUT /v1/chat/{channel}/mutes/{identity} | Yes | nothing |
UnmuteChat(ctx, channel, identity) | DELETE /v1/chat/{channel}/mutes/{identity} | Yes | nothing |
ListChatMutes(ctx, channel) | GET /v1/chat/{channel}/mutes | Yes | []string |
GetUsage(ctx) | GET /v1/usage | Yes | *UsageSummary: MinutesRemaining, Today, Month, Lifetime, Allowance, ... |
GetUsageSeries(ctx, days) | GET /v1/usage/series?days={days} | Yes | []DailyUsage, oldest first |
GetBalance(ctx) | GET /v1/billing/balance | Yes | *ProjectBalance |
GetRates(ctx) | GET /api/v1/billing/rtc/rates/current on RatesBaseURL, with no key | Yes | *RtcRates |
CreateStream(ctx, CreateStreamParams{StreamID, Playback, Metadata}) | POST /v1/streams | No | *BroadcastStreamWithPublish: the stream, with Publish |
CreateStreamToken(ctx, CreateStreamTokenParams{StreamID, Action, Identity, TTLSeconds}) | POST /v1/streams/{streamId}/tokens | Yes | *StreamToken: Token, ExpiresAt, with WHIPURL, or with LLHLS and WebRTC |
GetStream(ctx, streamID) | GET /v1/streams/{streamId} | Yes | *BroadcastStream |
ListStreams(ctx) | GET /v1/streams | Yes | []BroadcastStream |
UpdateStream(ctx, streamID, UpdateStreamParams{Playback, Metadata}) | PATCH /v1/streams/{streamId} | Yes | *BroadcastStream |
DeleteStream(ctx, streamID) | DELETE /v1/streams/{streamId} | Yes | nothing |
ParseWebhook and VerifyWebhook are functions of their own: see Webhooks. ParseCu, Ptr, ErrorInfo, ErrorCodes and RESTErrorCodes are helpers. Every request and reply is in the API reference; the stream methods are not offered on VoltKit cloud today.
Timeouts, retries and context
client, err := voltkit.NewStreamClient(voltkit.Config{
BaseURL: os.Getenv("VOLTKIT_RTC_URL"),
APIKey: os.Getenv("VOLTKIT_RTC_SECRET"),
Timeout: 5 * time.Second, // each attempt: headers and body (default 10 s)
Retries: voltkit.Ptr(1), // at most one more attempt (default 2; voltkit.Ptr(0) turns retries off)
HTTPClient: &http.Client{Transport: http.DefaultTransport}, // optional: a proxy, custom TLS, instrumentation
})
- Timeout.
Timeout(default 10 seconds) is the deadline for each attempt, from sending the request to reading the last byte of the reply, held by acontext.WithTimeouton your context. A service that does not answer, or a reply that stalls after its headers, fails withnetworkand "timed out after 5000ms". YourHTTPClient's ownTimeoutis not used: the context is the one clock. - What is retried. A call that is safe to repeat is tried again after a
networkfailure (no answer, a refused or dropped connection, the timeout), a429or a5xx. The waits are about 200 ms, then 400 ms, doubling each time, plus up to 100 ms at random, so a fleet of servers does not retry in step.Retry-Afteris not read. - What is not. Any other
4xx(the service made a decision and will make it again),billing-unavailable(a 503 that lasts until VoltKit acts),redirected, and a2xxreply that is not JSON. - How long a call can take. Up to
(Retries + 1) × Timeout, plus the waits between attempts: about 31 seconds with the defaults. In a request a user is waiting on, lower both, or give the call a context with a deadline. - Your context wins. When your context is cancelled or its deadline passes, the SDK stops at once, between attempts or in the middle of reading a body, and returns
context.Canceledorcontext.DeadlineExceeded, never a*voltkit.Error. Nothing is retried after that.
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second) // a user is waiting: cap the whole call, retries included
defer cancel()
tok, err := client.CreateToken(ctx, voltkit.CreateTokenParams{RoomID: roomID, Identity: identity, Role: voltkit.RoleViewer})
if errors.Is(err, context.DeadlineExceeded) {
http.Error(w, "try again in a moment", http.StatusGatewayTimeout) // your deadline passed: the context's error, not a *voltkit.Error
return
}
When an *voltkit.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
CreateRoom, CreateStream and SendMessage 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, EnsureRoom returns the room that exists, and a mute or a delete done twice ends the same way. (Go's own transport never resends a POST, PUT, PATCH or DELETE that may have reached the server either; it only replays a GET on a connection the server closed before answering.)
When one of the three fails with Retryable true, you decide:
- A
429, whatever its code, is a refusal: nothing was done. Send it again after a pause. - A
networkerror or a5xxmay have done the work before the reply was lost. Check first:
| Call | Check, then repeat |
|---|---|
CreateRoom | Call EnsureRoom with the same RoomID: it returns the room if your call made it, or makes it. Use ids nobody else uses, such as a UUID. |
CreateStream | Call GetStream. If the stream is there, mint a publish URL with CreateStreamToken and Action: voltkit.ActionPublish, since only CreateStream returns one. If IsNotFound, create it again. |
SendMessage | Put your own id in Metadata, and look for it in ListMessages before sending again. An Ephemeral message is never stored, so it cannot be found: send it again or drop it. |
type Gift struct{ ID, From, Name string }
// announceGift announces a gift once, even when a reply is lost.
func announceGift(ctx context.Context, client *voltkit.StreamClient, channel string, gift Gift) (*voltkit.ChatMessage, error) {
msg := voltkit.SendMessageParams{Channel: channel, Type: "gift", From: gift.From, Metadata: map[string]any{"giftId": gift.ID, "gift": gift.Name}}
sent, err := client.SendMessage(ctx, msg)
var e *voltkit.Error
if err == nil || !errors.As(err, &e) || !e.Retryable {
return sent, err
}
if e.Status != 429 {
// It may have been stored before the reply was lost: look before sending again.
recent, err := client.ListMessages(ctx, channel, &voltkit.ListMessagesOptions{Limit: 100})
if err != nil {
return nil, err
}
for i := range recent {
var meta struct {
GiftID string `json:"giftId"`
}
if json.Unmarshal(recent[i].Metadata, &meta) == nil && meta.GiftID == gift.ID {
return &recent[i], nil
}
}
}
select {
case <-time.After(time.Second):
case <-ctx.Done():
return nil, ctx.Err()
}
return client.SendMessage(ctx, msg)
}
Deletes that lost their reply
DeleteRoom, DeleteMessage, UnmuteChat and DeleteStream 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 nil. A 404 on the first attempt still fails, 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:
if err := client.DeleteRoom(ctx, roomID); err != nil {
var e *voltkit.Error
if !errors.As(err, &e) || !e.IsNotFound() {
log.Printf("end show %s: %v", roomID, err) // safe to log whole
}
// no-room: it had already ended
}
Where your API secret goes
The SDK sends your API secret in the X-API-Key header of requests to BaseURL, and nowhere else:
GetRatessends nothing toRatesBaseURL.- A redirect is never followed with it (below).
- It never appears in an error, in a log line the SDK writes (it writes none), or in the client object: see Safe to log.
Every request carries User-Agent: voltkit-rtc-go/<Version> and nothing else about your system.
Redirects are never followed
A request that carries your API secret never follows a redirect. Go's http.Client keeps custom headers across a redirect to another host, so 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 BaseURL redirects the same way.
If your BaseURL reaches 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. GetRates sends no secret and may follow redirects.
Safe to log
An error never carries your API secret, or the credentials of a URL (user:pass@ in BaseURL or RatesBaseURL), so log.Printf("%v", err) and your logger 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 (/, /, /), or any of those escaped once more (%252F, \"). A key over 256 characters is not looked for: words at least as long as it are shown as [not shown: it could not be checked for the API key]. A long message is cut only after that, so a cut cannot leave part of the secret behind.
The client object keeps them out of sight too. String, GoString, Format, MarshalJSON and LogValue all give the same redacted view, so fmt.Printf with any verb, json.Marshal(client) and slog print voltkit.StreamClient{BaseURL: https://[redacted]@x.test, RatesBaseURL: unset, Timeout: 10s, Retries: 2} and never the key. The key, the URLs as given and the redactor live in closures, which reflection cannot read, so a debugger's variable view or a reflection-based dumper sees function addresses. Requests still go to the URLs as given.
log.Printf("rtc client %v", client) // voltkit.StreamClient{BaseURL: https://api.voltkit.in, RatesBaseURL: unset, Timeout: 10s, Retries: 2}
slog.Info("rtc client ready", "client", client) // the same view as a group: client.baseUrl, client.timeout, client.retries
A Config prints, marshals and logs the same way, with APIKey as [redacted]; it is a plain struct, though, whose APIKey field reflection can read, so keep a Config out of dumps. 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.
Errors
A call that fails returns an *voltkit.Error; reach it with errors.As, branch on Code or the predicates, and log Message. A mistake in your own setup is a plain error instead, raised before anything is sent: see Mistakes caught before a request. Every code from every VoltKit SDK, and what to do about it, is on Errors.
Handling a failed call
A token endpoint on net/http, mapping each answer from VoltKit to an answer for the client:
func join(client *voltkit.StreamClient) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
identity := currentUser(r) // your session middleware knows who is asking
ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second)
defer cancel()
tok, err := client.CreateToken(ctx, voltkit.CreateTokenParams{
RoomID: "show-" + r.PathValue("showId"),
Identity: identity,
Role: voltkit.RoleViewer, // your rules decide; it is signed into the token
})
if err != nil {
writeError(w, err)
return
}
w.Header().Set("Cache-Control", "no-store") // a token is a credential for one room
writeJSON(w, http.StatusOK, map[string]any{"token": tok.Token, "wsUrl": tok.WSURL, "expiresAt": tok.ExpiresAt})
}
}
// writeError maps a failed call to an answer for the client, by code.
func writeError(w http.ResponseWriter, err error) {
var e *voltkit.Error
if !errors.As(err, &e) {
// Not an answer from VoltKit: the request was cancelled, or this handler has a bug.
log.Printf("join: %v", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "something went wrong"})
return
}
log.Printf("join: %v", e) // safe to log whole: the API secret is never in it
switch {
case e.IsNotFound(): // no-room
writeJSON(w, http.StatusNotFound, map[string]string{"error": "this show has ended"})
case e.IsOutOfMinutes(): // 402: quota-exhausted, daily-limit-reached, monthly-limit-reached
writeJSON(w, http.StatusPaymentRequired, map[string]string{"error": "this show cannot take viewers right now"})
case e.Code == "viewer-quota": // 429: the room is full
writeJSON(w, http.StatusTooManyRequests, map[string]string{"error": "this show is full"})
case e.IsCapacityExhausted(): // 503 capacity-exhausted, no-nodes: alert, new rooms are failing right now
w.Header().Set("Retry-After", "30")
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "try again in a moment"})
case e.Retryable: // network, 429, 5xx: the SDK has already retried
w.Header().Set("Retry-After", "10")
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "try again in a moment"})
default: // unauthorized, tenant-suspended, bad-request, redirected: a problem on your side, answered as a 500
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "something went wrong"})
}
}
func writeJSON(w http.ResponseWriter, status int, body any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(body)
}
func currentUser(r *http.Request) string { return r.Header.Get("X-Demo-User") } // a stand-in for your authentication
Register it with mux.HandleFunc("POST /shows/{showId}/join", join(client)). With chi, r.Post("/shows/{showId}/join", join(client)) and chi.URLParam(r, "showId") in place of r.PathValue; with Gin, wrap it with gin.WrapH or read c.Param("showId") and write with c.JSON. The error mapping is the same everywhere. The SDK's examples/tokenserver is this handler as a runnable program.
What an *Error carries
| Field or method | 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; redirected; rates-unconfigured; bad-request with Status 0 for an id refused before sending. A code this release does not know is passed through unchanged. |
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. On a call the SDK repeats, it means the retries have been spent. |
Message | The method and path, then what went wrong: POST /v1/rooms/show-42/tokens: room not found. Also what Error() returns. |
IsNotFound() | Status is 404: no such room, stream or message, or not yours. broadcast-disabled and chat-disabled are 404s too, so test IsBroadcastDisabled() first. |
IsOutOfMinutes() | Status is 402, or the code is quota-exhausted, daily-limit-reached or monthly-limit-reached. |
IsQuota() | The code is room-quota, or Status is 429: rate-limited, viewer-quota and stream-quota included. |
IsCapacityExhausted() | capacity-exhausted or no-nodes: no capacity for a new room right now. The one to alert on. |
IsBroadcastDisabled() | broadcast-disabled: broadcast is not switched on for this deployment. |
IsBillingUnavailable() | billing-unavailable: the project is billed from the wallet and no price list is active yet, or the service has no wallet billing. |
The predicates are safe on a nil *Error. voltkit.ErrorCodes is the whole shared registry in its order, voltkit.RESTErrorCodes the codes a call from this SDK can fail with, and voltkit.ErrorInfo(code) gives a code's origin and flags, for a bounded metrics label:
label := "other"
if _, known := voltkit.ErrorInfo(e.Code); known {
label = e.Code // a bounded label for your metrics; http-<status> and the website API's codes fall under "other"
}
What to do, code by code
Every code a call can fail with. "Retried" means on a call that is safe to repeat; CreateRoom, CreateStream and SendMessage are sent once.
| Code | Status | Retried | What it means, and what to do |
|---|---|---|---|
unauthorized | 401 | No | A wrong or revoked secret, or BaseURL is another project's. Check VOLTKIT_RTC_SECRET; alert yourself, tell the user only that something failed. A cheap read at startup, such as GetUsage, catches it before your users do. |
tenant-suspended | 403 | No | The account is suspended. Contact support. |
quota-exhausted | 402 | No | Out of minutes or credits. The project owner recharges; GetBalance says so before anyone tries. |
daily-limit-reached | 402 | No | The daily cap is reached. Wait for the UTC day to reset, or raise the cap. |
monthly-limit-reached | 402 | No | The monthly cap is reached. Wait for the month to reset, or raise the cap. |
billing-unavailable | 503 | No | Wallet billing is not ready: no active price list, or no wallet billing on this service. Contact support; a retry does not help. |
no-room | 404 | No | The room is gone, or was never yours. Treat the show as ended. |
no-message | 404 | No | The message is gone. Nothing to do. |
not-found | 404 | No | The mute, or whatever the call named, is not there. Nothing to do. |
no-stream | 404 | No | The stream is gone. Treat it as off air. |
broadcast-disabled | 404 | No | Broadcast is not available on this deployment (VoltKit cloud today). Do not retry. |
chat-disabled | 404 | No | Chat is not available on this deployment. Do not retry. |
room-exists | 409 | No | The id is taken. Use EnsureRoom, or another id. |
stream-exists | 409 | No | The stream id is taken. Use the existing stream, or another id. |
bad-request | 400 | No | Fix the call: the message names the field (an id outside [A-Za-z0-9._-]{1,128}, an empty message, a bad cursor). With Status 0, the SDK refused an id of "", "." or ".." before sending. |
bad-role | 400 | No | Role is not host, cohost, moderator or viewer. Fix the call. |
operator-only | 403 | No | An operator endpoint with a project key. Not something an SDK call reaches. |
bad-signature | 401 | No | From ParseWebhook: the delivery did not verify. Answer 401; VoltKit retries it. |
rate-limited | 429 | Yes | Your project's API rate limit (600 per minute by default). Slow down; spread calls out. |
room-quota | 429 | Yes | At your concurrent-room limit. End rooms you no longer need, or raise the limit. |
viewer-quota | 429 | Yes | The room is at its viewer limit. Tell the viewer it is full. |
stream-quota | 429 | Yes | At your concurrent-stream limit. Delete streams you no longer need. |
capacity-exhausted | 503 | Yes | The fleet is full: new rooms are failing right now. Ask the user to try again shortly, and alert. |
no-nodes | 503 | Yes | No media node is registered. As above. |
unavailable | 503 | Yes | The service could not answer (a read failed). Try again later; tell us if it repeats. |
internal | 500 | Yes | A fault on the service. Try again later; tell us if it repeats. |
network | 0 | Yes | No reply: DNS, a refused or dropped connection, the timeout, a TLS failure. Try again later; if it persists, check your server's outbound network. |
redirected | 3xx | No | The server answered a request carrying the key with a redirect. Fix BaseURL: see Redirects are never followed. |
rates-unconfigured | 0 | No | GetRates without RatesBaseURL. Set it to the website API's origin. |
http-<status> | that status | Only a 429 or 5xx | A reply that named no code: a proxy's error page, a success that is not JSON, or JSON of another shape than the method's. Check BaseURL, and anything between your server and VoltKit. |
RATE_LIMITED, NOT_FOUND, ... | as sent | By status | The website API's own codes, from GetRates, passed through unchanged. |
Mistakes caught before a request
These are plain error values, never an *voltkit.Error, so a handler cannot mistake them for an answer from VoltKit. They fail at once, are never retried, and never quote your secret:
| Mistake | How it fails |
|---|---|
BaseURL or APIKey empty; BaseURL or RatesBaseURL not an absolute http(s) URL with a host, or carrying a query or fragment (a path prefix is fine) | NewStreamClient returns an error |
APIKey that is only whitespace, such as a bare line break from a file | NewStreamClient returns an error |
APIKey holding a character no HTTP header can carry (a line break or NUL inside it, another control character, DEL, anything above U+00FF), a backslash, or a character outside ASCII such as an accented letter (no VoltKit key holds one) | NewStreamClient returns an error saying to check how the key is loaded |
Timeout negative; Retries negative | NewStreamClient returns an error |
An id of "", "." or "..", for a room, channel, message, identity or stream | The call returns *voltkit.Error bad-request, Status 0, and sends nothing: a URL reads those as steps through the path. Every other id is percent-encoded and sent as given |
A body encoding/json cannot encode: a channel, a func, a cycle or NaN in Metadata | The call returns a plain error naming it, such as PUT /v1/rooms/show-42: the body cannot be sent as JSON: ..., with the encoder's error wrapped |
GetRates without RatesBaseURL | The call returns *voltkit.Error rates-unconfigured, Status 0 |
VerifyWebhook with no secret, or a negative Tolerance | Panics, a loud mistake rather than a false that would look like an attack; ParseWebhook returns a plain error instead |
Whitespace around the secret is fine: it is trimmed before sending. A Timeout above 2^31−1 ms (about 24.8 days) is held there; a Retries of any size is accepted, and the waits stop doubling at the same cap.
Webhooks
VoltKit signs every delivery: X-Stream-Signature: t=<unix seconds>,v1=<hex>, where v1 is HMAC-SHA256 over <timestamp>.<raw body> with your signing secret. ParseWebhook verifies and decodes in one step, so a handler cannot forget to check:
// The current secret, and the previous one during a rotation (see below). Empty entries are skipped.
secrets := []string{os.Getenv("VOLTKIT_RTC_WEBHOOK_SECRET"), os.Getenv("VOLTKIT_RTC_WEBHOOK_SECRET_OLD")}
mux.HandleFunc("POST /hooks/voltkit", func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20)) // the raw bytes: the signature covers them exactly
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
event, err := voltkit.ParseWebhook(body, r.Header.Get("X-Stream-Signature"), secrets, nil)
if err != nil {
var e *voltkit.Error
if errors.As(err, &e) {
w.WriteHeader(http.StatusUnauthorized) // bad-signature: not from VoltKit, or too old
return
}
log.Printf("webhook: %v", err) // a plain error: no secret, a negative Tolerance; fix your setup
w.WriteHeader(http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK) // answer first
queue <- event // then do the work, deduplicating on event.ID
})
- Pass the raw body. The signature is HMAC-SHA256 over
<timestamp>.<raw body>, so a parsed and re-serialised body can never verify. Readr.Bodyyourself, before any JSON middleware. With chi the handler above is the handler; with Gin readc.GetRawData()and the header withc.GetHeader("X-Stream-Signature"), thenc.Status(http.StatusUnauthorized)on an*voltkit.Error. See Keep the raw body. - One header value. VoltKit sends the signature as one header, which
r.Header.Getreturns. If something in front of your server splits it into two header lines, passstrings.Join(r.Header.Values("X-Stream-Signature"), ",")instead. - What it checks. The timestamp must be within
Toleranceof your clock (voltkit.DefaultTolerance, five minutes, in either direction, inclusive), so a captured request cannot be replayed later, and av1signature must match one of your secrets, compared in constant time withhmac.Equal, every secret against every signature. To widen the window, pass&voltkit.VerifyOptions{Tolerance: voltkit.Ptr(10 * time.Minute)}as the last argument;Nowreplaces the clock in tests. A negativeToleranceis a plain error, not a switched-off check. - What it returns. The event, or an
*voltkit.Errorwith codebad-signature(Status401) when the delivery does not verify: answer 401, and VoltKit retries it. A plain error (no secret at all, a negativeTolerance, a verified body that is not JSON) is a mistake in your setup. VerifyWebhooktakes the same arguments and returnstrueorfalseinstead of the event; a mistake in the setup makes it panic rather than return afalsethat would look like an attack. PreferParseWebhookin a handler.
The event is a voltkit.StreamEvent: ID, Type, TenantID, RoomID, CreatedAt (unix milliseconds) and Data as json.RawMessage, whose shape depends on Type. Three things to get right:
- Answer first. A slow handler is retried, so your latency becomes duplicate deliveries. Retries come at 5 s, 30 s, 2 min, 10 min and 30 min; a 410 disables the endpoint.
- Deduplicate on
event.ID. Delivery is at-least-once by design; the id is stable across retries (also in theX-Stream-Deliveryheader, withX-Stream-Attempt). - Do not assume order. A
participant.leftcan arrive before theparticipant.joinedit followed; pair them byidentityandjoinedAt.
Every event type and its fields are on Webhooks. The SDK's examples/webhookreceiver is this handler as a runnable program, with a worker and an in-memory dedupe.
Rotating the signing secret
When you press Rotate secret with an overlap (24 hours by default, or 1 hour), VoltKit signs every delivery with both the new secret and the previous one until the overlap ends: X-Stream-Signature: t=<ts>,v1=<new>,v1=<previous>. ParseWebhook and VerifyWebhook accept a delivery when any v1 value matches any secret you pass. To rotate without dropping a delivery:
- Deploy the handler above, which takes a list. With
VOLTKIT_RTC_WEBHOOK_SECRET_OLDunset, it verifies exactly as before. - Press Rotate secret. Move the current secret to
VOLTKIT_RTC_WEBHOOK_SECRET_OLD, put the new one inVOLTKIT_RTC_WEBHOOK_SECRET, and deploy before the overlap ends. - After the overlap, remove
VOLTKIT_RTC_WEBHOOK_SECRET_OLD.
See Rotating the signing secret.
Testing
Point the client at an httptest.Server that answers the way the service does, { "error": "<code>", "message": "..." } with the status, to drive each branch of your error handling. Retries: voltkit.Ptr(0) keeps a failing test fast:
func TestJoinWhenTheShowHasEnded(t *testing.T) {
var calls []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls = append(calls, r.Method+" "+r.URL.RequestURI())
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"error":"no-room","message":"room not found"}`))
}))
defer srv.Close()
client, err := voltkit.NewStreamClient(voltkit.Config{BaseURL: srv.URL, APIKey: "sk_test", Retries: voltkit.Ptr(0)})
if err != nil {
t.Fatal(err)
}
_, err = client.CreateToken(context.Background(), voltkit.CreateTokenParams{RoomID: "show-42", Identity: "user-99", Role: voltkit.RoleViewer})
var e *voltkit.Error
if !errors.As(err, &e) || !e.IsNotFound() || calls[0] != "POST /v1/rooms/show-42/tokens" {
t.Fatalf("%v %v", err, calls)
}
}
For a test with no listener at all, pass HTTPClient: &http.Client{Transport: rt} where rt is your own http.RoundTripper. To test a webhook handler, sign a body yourself and pin the clock:
func signedHeader(secret string, body []byte) string {
ts := "1787000000"
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(ts + "."))
mac.Write(body)
return "t=" + ts + ",v1=" + hex.EncodeToString(mac.Sum(nil))
}
func TestWebhookVerifies(t *testing.T) {
body := []byte(`{"id":"evt_1","type":"webhook.test","tenantId":"t","createdAt":1787000000000}`)
opts := &voltkit.VerifyOptions{Now: func() time.Time { return time.Unix(1787000000, 0) }} // the clock at the timestamp
event, err := voltkit.ParseWebhook(body, signedHeader("whsec_test", body), []string{"whsec_test"}, opts)
if err != nil || event.ID != "evt_1" {
t.Fatal(err)
}
}
What to do when
The key is wrong
Every call fails with unauthorized (401), not retried. The secret in VOLTKIT_RTC_SECRET is wrong or revoked, or VOLTKIT_RTC_URL is another project's. Check both at startup with a cheap read, so you find out before your users do:
if _, err := client.GetUsage(ctx); err != nil {
log.Fatalf("VoltKit RTC credentials: %v", err) // unauthorized: the secret or the URL is wrong; the message never holds the key
}
A key with a stray line break or an accented character from a mangled file is refused by NewStreamClient itself, with an error that says to check how the key is loaded.
The account is out of credit or at a limit
New rooms, tokens and messages fail with IsOutOfMinutes(): quota-exhausted, daily-limit-reached or monthly-limit-reached, all 402 and not retried; rooms already live are warned and then ended. Answer the viewer with 402 and a message the project owner can act on (recharge, or wait for the limit to reset), and read GetBalance to say so before anyone tries: CanJoin false and Blocked with the code. billing-unavailable (503) is different: wallet billing is not ready, and a retry does not help; contact support.
The room is full
viewer-quota (429, IsQuota()): the room is at its viewer limit. Tell the viewer it is full; the SDK has already retried, so do not loop. room-quota (429) is your project's concurrent-room limit instead: end rooms you no longer need with DeleteRoom, or raise the limit. rate-limited (429) is your project's API rate limit: spread calls out.
The room has ended
no-room (404, IsNotFound()): the room is gone, or was never yours. For a viewer, say the show has ended. For the host coming back, call EnsureRoom with the same RoomID: it makes the room again if it is gone, and returns it with a fresh host token if it is not, Existed telling you which.
The network drops mid-call
Two different things. A call to the API that loses its connection, before or after the headers, fails with network (Status 0, Retryable true) and is retried on every call that is safe to repeat; the attempt's deadline covers the body, so a reply that stalls after its headers fails the same way. When the error reaches you, the retries are spent: answer 503 with Retry-After, and try again from a job or the user's next action. For CreateRoom, CreateStream and SendMessage, see A create may or may not have landed. A participant whose connection drops is not your backend's problem: the client SDKs reconnect on their own, and your webhook receives participant.left with reason disconnected only when they do not come back. Your server is never in the media path.
A create may or may not have landed
CreateRoom, CreateStream and SendMessage are sent once. A 429 means nothing was done: send again after a pause. A network error or a 5xx may have done the work before the reply was lost: check first, then repeat, as the table under Calls that are never repeated says. For rooms, EnsureRoom with your own id is both the check and the repeat.
The webhook does not verify
ParseWebhook returns bad-signature, and VoltKit shows the 401s in the delivery log. In order of likelihood: the body was parsed and re-serialised before it reached ParseWebhook (a JSON middleware in front of the handler: read r.Body raw); the secret is the wrong one, or has whitespace around it (nothing is trimmed: use it exactly as the dashboard shows it); the secret was rotated and the receiver holds only the old one (pass both, as above); your server's clock is more than five minutes off (t is checked both ways; fix the clock rather than widening Tolerance); a proxy split the header into two lines (join r.Header.Values). VoltKit retries a 401, so a fix within the retry window drops nothing.
Behind a proxy or a redirect
Go's default transport honours HTTPS_PROXY, HTTP_PROXY and NO_PROXY; the secret travels inside TLS to VoltKit, which a proxy that only forwards the connection never sees. For anything else (a proxy with its own authentication, a corporate CA, mTLS), pass an HTTPClient whose Transport is set up for it: the SDK copies the client, keeps your transport, and only changes the redirect policy and the timeout. A BaseURL that answers with a redirect, such as an http:// address upgraded to https:// or a www. rewrite, fails every call with redirected: set it to the exact scheme and host the Credentials tab shows. A proxy that answers with its own page for an error, or for a success, comes back as http-<status> with the page's first words in the message, redacted.
A slow request in a web handler
A call can take about 31 seconds with the defaults (three attempts of 10 seconds plus the waits), which is longer than a user waits. Either lower Timeout and Retries for a client used in handlers, or give every call the request's context with a deadline (context.WithTimeout(r.Context(), 3*time.Second)): the SDK stops at once when it passes, mid-body or between attempts, and returns context.DeadlineExceeded. Answer 503 or 504 with Retry-After, and never retry in the handler itself. Keep a second client with the default budget for background jobs, where waiting is fine.
Rotating a secret
Deploy the receiver with both secrets before pressing Rotate secret, then swap the variables and deploy again before the overlap ends: the steps are under Rotating the signing secret. Rotating the API secret is simpler: issue the new one from the Credentials tab, deploy it, then revoke the old one; a unauthorized after that means some instance still holds the old key.
Versions
voltkit.Version is the module's version, and User-Agent: voltkit-rtc-go/<Version> goes on every request. Upgrade with go get -u github.com/voltkit-rtc/voltkit-rtc-server-go; the changelog is on pkg.go.dev with the module. Until 1.0, a breaking change is a minor bump and everything else a patch.
| Version | What changed |
|---|---|
| 0.1.0 | The first release, at parity with the Node.js SDK 0.4.8: every method, the same retry and error behaviour, the API key never in an error or a printed client, redirects never followed with the key, webhooks with rotation. |
Without the SDK
Nothing in the SDK is required. Any HTTP client in any language can make the same calls, with your secret in X-API-Key: see the API reference. Go's net/http will do, as long as you keep what the SDK keeps: a deadline that covers the body, no redirects with the key, and the key out of your logs.
Help
Questions and bug reports: contact@voltkit.in. Send the error's Code and Message, which never contain your secret, and never the secret itself. Getting technical support lists what else to include.
Broadcast
Not offered on VoltKit cloud today; here for deployments that enable broadcast. On VoltKit cloud every one of these calls fails with broadcast-disabled (404, IsBroadcastDisabled()), and the methods are kept for parity with the API and the other server SDKs.
live, err := client.CreateStream(ctx, voltkit.CreateStreamParams{StreamID: "show-42", Playback: voltkit.PlaybackPublic})
// live.StreamKey, live.State ("idle"), live.PlaybackURLs.LLHLS, live.PlaybackURLs.WebRTC,
// live.Publish.WHIPURL (carries the publish token: returned ONLY here; treat it like a password)
// The publish URL is returned only at creation. Mint another when you need one:
pub, err := client.CreateStreamToken(ctx, voltkit.CreateStreamTokenParams{StreamID: "show-42", Action: voltkit.ActionPublish}) // pub.WHIPURL, pub.Token, pub.ExpiresAt
// With Playback token, each viewer needs URLs minted for them. Action defaults to play.
play, err := client.CreateStreamToken(ctx, voltkit.CreateStreamTokenParams{StreamID: "show-42", Identity: "user-99", TTLSeconds: 3600}) // play.LLHLS, play.WebRTC, play.Token, play.ExpiresAt
s, err := client.GetStream(ctx, "show-42") // s.State, s.Viewers, s.PeakViewers
streams, err := client.ListStreams(ctx) // []voltkit.BroadcastStream
s, err = client.UpdateStream(ctx, "show-42", voltkit.UpdateStreamParams{Playback: voltkit.PlaybackToken}) // who may watch, or metadata
err = client.DeleteStream(ctx, "show-42") // takes it off air; its URLs stop working
Publish.WHIPURL carries the publish token, so treat it like a password. TTLSeconds is capped at 24 hours. Viewers counts the playback sessions the media server serves itself, not an audience behind a CDN. CreateStream is never retried, for the same reason as CreateRoom. See Live broadcast.
Last updated . Something unclear or wrong? Tell us.