Skip to content

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
FieldDefaultWhat it does
BaseURLrequiredYour 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.
APIKeyrequiredYour API secret. Whitespace around it is ignored.
RatesBaseURLunsetVoltKit's website API, https://api.voltkit.in, where GetRates reads the price list. It is never sent the secret. "" and "/" mean unset.
Timeout10 sThe 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.
Retriesnil = 2How many more attempts a call that is safe to repeat gets after a temporary failure. voltkit.Ptr(0) turns retries off.
HTTPClienta fresh http.ClientYour 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
  • EnsureRoom 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. CreateRoom is the strict version: it fails with room-exists (409) if the id is taken, and makes up an id if you leave RoomID empty.
  • Metadata (any value encoding/json can 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 as json.RawMessage. Left nil, it is left out of the body.
  • Role is voltkit.RoleHost, RoleCohost, RoleModerator or RoleViewer. 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 GetRoom 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.
  • 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
	}
}
  • Legacy are the minutes spent first, in participant-seconds. On an unlimited plan Unlimited is true, and OwnSeconds and TotalSeconds are -1.
  • Wallet is nil 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 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: nil for 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, GetRates fails at once with rates-unconfigured (Status 0) and sends nothing.
  • A RatesBaseURL that names something other than the website API gets a reply with no rates in it. The call fails, usually 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.
  • GetRates sends no secret, so it may follow redirects, with your HTTPClient's policy (Go's default stops after ten hops, which comes back as a network error).

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.

MethodRequestRetriedReturns
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/roomsNothe same, with Existed false
CreateToken(ctx, CreateTokenParams{RoomID, Identity, Role})POST /v1/rooms/{roomId}/tokensYes*RoomToken: Token, WSURL, Role, Identity, RoomID, ExpiresAt
GetRoom(ctx, roomID)GET /v1/rooms/{roomId}Yes*RoomView: Room, Participants, Producers
ListRooms(ctx)GET /v1/roomsYes*RoomList: Rooms
ListParticipants(ctx, roomID)GET /v1/rooms/{roomId}/participantsYes[]Participant, empty rather than nil
DeleteRoom(ctx, roomID)DELETE /v1/rooms/{roomId}Yesnothing
CreateChatToken(ctx, CreateChatTokenParams{Channel, Identity, Role})POST /v1/chat/{channel}/tokensYes*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}/messagesYes[]ChatMessage, oldest first
SendMessage(ctx, SendMessageParams{Channel, Text, Type, From, To, Metadata, Ephemeral})POST /v1/chat/{channel}/messagesNo*ChatMessage
DeleteMessage(ctx, channel, messageID)DELETE /v1/chat/{channel}/messages/{messageId}Yesnothing
MuteChat(ctx, channel, identity)PUT /v1/chat/{channel}/mutes/{identity}Yesnothing
UnmuteChat(ctx, channel, identity)DELETE /v1/chat/{channel}/mutes/{identity}Yesnothing
ListChatMutes(ctx, channel)GET /v1/chat/{channel}/mutesYes[]string
GetUsage(ctx)GET /v1/usageYes*UsageSummary: MinutesRemaining, Today, Month, Lifetime, Allowance, ...
GetUsageSeries(ctx, days)GET /v1/usage/series?days={days}Yes[]DailyUsage, oldest first
GetBalance(ctx)GET /v1/billing/balanceYes*ProjectBalance
GetRates(ctx)GET /api/v1/billing/rtc/rates/current on RatesBaseURL, with no keyYes*RtcRates
CreateStream(ctx, CreateStreamParams{StreamID, Playback, Metadata})POST /v1/streamsNo*BroadcastStreamWithPublish: the stream, with Publish
CreateStreamToken(ctx, CreateStreamTokenParams{StreamID, Action, Identity, TTLSeconds})POST /v1/streams/{streamId}/tokensYes*StreamToken: Token, ExpiresAt, with WHIPURL, or with LLHLS and WebRTC
GetStream(ctx, streamID)GET /v1/streams/{streamId}Yes*BroadcastStream
ListStreams(ctx)GET /v1/streamsYes[]BroadcastStream
UpdateStream(ctx, streamID, UpdateStreamParams{Playback, Metadata})PATCH /v1/streams/{streamId}Yes*BroadcastStream
DeleteStream(ctx, streamID)DELETE /v1/streams/{streamId}Yesnothing

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 a context.WithTimeout on your context. A service that does not answer, or a reply that stalls after its headers, fails with network and "timed out after 5000ms". Your HTTPClient's own Timeout is not used: the context is the one clock.
  • 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. Retry-After is 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 a 2xx reply that is not JSON.
  • How long a call can take. Up to (Retries + 1) × Timeout, plus the waits between attempts: about 31 seconds with the defaults. In a request a user is waiting on, lower both, 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.Canceled or context.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 network error or a 5xx may have done the work before the reply was lost. Check first:
