SDKs
Java Server SDK with a Spring Boot Starter
in.voltkit:voltkit-rtc-server for Java: a Spring Boot starter with token and webhook endpoints, plain Java, rooms, tokens, chat, usage, balance and errors.
in.voltkit:voltkit-rtc-server is the server-side Java video calling SDK: it wraps the REST API for a Java backend, and in.voltkit:voltkit-rtc-server-spring-boot-starter makes its two beans from application.yml, so a Spring Boot video call token server is one controller (A token endpoint). 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.
Install
Gradle (Kotlin DSL):
dependencies {
implementation("in.voltkit:voltkit-rtc-server:0.1.0")
// Spring Boot: the starter brings the SDK in and makes the beans for you
implementation("in.voltkit:voltkit-rtc-server-spring-boot-starter:0.1.0")
}
Gradle (Groovy):
dependencies {
implementation 'in.voltkit:voltkit-rtc-server:0.1.0'
implementation 'in.voltkit:voltkit-rtc-server-spring-boot-starter:0.1.0' // Spring Boot only
}
Maven:
<dependency>
<groupId>in.voltkit</groupId>
<artifactId>voltkit-rtc-server</artifactId>
<version>0.1.0</version>
</dependency>
<!-- Spring Boot: the starter instead; it depends on voltkit-rtc-server -->
<dependency>
<groupId>in.voltkit</groupId>
<artifactId>voltkit-rtc-server-spring-boot-starter</artifactId>
<version>0.1.0</version>
</dependency>
Java 17 or newer. The SDK's one dependency is com.fasterxml.jackson.core:jackson-databind (2.15 or newer, pulled in); the starter works on Spring Boot 3.5 and 4.x. Everything lives in the package in.voltkit.rtc, the starter in in.voltkit.rtc.spring. Every reply is a Java record (RoomWithHostToken, RoomToken, ChatMessage, UsageSummary, ProjectBalance, RtcRates, StreamEvent and the rest), with a JsonNode 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
import in.voltkit.rtc.*;
StreamClient rtc = StreamClient.builder()
.baseUrl(System.getenv("VOLTKIT_RTC_URL")) // your API base URL, e.g. https://api.voltkit.in
.apiKey(System.getenv("VOLTKIT_RTC_SECRET")) // your API secret: server-side only
.build();
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 immutable and safe to use from any thread. build() checks every option, so a missing or malformed value fails at startup instead of on a user's first call (see Mistakes caught before a request). StreamClient.fromEnv() reads the same two variables, plus the optional VOLTKIT_API_URL for rates, and nothing else; fromEnv(Map<String, String>) reads the same names from a map:
StreamClient rtc = StreamClient.fromEnv(); // VOLTKIT_RTC_URL, VOLTKIT_RTC_SECRET and, optionally, VOLTKIT_API_URL
| Builder method | Default | What it does |
|---|---|---|
baseUrl(String) | 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; it must be an absolute URL with a host. |
apiKey(String) | required | Your API secret. Whitespace around it is ignored; a line break or an accented character inside it is refused (see below). |
ratesBaseUrl(String) | unset | VoltKit's website API, https://api.voltkit.in, where getRates() reads the price list. It is never sent the secret. null, "" and "/" mean unset. |
timeout(Duration) | 10 s | The deadline for each attempt, from sending the request to reading the last byte of the reply. Above zero; kept in whole milliseconds, rounded up; above 2^31−1 ms it is held there. |
retries(int) | 2 | How many more attempts a call that is safe to repeat gets after a temporary failure. 0 turns retries off. |
httpClient(HttpClient) | a fresh java.net.http.HttpClient | Your own client, for a proxy, an SSL context or an executor. It must be built with followRedirects(HttpClient.Redirect.NEVER); the SDK derives a redirect-following sibling from it for the rates host. |
transport(HttpTransport) | JdkHttpTransport | Any transport of your own: a fake for tests, an adapter over another HTTP client, instrumentation. See Testing. |
rtc.timeout(), rtc.retries(), rtc.baseUrl() and rtc.ratesBaseUrl() show what a client was built with; the URLs are shown with any credentials as [redacted].
Spring Boot
The starter registers a StreamClient when voltkit.rtc.api-key is set and a WebhookVerifier when voltkit.rtc.webhook.secrets is set. Your own bean of either type wins; an HttpTransport bean, if you define one, is used by the client. A bad value fails the application at startup with the SDK's own message, which never quotes the secret. It registers no controller: the endpoints below are yours.
voltkit:
rtc:
base-url: ${VOLTKIT_RTC_URL} # required: your API base URL
api-key: ${VOLTKIT_RTC_SECRET} # required: the secret, from the environment, never in this file
rates-base-url: ${VOLTKIT_API_URL:} # optional, for getRates(); empty means unset
timeout: 5s # default 10s: each attempt, headers and body
retries: 1 # default 2
webhook:
secrets: ${VOLTKIT_RTC_WEBHOOK_SECRETS:} # whsec_current, or "whsec_new,whsec_old" during a rotation; a YAML list works too
tolerance: 300s # default 300s, either way
| Property | Type | Bean it feeds |
|---|---|---|
voltkit.rtc.base-url | string | StreamClient |
voltkit.rtc.api-key | string | StreamClient (the bean exists only when this is set) |
voltkit.rtc.rates-base-url | string | StreamClient |
voltkit.rtc.timeout | duration (5s, 500ms) | StreamClient |
voltkit.rtc.retries | integer, 0 or more | StreamClient |
voltkit.rtc.webhook.secrets | comma-separated string or list | WebhookVerifier (the bean exists only when at least one is set) |
voltkit.rtc.webhook.tolerance | duration | WebhookVerifier |
The starter ships configuration metadata, so your IDE completes the keys. Inject the beans where you need them:
@Service
public class ShowService {
private final StreamClient rtc;
public ShowService(StreamClient rtc) {
this.rtc = rtc;
}
public RoomWithHostToken goLive(String showId, String ownerId) {
return rtc.ensureRoom("show-" + showId, "user-" + ownerId);
}
}
A token endpoint
Your backend decides the role, mints the token, and maps every failure to a status without leaking why. The user comes from your session, never from the request body.
import in.voltkit.rtc.Role;
import in.voltkit.rtc.RoomToken;
import in.voltkit.rtc.StreamClient;
import in.voltkit.rtc.StreamException;
import java.security.Principal;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class TokenController {
private static final Logger log = LoggerFactory.getLogger(TokenController.class);
private final StreamClient rtc;
private final ShowRepository shows;
public TokenController(StreamClient rtc, ShowRepository shows) {
this.rtc = rtc;
this.shows = shows;
}
public record TokenReply(String token, String wsUrl, long expiresAt, String role) {}
@PostMapping("/shows/{showId}/join")
public ResponseEntity<?> join(@PathVariable String showId, Principal user) {
String role = shows.isOwner(showId, user.getName()) ? Role.HOST : Role.VIEWER; // your rule; signed into the token
try {
RoomToken t = rtc.createToken("show-" + showId, "user-" + user.getName(), role);
return ResponseEntity.ok()
.header("Cache-Control", "no-store")
.body(new TokenReply(t.token(), t.wsUrl(), t.expiresAt(), t.role()));
} catch (StreamException e) {
log.warn("createToken failed: {} ({}, status {})", e.getMessage(), e.getCode(), e.getStatus()); // safe: the secret is never in it
if (e.isNotFound()) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("error", "This show has ended")); // no-room
}
if (e.isOutOfMinutes()) {
return ResponseEntity.status(HttpStatus.PAYMENT_REQUIRED).body(Map.of("error", "This show cannot take viewers right now"));
}
if ("viewer-quota".equals(e.getCode())) {
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS).body(Map.of("error", "This show is full"));
}
if (e.isRetryable()) {
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).header("Retry-After", "10").body(Map.of("error", "Try again in a moment"));
}
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Map.of("error", "Something went wrong")); // 401, 403, 400: a problem on your side
}
}
}
With Spring Security, keep CSRF protection off for this route (the app is not a browser form), or the app's POST gets 403 before your code runs. A complete token endpoint for every language is on Token server.
A webhook endpoint
Read the raw body (@RequestBody byte[], never a parsed type), verify it against every configured secret, answer 401 when it does not verify, otherwise answer 200 first and do the work after, deduplicating on the event id.
import in.voltkit.rtc.StreamEvent;
import in.voltkit.rtc.StreamException;
import in.voltkit.rtc.WebhookVerifier;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class WebhookController implements AutoCloseable {
private final WebhookVerifier verifier; // from the starter: voltkit.rtc.webhook.secrets and tolerance
private final ExecutorService work = Executors.newSingleThreadExecutor();
private final Set<String> seen = ConcurrentHashMap.newKeySet(); // bound it in a real service: the last few thousand ids
public WebhookController(WebhookVerifier verifier) {
this.verifier = verifier;
}
@PostMapping("/hooks/voltkit")
public ResponseEntity<Void> receive(@RequestBody byte[] rawBody,
@RequestHeader(value = "X-Stream-Signature", required = false) String signature) {
StreamEvent event;
try {
event = verifier.parse(rawBody, signature);
} catch (StreamException e) { // bad-signature: not from VoltKit, or too old
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
if (seen.add(event.id())) { // delivery is at-least-once: a redelivery is skipped
work.submit(() -> handle(event)); // the work happens after the 200 below
}
return ResponseEntity.ok().build();
}
private void handle(StreamEvent event) {
switch (event.type()) { // never assume order: a participant.left can arrive before its participant.joined
case "room.ended" -> markEnded(event.roomId(), event.data());
case "usage.limit_reached" -> alertOutOfMinutes(event.data());
default -> { }
}
}
@Override
public void close() { // Spring closes an AutoCloseable bean with the context
work.shutdown();
}
}
With Spring Security, leave this path out of CSRF protection too. Without the starter, make the verifier yourself: new WebhookVerifier(List.of(current, previous)). The event's data() is a JsonNode; its shape by type is on Webhooks. examples/spring-boot in the SDK repository is this application, complete and runnable.
Spring Boot 3 or 4
The starter is built against Spring Boot 4.1 and its tests run again on 3.5, so either line works with the same properties. One difference to know: Spring Boot 4 uses Jackson 3 (tools.jackson) for its own JSON, while the SDK's records hold Jackson 2 types (com.fasterxml.jackson.databind.JsonNode for metadata and data()), which the SDK brings along. Read them with the JsonNode API (event.data().path("reason").asText()), or map them with a Jackson 2 ObjectMapper of your own (new com.fasterxml.jackson.databind.ObjectMapper().treeToValue(node, MyType.class)); do not return a JsonNode straight from a Boot 4 controller, where Jackson 3 would not recognise it. Everything else (String, long, List, the records themselves) serialises fine either way.
Plain Java
Nothing needs Spring. Build the client once, and verify webhooks with Webhooks.parse or a WebhookVerifier you keep:
import com.sun.net.httpserver.HttpServer;
import in.voltkit.rtc.*;
import java.net.InetSocketAddress;
import java.util.List;
StreamClient rtc = StreamClient.fromEnv(); // VOLTKIT_RTC_URL, VOLTKIT_RTC_SECRET
WebhookVerifier hooks = new WebhookVerifier(List.of(System.getenv("VOLTKIT_RTC_WEBHOOK_SECRET")));
RoomWithHostToken room = rtc.ensureRoom("show-42", "user-7"); // the host goes live
RoomToken viewer = rtc.createToken("show-42", "user-99", Role.VIEWER); // a viewer joins
HttpServer server = HttpServer.create(new InetSocketAddress(8080), 0);
server.createContext("/hooks/voltkit", exchange -> {
byte[] raw = exchange.getRequestBody().readAllBytes(); // the RAW bytes
String signature = exchange.getRequestHeaders().getFirst("X-Stream-Signature");
StreamEvent event;
try {
event = hooks.parse(raw, signature);
} catch (StreamException e) {
exchange.sendResponseHeaders(401, -1);
exchange.close();
return;
}
exchange.sendResponseHeaders(200, -1); // answer first
exchange.close();
queue.add(event); // then do the work
});
server.start();
Rooms and tokens
// Create the room, or get it back if it already exists.
RoomWithHostToken room = rtc.ensureRoom("show-42", "user-7");
// room.roomId(), room.hostToken(), room.wsUrl(), room.expiresAt(), room.existed()
// With metadata every participant receives on join (any value Jackson can write: a Map, a POJO, a JsonNode).
RoomWithHostToken withSeats = rtc.ensureRoom(new EnsureRoomParams("show-42", "user-7", Map.of("seatFormat", 20)));
// The strict version: room-exists (409) if the id is taken; makes an id up if you leave roomId out.
RoomWithHostToken made = rtc.createRoom(CreateRoomParams.builder().roomId("show-42").hostIdentity("user-7").build());
// Let someone in, with the role YOU decide: Role.HOST, COHOST, MODERATOR or VIEWER.
RoomToken token = rtc.createToken("show-42", "user-99", Role.VIEWER);
// token.token(), token.wsUrl(), token.expiresAt(): hand these to that one client
RoomView view = rtc.getRoom("show-42"); // the authoritative view: view.room(), view.participants(), view.producers()
RoomList rooms = rtc.listRooms(); // every live room, as the wrapper object the API sends: rooms.rooms()
List<Participant> people = rtc.listParticipants("show-42"); // identity(), role(), handRaised(), muted(), joinedAt(), metadata()
rtc.deleteRoom("show-42"); // ends it for everyone
ensureRoomcreates 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.createRoomis the strict version: it fails withroom-exists(409) if the id is taken, and makes up an id if you leaveroomIdout.metadataon either call is room state that every participant receives when they join. It is applied only when the room is created; replies carry it as aJsonNode.roleis a plain string (Role.HOST,Role.COHOST,Role.MODERATOR,Role.VIEWER). It is signed into the token, so the client cannot change it. A token lasts ten minutes; a connected client is sent fresh ones, so a call can run for hours.expiresAt()is in unix seconds;createdAt()andjoinedAt()are in milliseconds.- 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}, scoped to your project: another project's room is a 404, never a 403. Any id is percent-encoded as one path segment before it is sent;"","."and".."are refused.
Roles are covered in Rooms, and a complete token endpoint is above and on Token server.
Chat
// Admit someone to a channel's chat with no media rights (role defaults to viewer).
ChatToken chat = rtc.createChatToken("show-42", "user-99");
ChatToken mod = rtc.createChatToken(new CreateChatTokenParams("show-42", "user-5", Role.MODERATOR));
// chat.token(), chat.wsUrl() (ends in /ws/chat), chat.role(), chat.identity(), chat.channel(), chat.expiresAt()
// Take part without holding a connection.
rtc.sendMessage(SendMessageParams.builder("show-42").text("The show starts in 5 minutes").type("system").build());
rtc.sendMessage(SendMessageParams.builder("show-42").type("gift").from("user-7").metadata(Map.of("gift", "rose", "count", 5)).build());
rtc.sendMessage(SendMessageParams.builder("show-42").type("reaction").metadata(Map.of("emoji", "heart")).ephemeral(true).build()); // not stored
rtc.sendMessage(SendMessageParams.builder("show-42").to("user-13").text("Please keep it friendly").build()); // a direct message
List<ChatMessage> messages = rtc.listMessages("show-42", ListMessagesOptions.limit(100)); // oldest first
List<ChatMessage> older = rtc.listMessages("show-42", ListMessagesOptions.before(messages.get(0).id())); // the page before that
rtc.deleteMessage("show-42", messageId);
rtc.muteChat("show-42", "user-13"); // survives reconnecting; they can still read
rtc.unmuteChat("show-42", "user-13");
List<String> muted = rtc.listChatMutes("show-42"); // ["user-13"]
ChatInfo info = rtc.getChatInfo("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.
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; new ListMessagesOptions(limit, before) takes both. A ChatMessage carries id(), channel(), from(), to(), type(), text(), metadata(), clientMsgId() and ts() (ms). sendMessage is never retried. See Chat.
Usage
UsageSummary usage = rtc.getUsage();
// usage.minutesRemaining() is -1 on an unlimited plan; usage.today(), month(), lifetime() each carry
// participantSeconds(), chatMessages(), broadcastSeconds(), ...; usage.allowance() says where the account stands now
List<DailyUsage> days = rtc.getUsageSeries(30); // one entry per UTC day, oldest first, ending today
List<DailyUsage> month = rtc.getUsageSeries(); // 30 days; the server caps days at 90
Each period is a Usage with participantSeconds(), publisherSeconds(), roomSeconds(), roomsCreated(), peakParticipants(), chatMessages(), chatConnectionSeconds(), broadcastSeconds(), broadcastViewerSeconds(), streamsStarted(), and billableSeconds() / freeSeconds() (nullable Long). allowance() breaks it down: free minutes left today and this month, the paid balance, the daily and monthly caps, and blocked() with the reason when the project cannot be used. A DailyUsage adds day() (YYYY-MM-DD, UTC); a day with no usage is present and zero. See Reading your usage.
Balance and rates
The balance is the VoltKit Wallet a project spends from, after its free and bought minutes.
Balance
ProjectBalance balance = rtc.getBalance();
// balance.billingMode() "legacy" | "wallet"; balance.canJoin(); balance.blocked() (null when it can join)
// balance.legacy(): unlimited(), freeSeconds(), ownSeconds(), sharedSeconds(), totalSeconds()
// balance.wallet() (null unless billed from the wallet): paidCu(), promoCu(), availableCu() "186000000", availableCredits() "31"
// balance.burn(): liveParticipants(), projectCuPerSecond(), walletCuPerSecond()
// balance.remainingSeconds(), balance.walletLow().active(), balance.pricingVersion(), balance.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:
ProjectBalance balance = rtc.getBalance();
if (!balance.canJoin()) {
switch (balance.blocked()) {
case "tenant-suspended" -> respond(403, "This account is suspended");
case "billing-unavailable" -> respond(503, "Billing is being set up");
default -> respond(402, "Recharge to go live"); // quota-exhausted, daily- or monthly-limit-reached
}
}
legacy()are the minutes spent first, in participant-seconds. On an unlimited planunlimited()istrue, andownSeconds()andtotalSeconds()are-1.wallet()isnullunless the project is billed from the wallet (billingMode()iswallet). It lists only the shares this project may spend (spendOrder(),scopes()), in the order it spends them: its own share (project:<id>), the RTC share (product:rtc), thenunallocated. 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 (-1when nothing is being spent or nothing limits it).walletLow().active()istruewhile the project's rooms are being warned.pricingVersion()is the price list new sessions start on:nullfor a legacy project, and while no price list is active.
Amounts are strings. Money is in credit units (cu): 1 credit = 6,000,000 units, 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 the arithmetic with BigInteger, through Cu, and show availableCredits(), which is already formatted (at most four decimals, rounded down):
BigInteger cu = Cu.parse(balance.wallet().availableCu()); // "186000000" -> 186000000, exactly
BigInteger wholeCredits = Cu.wholeCredits(cu); // 31 (Cu.PER_CREDIT is 6,000,000)
Rates
StreamClient rtc = StreamClient.builder()
.baseUrl(System.getenv("VOLTKIT_RTC_URL"))
.apiKey(System.getenv("VOLTKIT_RTC_SECRET"))
.ratesBaseUrl("https://api.voltkit.in") // VoltKit's website API: the origin, without /api/v1
.build();
RtcRates rates = rtc.getRates();
// rates.version(), rates.kind() "standard", rates.effectiveAt(), rates.unit(),
// rates.tiers(): 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 withrates-unconfigured(status 0) 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. - It is the one call that may follow a redirect (a
www.or HTTPS upgrade in front of the website API).
A rate's cuPerParticipantSecond() is exact, so a cost is integer arithmetic:
RtcTierRate video = rates.tiers().stream().filter(t -> t.id().equals("video_30")).findFirst().orElseThrow();
BigInteger cost = Cu.parse(video.cuPerParticipantSecond()).multiply(BigInteger.valueOf(4L * 30 * 60)); // 4 people, 30 minutes: 71280000 units, 11.88 credits
boolean affordable = Cu.parse(balance.wallet().availableCu()).compareTo(cost) >= 0; // rough: free and bought minutes are spent first
Every method
"Retried" means the SDK tries the call again after a temporary failure (see Timeouts, retries and interrupts). ? marks an optional field: left null, it is not sent. Every reply is a record in in.voltkit.rtc; one annotated to ignore unknown fields, so a field a newer service adds never breaks an old SDK.
| Method | Request | Retried | Returns |
|---|---|---|---|
ensureRoom(EnsureRoomParams) / ensureRoom(roomId, hostIdentity) | PUT /v1/rooms/{roomId} | Yes | RoomWithHostToken with existed() |
createRoom(CreateRoomParams{roomId?, hostIdentity, metadata?}) | POST /v1/rooms | No | RoomWithHostToken, existed() false |
createToken(CreateTokenParams) / createToken(roomId, identity, role) | POST /v1/rooms/{roomId}/tokens | Yes | RoomToken |
getRoom(roomId) | GET /v1/rooms/{roomId} | Yes | RoomView{room, participants, producers} |
listRooms() | GET /v1/rooms | Yes | RoomList{rooms} |
listParticipants(roomId) | GET /v1/rooms/{roomId}/participants | Yes | List<Participant> |
deleteRoom(roomId) | DELETE /v1/rooms/{roomId} | Yes | nothing |
createChatToken(CreateChatTokenParams{channel, identity, role?}) / createChatToken(channel, identity) | POST /v1/chat/{channel}/tokens | Yes | ChatToken |
getChatInfo(channel) | GET /v1/chat/{channel} | Yes | ChatInfo{channel, members} |
listMessages(channel, ListMessagesOptions{limit?, before?}) / listMessages(channel) | GET /v1/chat/{channel}/messages | Yes | List<ChatMessage>, oldest first |
sendMessage(SendMessageParams{channel, text?, type?, from?, to?, metadata?, ephemeral?}) | POST /v1/chat/{channel}/messages | No | ChatMessage |
deleteMessage(channel, messageId) | DELETE /v1/chat/{channel}/messages/{messageId} | Yes | nothing |
muteChat(channel, identity) | PUT /v1/chat/{channel}/mutes/{identity} | Yes | nothing |
unmuteChat(channel, identity) | DELETE /v1/chat/{channel}/mutes/{identity} | Yes | nothing |
listChatMutes(channel) | GET /v1/chat/{channel}/mutes | Yes | List<String> |
getUsage() | GET /v1/usage | Yes | UsageSummary |
getUsageSeries(days) / getUsageSeries() (30) | GET /v1/usage/series?days={days} | Yes | List<DailyUsage>, oldest first |
getBalance() | GET /v1/billing/balance | Yes | ProjectBalance |
getRates() | GET /api/v1/billing/rtc/rates/current on ratesBaseUrl, with no key | Yes | RtcRates |
createStream(CreateStreamParams{streamId, playback?, metadata?}) | POST /v1/streams | No | BroadcastStream with publish() |
createStreamToken(CreateStreamTokenParams{streamId, action?, identity?, ttlSeconds?}) | POST /v1/streams/{streamId}/tokens | Yes | StreamToken |
getStream(streamId) | GET /v1/streams/{streamId} | Yes | BroadcastStream |
listStreams() | GET /v1/streams | Yes | List<BroadcastStream> |
updateStream(streamId, UpdateStreamParams{playback?, metadata?}) | PATCH /v1/streams/{streamId} | Yes | BroadcastStream |
deleteStream(streamId) | DELETE /v1/streams/{streamId} | Yes | nothing |
The records, field by field (long where the wire sends an integer, a nullable Long / Integer / Boolean where the field is optional, String for money, JsonNode for opaque JSON):
| Record | Fields |
|---|---|
RoomWithHostToken | roomId, tenantId, originNode, state, hostIdentity, createdAt (ms), nodes (List<String>), participants (Integer), demo (Boolean), metadata, hostToken, wsUrl, expiresAt (s), existed |
RoomToken | token, wsUrl, role, identity, roomId, expiresAt (s) |
Room / RoomView / RoomList | Room: the first ten fields of RoomWithHostToken; RoomView: room, participants (List<Participant>), producers (List<JsonNode>); RoomList: rooms (List<Room>) |
Participant | identity, role, nodeId, handRaised, handMetadata, kicked (Boolean), muted, videoBlocked (Boolean), joinedAt (Long, ms), metadata |
ChatToken / ChatInfo | token, wsUrl, role, identity, channel, expiresAt; channel, members |
ChatMessage | id, channel, from, to, type, text, metadata, clientMsgId, ts (ms) |
UsageSummary | tenantId, plan, status, minutesLimit, usedMinutes, sharedUsedMinutes, minutesRemaining, allowance (Allowance), today, month, lifetime (Usage) |
Usage / DailyUsage | the ten counters above plus billableSeconds, freeSeconds (Long); DailyUsage adds day |
Allowance | plan, freeDailyMinutes, freeMonthlyMinutes, dailyCapMinutes, monthlyCapMinutes, usedTodaySeconds, usedMonthSeconds, freeUsedTodaySeconds, freeUsedMonthSeconds, freeRemainingSeconds, paidRemainingSeconds, remainingSeconds, blocked |
ProjectBalance | tenantId, billingMode, canJoin, blocked, legacy (LegacyFunds), wallet (WalletFunds, nullable), burn (BurnView), remainingSeconds, walletLow (WalletLowState), pricingVersion (Integer), updatedAt (ms) |
WalletFunds | spendOrder (List<String>), scopes (Map<String, WalletScopeAmount{paidCu, promoCu}>), paidCu, promoCu, availableCu, availableCredits |
RtcRates | version, kind, effectiveAt, unit (RtcUnit{price, rate, cuPerCredit}), tiers (List<RtcTierRate>), surcharges (List<RtcSurcharge>), idleTier, modes (List<RtcMode>), conversion (RtcConversion), notes (List<String>) |
RtcTierRate | id, rank, label, media, credits, cuPerParticipantSecond, base, surcharge, available |
BroadcastStream / StreamToken | streamId, tenantId, streamKey, state, playback, createdAt, startedAt, endedAt, viewers, peakViewers, metadata, playbackUrls (PlaybackUrls{llhls, webrtc, token, expiresAt}), publish (PublishInfo{whipUrl, token, expiresAt}, only from createStream); StreamToken: streamId, action, token, expiresAt, whipUrl, llhls, webrtc |
StreamEvent | id, type, tenantId, roomId (nullable), createdAt (ms), data (JsonNode, nullable) |
A field the service did not send is null (room.metadata(), a RoomList whose reply had no rooms), as it is undefined in the Node.js SDK; the list-returning methods answer an empty list instead. Webhooks.parse, Webhooks.verify and WebhookVerifier are described under Webhooks. Every request and reply is in the API reference.
Timeouts, retries and interrupts
StreamClient rtc = StreamClient.builder()
.baseUrl(System.getenv("VOLTKIT_RTC_URL"))
.apiKey(System.getenv("VOLTKIT_RTC_SECRET"))
.timeout(Duration.ofSeconds(5)) // each attempt: headers and body
.retries(1) // at most one more attempt
.build();
- Timeout.
timeout(default 10 seconds) is the deadline for each attempt, from sending the request to reading the last byte of the reply. A service that does not answer, or a reply that stalls after its headers, fails withnetworkand "timed out after 5000ms", and the stalled exchange is cancelled, not left open. A connection that drops while the body is being read isnetworktoo, and retried on the same terms. - 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 200 ms, then 400 ms, doubling each time, plus up to 100 ms at random, so a fleet of servers does not retry in step. - What is not. Any other
4xx(the service made a decision and will make it again),billing-unavailable(a 503 that lasts until VoltKit acts),redirected, a2xxreply that is not JSON, and every error raised before sending. - 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; keep a second client with the default budget for background jobs. - Interrupts. The methods block. Interrupting the calling thread, during a request or while waiting to retry, stops the call at once with a
java.util.concurrent.CancellationException(not aStreamException), cancels the request in flight, and leaves the thread's interrupt flag set. On Java 21 the methods are fine to call from virtual threads.
When an error reaches you with isRetryable() 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.
When one of the three fails with isRetryable() 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(streamId). If the stream is there, mint a publish URL with createStreamToken(CreateStreamTokenParams.publish(streamId)), since only createStream returns one. If it fails with isNotFound(), create it again. |
sendMessage | Put your own id in metadata, and look for it in listMessages(channel) before sending again. An ephemeral message is never stored, so it cannot be found: send it again or drop it. |
// Announce a gift once, even when a reply is lost.
ChatMessage announceGift(String channel, Gift gift) throws InterruptedException {
SendMessageParams message = SendMessageParams.builder(channel)
.type("gift").from(gift.from()).metadata(Map.of("giftId", gift.id(), "gift", gift.name())).build();
try {
return rtc.sendMessage(message);
} catch (StreamException e) {
if (!e.isRetryable()) throw e;
if (e.getStatus() != 429) { // it may have been stored before the reply was lost: look before sending again
for (ChatMessage m : rtc.listMessages(channel, ListMessagesOptions.limit(100))) {
if (m.metadata() != null && gift.id().equals(m.metadata().path("giftId").asText(null))) return m;
}
}
Thread.sleep(1000);
return rtc.sendMessage(message);
}
}
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 normally. A 404 on the first attempt still throws, 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 yourself:
try {
rtc.deleteRoom(roomId);
} catch (StreamException e) {
if (!e.isNotFound()) throw e; // 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 toratesBaseUrl.- A redirect is never followed with it (below).
- It never appears in an error, in a log line (the SDK writes none), or in the client object: see Safe to log.
- Every request also carries
User-Agent: voltkit-rtc-java/<version>(StreamClient.VERSION,StreamClient.USER_AGENT) and nothing else that identifies your server.
Redirects are never followed
A request that carries your API secret never follows a redirect. An 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: getStatus() 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 reached the service through a redirect (an http:// address upgraded to https://, or a www. rewrite in front), it fails: set baseUrl to the exact scheme and host of your API base URL, as the Credentials tab shows it. getRates() sends no secret and may follow one. An HttpClient you pass to httpClient(...) must be built with followRedirects(HttpClient.Redirect.NEVER), or the builder refuses it; the SDK derives a redirect-following sibling from it (same proxy, SSL context, executor and connect timeout) for the rates host. With a transport(...) of your own, redirects are yours to refuse: the SDK still treats any 3xx it gets back with the key as redirected.
Safe to log
An error never carries your API secret, or the credentials of a URL (user:pass@ in baseUrl or ratesBaseUrl), so log.warn(e.getMessage()) and log.error("...", e) can take it whole. Wherever the transport, a dropped connection, a redirect or the service's reply quotes either one, getMessage() and getCode() 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. toString() shows the URLs with credentials as [redacted], the timeout and the retries, and nothing of the key; Jackson serialises the client as that same string; field-walking serialisers (Gson, Kryo, commons-lang's ReflectionToStringBuilder) skip the field that holds the secrets; the client is not Serializable. An HttpTransport.Request prints and serialises as one line with header names only and the URL without its credentials, so a transport that logs requests never logs the key. The starter's VoltKitRtcProperties print the key as <set> and the webhook secrets as a count, and Actuator's configprops masks values by default.
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.
Threads and lifetime
One StreamClient per application, made at startup and shared: it is immutable, every call is independent, and nothing in it needs closing. By default it builds a JdkHttpTransport with two java.net.http.HttpClients (one that never follows redirects, for the key; a sibling that may, for the rates host), each with its own connection pool; a client per request would leak those. If several clients must share one HttpClient (one project per tenant, say), pass the same HttpClient to each builder's httpClient(...), or the same JdkHttpTransport to transport(...). The redaction pattern for the key is compiled on the first error, once per client, and the build is thread-safe.
Logging
The SDK logs nothing, at any level, and has no logging dependency. Nothing it does can print a header, a body, a token, a URL with credentials or the key. What you log is up to you: StreamException.getMessage() and getCode() are safe; the token() fields of replies are not.
Errors
A call that fails throws a StreamException, unchecked (a RuntimeException). Branch on getCode() or the predicates, and log getMessage(). A mistake in your own setup is an IllegalArgumentException (or a NullPointerException for a missing required value) 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
The token endpoint above is the pattern: catch StreamException, log it whole, map isNotFound(), isOutOfMinutes(), viewer-quota and isRetryable() to the statuses your client understands, and answer everything else as a 500 without saying why. The user learns nothing about your setup; your log holds the message, which never holds the key.
What a StreamException carries
| Member | Meaning |
|---|---|
getCode() | 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 locally. Any string: a code this release does not know is passed through. |
getStatus() | The HTTP status. 0 when nothing reached the service or the SDK refused the call itself; the 3xx for redirected. |
isRetryable() | true for network, a 429 and a 5xx other than billing-unavailable: trying again later could work. On a repeatable call the SDK has already spent its retries when you see it. |
getMessage() | The method and path, then what went wrong: POST /v1/rooms/show-42/tokens: room not found. |
isNotFound() | status 404: no such room, stream or message, or not yours. broadcast-disabled and chat-disabled are 404s too, so test isBroadcastDisabled() first. |
isOutOfMinutes() | status 402, or quota-exhausted, daily-limit-reached, monthly-limit-reached. |
isQuota() | room-quota, or any 429: rate-limited, viewer-quota, 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. Never retried. |
What to do, code by code
"Retried" means on a call that is safe to repeat; createRoom, createStream and sendMessage are sent once.
| Case | How it arrives | Retried | What to do |
|---|---|---|---|
| Wrong or revoked secret | 401 unauthorized | No | Check VOLTKIT_RTC_SECRET, and that baseUrl is this project's. Alert yourself, and tell the user only that something failed. A cheap read at startup, such as getUsage(), catches it before your users do. |
| Out of minutes or credits | isOutOfMinutes(): 402 quota-exhausted, daily-limit-reached, monthly-limit-reached | No | The project owner recharges the wallet, or waits for the limit to reset. getBalance() says so before anyone tries. |
| Account suspended | 403 tenant-suspended | No | Contact support. |
| Wallet billing not ready | isBillingUnavailable(): 503 billing-unavailable | No | Contact support. A retry does not help. |
| Gone, or never there | isNotFound(): 404 no-room, no-stream, no-message, not-found | No | Treat the room as ended, and the stream or message as gone. |
| Feature switched off | 404 broadcast-disabled, chat-disabled | No | Not available on this deployment. |
| Id already taken | 409 room-exists, stream-exists | No | Use ensureRoom, the existing stream, or another id. |
| Bad call | 400 bad-request, bad-role | No | Fix the call: the message names the field. With status 0 the SDK refused an id of "", "." or ".." before sending. |
| Too many, or at a limit | isQuota(): 429 rate-limited, room-quota, viewer-quota, stream-quota | Yes | Slow down, end rooms and streams you no longer need, or tell the viewer the room is full. |
| No capacity | isCapacityExhausted(): 503 capacity-exhausted, no-nodes | Yes | Ask the user to try again shortly, and alert: new rooms are failing right now. |
| Service trouble | 503 unavailable, 500 internal | Yes | Try again later; tell us if it repeats. |
| No reply | network, status 0: DNS, a refused or dropped connection, the deadline ("timed out after 10000ms"), or your HttpClient's own connect timeout, in its words | Yes | Try again later. If it persists, check your server's outbound network. |
| Redirected | redirected, status a 3xx | No | Fix baseUrl: see Redirects are never followed. |
| Not the API | http-<status>: a proxy's error page, or a success that is not JSON (http-200; a body of only whitespace counts as not JSON, and so does JSON that does not fit the record) | Only a 429 or 5xx | Check baseUrl, and anything between your server and VoltKit. |
| The website API's own codes | RATE_LIMITED, NOT_FOUND, INTERNAL_ERROR, ... from getRates() | By status | Passed through unchanged. |
Mistakes caught before a request
These throw at once, are never retried, and never quote your secret:
| Mistake | How it fails |
|---|---|
baseUrl or apiKey missing or empty | IllegalArgumentException from build() |
apiKey that is only whitespace, such as a bare line break from a file | IllegalArgumentException from build() |
apiKey holding a character no HTTP header can carry (a line break or NUL inside it, another control character, one above U+00FF), or one no VoltKit key has (a backslash, an accented letter) | IllegalArgumentException from build(): check how the secret is loaded |
baseUrl that is not an absolute URL with a host | IllegalArgumentException from build(), which never quotes the URL (it may carry credentials) |
timeout of zero or negative; retries below 0 | IllegalArgumentException from the builder method |
An HttpClient that follows redirects | IllegalArgumentException from httpClient(...): build it with followRedirects(HttpClient.Redirect.NEVER) |
A required field left null (hostIdentity, identity, role, a channel) | NullPointerException from the params record |
An id of "", "." or ".." for a room, channel, message, identity or stream | StreamException bad-request, status 0; nothing is sent: a URL reads those as steps through the path. Every other id is encoded and sent as given |
A body Jackson cannot write (a self-referencing object in metadata) | IllegalArgumentException naming the call: PUT /v1/rooms/show-42: the body cannot be sent as JSON: ... |
getRates() without ratesBaseUrl | StreamException rates-unconfigured, status 0 |
A webhook body that is null, no secret, a negative tolerance | NullPointerException / IllegalArgumentException from Webhooks or WebhookVerifier |
Whitespace around the secret is fine: it is trimmed before sending. A timeout is kept in whole milliseconds, rounded up (a positive duration under a millisecond counts as one); above 2^31−1 ms (about 24.8 days) it is held there.
The error registry
ErrorCodes.ALL is every code a VoltKit RTC SDK can report, in the order of the registry every SDK shares; ErrorCodes.REST the codes a call from this SDK can fail with; ErrorCodes.info(code) what the registry says about one (an Optional<ErrorCodes.Info> with origin(), http(), terminal(), retryable()); ErrorCodes.isKnown(code) and isRest(code) the two membership tests. Two kinds of code are outside the registry and passed through unchanged: http-<status> when a reply names no code, and the website API's own codes from getRates().
Set<String> known = new HashSet<>(ErrorCodes.REST);
String label = known.contains(e.getCode()) ? e.getCode() : "other"; // a bounded label for your metrics
Webhooks
The signature header is X-Stream-Signature: t=<unix seconds>,v1=<hex>[,v1=<hex>], HMAC-SHA256 over <t>.<raw body>. Verify over the raw body bytes as received: a parsed and re-serialised body can never match. The Spring Boot controller above and the plain Java receiver under Plain Java are the two shapes; the pieces:
// Verify and decode in one step: a StreamException bad-signature (status 401) when it does not verify.
StreamEvent event = Webhooks.parse(rawBody, signatureHeader, "whsec_...");
StreamEvent event = Webhooks.parse(rawBody, signatureHeader, List.of(currentSecret, previousSecret)); // during a rotation
// Or a yes/no.
boolean ok = Webhooks.verify(rawBody, signatureHeader, List.of(currentSecret, previousSecret));
// Your own window or clock, as the last argument.
VerifyOptions options = VerifyOptions.defaults().withTolerance(Duration.ofMinutes(10));
StreamEvent event = Webhooks.parse(rawBody, signatureHeader, secrets, options);
// Bound once, used from every request (what the starter's bean is).
WebhookVerifier verifier = new WebhookVerifier(List.of(currentSecret, previousSecret), options);
StreamEvent event = verifier.parse(rawBody, signatureHeader);
- Pass the raw body. In Spring,
@RequestBody byte[]; in a servlet,request.getInputStream().readAllBytes(); withcom.sun.net.httpserver,exchange.getRequestBody().readAllBytes(). Anullbody is refused with aNullPointerExceptionthat says so, never a quietfalsethat would look like an attack. See Keep the raw body. - What it checks. The timestamp must be within the tolerance of your clock (
VerifyOptions.DEFAULT_TOLERANCE, 300 seconds, either way, inclusive at the boundary), so a captured request cannot be replayed later; then av1signature must match one of your secrets, compared in constant time, every secret against every signature with no early return. The header grammar is strict: at most 4096 characters,t1 to 15 digits exactly once, eachv164 lower-case hex characters, other keys skipped; anything else does not verify. - What it throws.
Webhooks.parseandWebhookVerifier.parsethrowStreamExceptionbad-signature(status 401) when the delivery does not verify: answer401, and VoltKit retries it. No secret is a mistake in your setup, never a quietfalse:NullPointerExceptionfornull,IllegalArgumentExceptionfor""or an empty list, at construction for aWebhookVerifier; so is a negative tolerance (IllegalArgumentException). Nothing can switch the check off:Duration.ZEROis allowed and refuses anything but the exact instant. - The clock.
VerifyOptions.withClock(Clock)pins "now" (Clock.fixed(...)) in tests; the default isClock.systemUTC(). The comparison uses fractional seconds, as the Node.js SDK does. - The event.
StreamEvent:id(),type(),tenantId(),roomId()(may benull),createdAt()(ms) anddata()(aJsonNode, by type; may benull). Three things to get right: answer first (a slow handler is retried, so your latency becomes duplicate deliveries); deduplicate onid()(delivery is at-least-once); do not assume order (aparticipant.leftcan arrive before theparticipant.joinedit followed; pair them byidentityandjoinedAt).
Every event type and its fields are on Webhooks.
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>. Webhooks.verify, Webhooks.parse and WebhookVerifier accept a delivery when any v1 value matches any secret you pass. To rotate without dropping a delivery:
- Deploy a receiver that takes a list of secrets (the controller above, with
voltkit.rtc.webhook.secretsas a comma-separated value or a YAML list;new WebhookVerifier(List.of(...))without the starter). With only the current one set, it verifies exactly as before. - Press Rotate secret. Move the current secret to the second slot, put the new one first (
VOLTKIT_RTC_WEBHOOK_SECRETS=whsec_new,whsec_old), and deploy before the overlap ends. - After the overlap, remove the old one.
See Rotating the signing secret.
Testing
Inject an HttpTransport to test your code without a network. It is one method: take a request, return a response.
List<String> calls = new ArrayList<>();
HttpTransport fake = request -> {
calls.add(request.method() + " " + request.url());
return new HttpTransport.Response(404, Map.of(), "{\"error\":\"no-room\",\"message\":\"room not found\"}".getBytes(StandardCharsets.UTF_8));
};
StreamClient rtc = StreamClient.builder()
.baseUrl("https://rtc.test")
.apiKey("sk_test_0123456789abcdef")
.transport(fake)
.retries(0) // a failing test stays fast
.build();
StreamException e = assertThrows(StreamException.class, () -> rtc.createToken("show-42", "user-99", Role.VIEWER));
assertTrue(e.isNotFound());
assertEquals(List.of("POST https://rtc.test/v1/rooms/show-42/tokens"), calls);
Answer with the service's error body, { "error": "<code>", "message": "…" }, and its status to drive each branch of your error handling; throw java.net.http.HttpTimeoutException from the fake to play a deadline, any other IOException to play a dropped connection. request.headers() holds the real headers, request.body() the JSON bytes as sent; request.toString() never shows the key, so a recorded request is safe to print. With Spring Boot, an HttpTransport bean in a test configuration is picked up by the starter; ApplicationContextRunner with voltkit.rtc.* property values exercises the auto-configuration itself. For webhooks, pin the clock with VerifyOptions.defaults().withClock(Clock.fixed(Instant.ofEpochSecond(ts), ZoneOffset.UTC)) and sign a body yourself with javax.crypto.Mac (HmacSHA256 over ts + "." + body, lower-case hex).
What to do when
| Situation | How it shows | What to do |
|---|---|---|
| The key is wrong | Every call fails with unauthorized (401), not retried. | The secret in VOLTKIT_RTC_SECRET is wrong or revoked, or baseUrl is another project's. Check both at startup with a cheap read (rtc.getUsage()), so you find out before your users do; the message never holds the key. |
| The key has whitespace around it, or a line break inside it | Whitespace around it: nothing, it is trimmed. A line break, NUL or accented character inside it: IllegalArgumentException from build() saying to check how the key is loaded. | Look at how the secret is read: a quoted .env line with a literal \n, a file with a trailing newline read whole, a value pasted with the line break. The exception never quotes the key. |
| Out of minutes or credits | isOutOfMinutes(): 402 quota-exhausted, daily-limit-reached, monthly-limit-reached, 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). getBalance() says so before anyone tries: canJoin() false and blocked() with the code. |
| Wallet billing not ready | isBillingUnavailable(): 503 billing-unavailable, the one 503 never retried. | Contact support; a retry does not help. |
| 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 id is taken | room-exists (409) from createRoom, not retried. | Use ensureRoom, which returns the live room instead, or another id. |
| The room is full, or you are at a limit | viewer-quota (429, isQuota()): the room is at its viewer limit. room-quota (429): your project's concurrent-room limit. rate-limited (429): your project's API rate limit (600 per minute by default). All retried first. | Tell the viewer the room is full; end rooms you no longer need with deleteRoom; spread calls out. The SDK has already retried, so do not loop. |
| The network drops mid-call | A call to the API that loses its connection, before or after the headers, fails with network (status 0, retryable), retried on every call that is safe to repeat; the deadline covers the body. | 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 Calls that are never repeated. 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. |
| A create may or may not have landed | createRoom, createStream or sendMessage failed with isRetryable() true. | A 429 means nothing was done: send again after a pause. A network error or a 5xx may have done the work: 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. |
| Timeouts | network, "timed out after 10000ms": the service did not answer, or the reply stalled after its headers. A connect timeout you set on your own HttpClient arrives as network too, in its own words (HttpConnectTimeoutException: HTTP connect timed out). | Retried first on repeatable calls. In a web handler, lower timeout and retries (a call can take about 31 seconds with the defaults), 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. |
| Redirected | redirected, status a 3xx, the message naming where it pointed; not retried. | baseUrl reaches the service through a redirect (http:// upgraded to https://, a www. rewrite). Set it to the exact scheme and host the Credentials tab shows. |
| The base URL is wrong in another way | http-<status> with a page's first words in the message (a web server, a proxy); network with "unsupported" or "connection refused" in the message (a ws:// URL, a wrong port); not-found (404) for a path prefix that is not the API's. | Compare baseUrl with the Credentials tab character by character: scheme, host, port, no /v1, no wss://. The SDK adds /v1/... itself. |
| Behind a proxy | The JDK client honours http.proxyHost / https.proxyHost and ProxySelector.getDefault(); the secret travels inside TLS to VoltKit, which a proxy that only forwards the connection never sees. | For a proxy with its own authentication, a corporate CA or mTLS, pass an HttpClient built for it (proxy(...), authenticator(...), sslContext(...)) to httpClient(...), with followRedirects(Redirect.NEVER). A proxy that answers with its own page comes back as http-<status> with the page's first words, redacted. |
A user-supplied HttpClient follows redirects | IllegalArgumentException from httpClient(...), at construction: "the HttpClient given to JdkHttpTransport follows redirects". | Build it with HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NEVER) (the JDK's default). The SDK derives a following sibling for the rates host, so getRates() still works behind a www. redirect. |
| Java version too old | UnsupportedClassVersionError: ... class file version 61.0, or Gradle/Maven refusing the artifact's org.gradle.jvm.version 17. | The SDK needs Java 17 or newer (records, java.net.http). Run on 17 or later; Spring Boot 3.5 and 4.x both need at least 17 themselves. |
| Spring Boot 3 or 4 | Both work with the same starter and properties. On Boot 4, Jackson 3 is the web mapper while the SDK's JsonNodes are Jackson 2's. | See Spring Boot 3 or 4: read metadata / data() with the JsonNode API or a Jackson 2 mapper; never return a JsonNode straight from a Boot 4 controller. |
| The webhook does not verify | Webhooks.parse throws 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 parse (declare @RequestBody byte[], not a POJO or String through a message converter); 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); your server's clock is more than five minutes off (t is checked both ways; fix the clock rather than widening the tolerance); a proxy split the header into two lines (join them with ,). VoltKit retries a 401, so a fix within the retry window drops nothing. |
| Rotating a secret | The webhook secret: deploy the receiver with both secrets before pressing Rotate secret, then swap the variables and deploy again before the overlap ends (steps). | The API secret: issue the new one from the Credentials tab, deploy it, then revoke the old one; an unauthorized after that means some instance still holds the old key. |
Security notes
- The secret belongs in the environment or a secrets manager, never in
application.yml, a repository or a log. The starter reads it from${VOLTKIT_RTC_SECRET};fromEnv()reads it by name;build()refuses a mangled one without quoting it. - The SDK sends the secret to
baseUrlonly, inX-API-Key, never across a redirect and never to the rates host;getRates()is the only call that may follow a redirect, and it carries nothing. - Errors, the printed client, Jackson's view of the client and of a request, and the starter's properties never hold the secret or a URL's credentials; the redaction covers every encoding a reply can use and fails closed. Tokens in replies (
token(),hostToken(),publish().whipUrl()) are credentials for one room or stream: hand them to the one client that needs them and never log them. - Webhooks: verify over the raw bytes, with constant-time comparison and a replay window checked both ways; a missing secret or a bad tolerance is a loud exception, never a quiet pass or a quiet fail. Keep CSRF protection off the webhook route and on everything else.
- Ids are percent-encoded as one path segment, and
"","."and".."are refused before sending, so an id from a request can never redirect a call to another endpoint. - The client is safe to share between threads; nothing in it is mutable after
build(). The only dependency is Jackson (2.15 or newer); pin it with your platform BOM as you would any other.
Versions
StreamClient.VERSION is the SDK's version at runtime, and User-Agent: voltkit-rtc-java/<version> goes on every request. The starter's version always equals the SDK's; upgrade both together. Until 1.0, a breaking change is a minor bump and everything else a patch. The changelog ships with the artifact's sources (CHANGELOG.md) and is on Maven Central.
| 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; the Spring Boot starter for Boot 3.5 and 4.x. |
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. java.net.http.HttpClient will do, as long as you keep what the SDK keeps: a deadline that covers the body, Redirect.NEVER with the key, and the key out of your logs.
Help
Questions and bug reports: contact@voltkit.in. Send the error's getCode() and getMessage(), 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.
BroadcastStream live = rtc.createStream(new CreateStreamParams("show-42", PlaybackPolicy.PUBLIC, null));
// 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
StreamToken pub = rtc.createStreamToken(CreateStreamTokenParams.publish("show-42")); // a fresh publish URL later: whipUrl(), token(), expiresAt()
StreamToken play = rtc.createStreamToken(CreateStreamTokenParams.play("show-42", "user-99")); // a viewer's URLs for playback "token": llhls(), webrtc(), token()
BroadcastStream s = rtc.getStream("show-42"); // state(), viewers(), peakViewers(); never publish()
List<BroadcastStream> all = rtc.listStreams();
rtc.updateStream("show-42", UpdateStreamParams.playback(PlaybackPolicy.TOKEN)); // who may watch, or metadata
rtc.deleteStream("show-42"); // takes it off air; its URLs stop working
publish().whipUrl() carries the publish token, so treat it like a password. new CreateStreamTokenParams(streamId, action, identity, ttlSeconds) takes everything; 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.