CallCheck, then repeat
CreateRoomCall 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.
CreateStreamCall 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.
SendMessagePut 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:

  • GetRates sends nothing to RatesBaseURL.
  • 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 (&sol;, &#47;, &#x2F;), 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. 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 methodMeaning
CodeThe 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.
StatusThe HTTP status. 0 when nothing reached the service or the SDK refused the call itself; the 3xx for redirected.
Retryabletrue 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.
MessageThe 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.

CodeStatusRetriedWhat it means, and what to do
unauthorized401NoA 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-suspended403NoThe account is suspended. Contact support.
quota-exhausted402NoOut of minutes or credits. The project owner recharges; GetBalance says so before anyone tries.
daily-limit-reached402NoThe daily cap is reached. Wait for the UTC day to reset, or raise the cap.
monthly-limit-reached402NoThe monthly cap is reached. Wait for the month to reset, or raise the cap.
billing-unavailable503NoWallet billing is not ready: no active price list, or no wallet billing on this service. Contact support; a retry does not help.
no-room404NoThe room is gone, or was never yours. Treat the show as ended.
no-message404NoThe message is gone. Nothing to do.
not-found404NoThe mute, or whatever the call named, is not there. Nothing to do.
no-stream404NoThe stream is gone. Treat it as off air.
broadcast-disabled404NoBroadcast is not available on this deployment (VoltKit cloud today). Do not retry.
chat-disabled404NoChat is not available on this deployment. Do not retry.
room-exists409NoThe id is taken. Use EnsureRoom, or another id.
stream-exists409NoThe stream id is taken. Use the existing stream, or another id.
bad-request400NoFix 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-role400NoRole is not host, cohost, moderator or viewer. Fix the call.
operator-only403NoAn operator endpoint with a project key. Not something an SDK call reaches.
bad-signature401NoFrom ParseWebhook: the delivery did not verify. Answer 401; VoltKit retries it.
rate-limited429YesYour project's API rate limit (600 per minute by default). Slow down; spread calls out.
room-quota429YesAt your concurrent-room limit. End rooms you no longer need, or raise the limit.
viewer-quota429YesThe room is at its viewer limit. Tell the viewer it is full.
stream-quota429YesAt your concurrent-stream limit. Delete streams you no longer need.
capacity-exhausted503YesThe fleet is full: new rooms are failing right now. Ask the user to try again shortly, and alert.
no-nodes503YesNo media node is registered. As above.
unavailable503YesThe service could not answer (a read failed). Try again later; tell us if it repeats.
internal500YesA fault on the service. Try again later; tell us if it repeats.
network0YesNo reply: DNS, a refused or dropped connection, the timeout, a TLS failure. Try again later; if it persists, check your server's outbound network.
redirected3xxNoThe server answered a request carrying the key with a redirect. Fix BaseURL: see Redirects are never followed.
rates-unconfigured0NoGetRates without RatesBaseURL. Set it to the website API's origin.
http-<status>that statusOnly a 429 or 5xxA 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 sentBy statusThe 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:

MistakeHow 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 fileNewStreamClient 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 negativeNewStreamClient returns an error
An id of "", "." or "..", for a room, channel, message, identity or streamThe 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 MetadataThe 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 RatesBaseURLThe call returns *voltkit.Error rates-unconfigured, Status 0
VerifyWebhook with no secret, or a negative TolerancePanics, 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. Read r.Body yourself, before any JSON middleware. With chi the handler above is the handler; with Gin read c.GetRawData() and the header with c.GetHeader("X-Stream-Signature"), then c.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.Get returns. If something in front of your server splits it into two header lines, pass strings.Join(r.Header.Values("X-Stream-Signature"), ",") instead.
  • What it checks. The timestamp must be within Tolerance of your clock (voltkit.DefaultTolerance, five minutes, in either direction, inclusive), so a captured request cannot be replayed later, and a v1 signature must match one of your secrets, compared in constant time with hmac.Equal, every secret against every signature. To widen the window, pass &voltkit.VerifyOptions{Tolerance: voltkit.Ptr(10 * time.Minute)} as the last argument; Now replaces the clock in tests. A negative Tolerance is a plain error, not a switched-off check.
  • What it returns. The event, or an *voltkit.Error with code bad-signature (Status 401) when the delivery does not verify: answer 401, and VoltKit retries it. A plain error (no secret at all, a negative Tolerance, a verified body that is not JSON) is a mistake in your setup.
  • VerifyWebhook takes the same arguments and returns true or false instead of the event; a mistake in the setup makes it panic rather than return a false that would look like an attack. Prefer ParseWebhook in 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 the X-Stream-Delivery header, with X-Stream-Attempt).
  • Do not assume order. A participant.left can arrive before the participant.joined it followed; pair them by identity and joinedAt.

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:

  1. Deploy the handler above, which takes a list. With VOLTKIT_RTC_WEBHOOK_SECRET_OLD unset, it verifies exactly as before.
  2. Press Rotate secret. Move the current secret to VOLTKIT_RTC_WEBHOOK_SECRET_OLD, put the new one in VOLTKIT_RTC_WEBHOOK_SECRET, and deploy before the overlap ends.
  3. 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.

VersionWhat changed
0.1.0The 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.