Skip to content

SDKs

PHP Server SDK for Laravel and Symfony

voltkit/rtc-server on Packagist: rooms, tokens, chat, usage, balance and signed webhooks from PHP, with plain PHP and Laravel examples and a PSR-18 transport.

voltkit/rtc-server wraps the REST API for a PHP backend: the PHP video call API client. It has no framework dependency, so it works the same in Laravel, Symfony or plain PHP; a Laravel video call integration is a controller and a service provider (see Handling a failed call). 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

composer require voltkit/rtc-server

PHP 8.2 or newer with ext-curl, ext-json and ext-mbstring; no Composer dependency at runtime (PSR-18, PSR-17 and PSR-3 are optional, for your own HTTP client and logger). Everything lives under the VoltKit\Rtc namespace:

use VoltKit\Rtc\StreamClient;
use VoltKit\Rtc\StreamError;
use VoltKit\Rtc\Role;
use VoltKit\Rtc\Webhooks;

Every reply is the API's JSON as a PHP array with the same camelCase keys ($room['hostToken']); unknown fields pass through, and money stays a decimal string. The PHPDoc array shapes on each method (RoomWithHostToken, RoomToken, ChatMessage, UsageSummary, ProjectBalance, RtcRates and the rest) tell PHPStan and your editor what is there. This page describes 0.1.0, at parity with the Node.js SDK 0.4.8; see Versions.

Create a client

use VoltKit\Rtc\StreamClient;

$rtc = new StreamClient(
    baseUrl: $config['rtc_url'],       // your API base URL, e.g. https://api.voltkit.in
    apiKey: $config['rtc_secret'],     // your API secret: server-side only
);

// Or from the environment: VOLTKIT_RTC_URL, VOLTKIT_RTC_SECRET and, optionally, VOLTKIT_API_URL for rates.
$rtc = StreamClient::fromEnv();

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 application boots and share it (a Laravel singleton, a container service): it holds no per-call state. The constructor checks every argument, so a wrong value fails at startup instead of on a user's first call (see Mistakes caught before a request). fromEnv() reads the three variables from $_SERVER, $_ENV and getenv() in that order, so a value a dotenv loader put only in the arrays is found; a missing variable is named in the exception, its value never is.

ArgumentDefaultWhat it does
baseUrlrequiredYour API base URL: an absolute https:// URL with a host (or http:// for a controller on your own network), exactly as the Credentials tab shows it. The API secret is sent only here. A trailing / is dropped; a path prefix is kept.
apiKeyrequiredYour API secret. Whitespace around it is ignored.
ratesBaseUrlnullVoltKit's website API, https://api.voltkit.in, where getRates() reads the price list. It is never sent the secret. '' counts as unset.
timeoutMs10000The deadline for each attempt, in milliseconds, from sending the request to reading the last byte of the reply.
retries2How many more attempts a call that is safe to repeat gets after a temporary failure. 0 turns retries off.
transportCurlTransportA VoltKit\Rtc\Transport\TransportInterface: the built-in curl one with your own CURLOPT_* values, the PSR-18 adapter, or a test double.
loggernullA PSR-3 logger that receives method, path, status, attempt and elapsed time per attempt, and nothing else: see Logging.

Named arguments are the readable way to call it; the positional order is the one above.

Rooms and tokens

use VoltKit\Rtc\Role;

// Create the room, or get it back with a fresh host token if it already exists.
$room = $rtc->ensureRoom(roomId: 'show-42', hostIdentity: 'user-7');
// ['roomId' => 'show-42', 'hostToken' => '...', 'wsUrl' => '...', 'expiresAt' => 1755600600, 'existed' => false, 'originNode' => ..., ...]

// Let someone in, with the role YOU decide.
$token = $rtc->createToken(roomId: 'show-42', identity: 'user-99', role: Role::Viewer);
// ['token' => '...', 'wsUrl' => '...', 'role' => 'viewer', 'identity' => 'user-99', 'roomId' => 'show-42', 'expiresAt' => 1755600600]

$view = $rtc->getRoom('show-42');            // ['room' => [...], 'participants' => [...], 'producers' => [...]]: the authoritative view
$rooms = $rtc->listRooms();                   // ['rooms' => [...]]: every live room, an object, not an array
$people = $rtc->listParticipants('show-42');  // [['identity' => ..., 'role' => ..., 'handRaised' => ..., 'muted' => ..., 'joinedAt' => ..., 'metadata' => ...], ...]
$rtc->deleteRoom('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: $rtc->createRoom(hostIdentity: 'user-7', roomId: 'show-42') fails with room-exists (409) if the id is taken, and $rtc->createRoom('user-7') makes an id up.
  • metadata (an array, a \stdClass, anything json_encode takes) on either call is room state that every participant receives when they join. It is applied only when the room is created. Left null, it is left out of the body; an empty PHP array is sent as [], so pass new \stdClass() for {}.
  • role is Role::Host, Role::Cohost, Role::Moderator or Role::Viewer, or the plain string. 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 (the role defaults to viewer).
$chat = $rtc->createChatToken('show-42', 'user-99');
$chat = $rtc->createChatToken('show-42', 'user-99', Role::Moderator);
// ['token' => '...', 'wsUrl' => '.../ws/chat', 'role' => 'moderator', 'identity' => 'user-99', 'channel' => 'show-42', 'expiresAt' => ...]

// Take part without holding a connection.
$rtc->sendMessage('show-42', text: 'The show starts in 5 minutes', type: 'system');
$rtc->sendMessage('show-42', type: 'gift', from: 'user-7', metadata: ['gift' => 'rose', 'count' => 5]);
$rtc->sendMessage('show-42', type: 'reaction', metadata: ['emoji' => 'heart'], ephemeral: true);   // not stored
$rtc->sendMessage('show-42', to: 'user-13', text: 'Please keep it friendly');                        // a direct message

$messages = $rtc->listMessages('show-42', limit: 100);                // the newest 100, oldest first
$older = $rtc->listMessages('show-42', before: $messages[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');
$muted = $rtc->listChatMutes('show-42');   // ['user-13']
$info = $rtc->getChatInfo('show-42');      // ['channel' => 'show-42', 'members' => 1284]

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. sendMessage is never retried. See Chat.

Usage

$usage = $rtc->getUsage();
// ['tenantId' => ..., 'plan' => 'metered', 'minutesRemaining' => 990, 'allowance' => [...],
//  'today' => ['participantSeconds' => ..., 'chatMessages' => ..., 'roomsCreated' => ..., ...], 'month' => [...], 'lifetime' => [...]]

$days = $rtc->getUsageSeries(30);   // one entry per UTC day, oldest first, ending today; 0 means 30, at most 90

Each period carries participantSeconds, chatMessages, broadcastSeconds, broadcastViewerSeconds and more. minutesRemaining is -1 on an unlimited plan. allowance breaks it down: free minutes left today and this month, the paid balance, the daily and monthly limits, and blocked with the reason when the project cannot be used. 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

$balance = $rtc->getBalance();
// ['tenantId' => ..., 'billingMode' => 'wallet', 'canJoin' => true, 'blocked' => null,
//  'legacy' => ['unlimited' => false, 'freeSeconds' => 0, 'ownSeconds' => 60, 'sharedSeconds' => 0, 'totalSeconds' => 60],
//  'wallet' => ['spendOrder' => [...], 'scopes' => [...], 'paidCu' => '180000000', 'promoCu' => '6000000',
//               'availableCu' => '186000000', 'availableCredits' => '31'],
//  'burn' => ['liveParticipants' => 2, 'projectCuPerSecond' => '19800', 'walletCuPerSecond' => '29700'],
//  'remainingSeconds' => 6292, 'walletLow' => ['active' => false, 'graceSeconds' => 60], 'pricingVersion' => 3, '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:

function admissionNotice(StreamClient $rtc): ?string
{
    $balance = $rtc->getBalance();
    if ($balance['canJoin']) {
        return null;
    }
    return match ($balance['blocked']) {
        'tenant-suspended' => 'This account is suspended',    // answer 403
        'billing-unavailable' => 'Billing is being set up',   // answer 503
        default => 'Recharge to go live',                     // quota-exhausted, daily- or monthly-limit-reached: answer 402
    };
}
  • legacy are the minutes spent first, in participant-seconds. On an unlimited plan unlimited is true, and ownSeconds and totalSeconds are -1.
  • wallet is null 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: null 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, 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. Add them up with bcmath or gmp, never as floats; show availableCredits, which is already formatted (at most four decimals, rounded down):

$cu = $balance['wallet']['availableCu'] ?? '0';   // '186000000'
$wholeCredits = bcdiv($cu, '6000000', 0);          // '31' (ext-bcmath; gmp_div_q with ext-gmp)

Rates

$rtc = new StreamClient($baseUrl, $apiKey, ratesBaseUrl: 'https://api.voltkit.in');   // VoltKit's website API: the origin, without /api/v1
$rates = $rtc->getRates();
// ['version' => 3, 'kind' => 'standard', 'effectiveAt' => ..., 'unit' => [...],
//  'tiers' => [['id' => 'video_30', 'credits' => '99.00', 'cuPerParticipantSecond' => '9900', ...], ...],
//  'surcharges' => [...], 'idleTier' => 'audio_std', 'modes' => [...], 'conversion' => [...], 'notes' => [...]]

The public price list: each rate in credits per 1,000 participant-minutes, the media modes that are switched on, and the conversion for new recharges. Prices are set by VoltKit on the website, so 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 the curl transport may follow redirects for it (five at most).

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

$video = null;
foreach ($rates['tiers'] as $tier) {
    if ($tier['id'] === 'video_30') {
        $video = $tier;
    }
}
$cost = bcmul($video['cuPerParticipantSecond'], (string) (4 * 30 * 60));      // 4 people for 30 minutes: '71280000' units, 11.88 credits at '9900'
$affordable = bccomp($balance['wallet']['availableCu'] ?? '0', $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 and retries). Arguments left null are left out of the body; every method also takes named arguments.

MethodRequestRetriedReturns
ensureRoom(string $roomId, string $hostIdentity, mixed $metadata = null)PUT /v1/rooms/{roomId}Yesthe room array with hostToken, wsUrl, expiresAt and existed
createRoom(string $hostIdentity, ?string $roomId = null, mixed $metadata = null)POST /v1/roomsNothe same, with existed false
createToken(string $roomId, string $identity, Role|string $role)POST /v1/rooms/{roomId}/tokensYes['token', 'wsUrl', 'role', 'identity', 'roomId', 'expiresAt']
getRoom(string $roomId)GET /v1/rooms/{roomId}Yes['room' => ..., 'participants' => [...], 'producers' => [...]]
listRooms()GET /v1/roomsYes['rooms' => [...]]
listParticipants(string $roomId)GET /v1/rooms/{roomId}/participantsYesa list of participants, [] when none
deleteRoom(string $roomId)DELETE /v1/rooms/{roomId}Yesnothing (void)
createChatToken(string $channel, string $identity, Role|string|null $role = null)POST /v1/chat/{channel}/tokensYes['token', 'wsUrl', 'role', 'identity', 'channel', 'expiresAt']
getChatInfo(string $channel)GET /v1/chat/{channel}Yes['channel' => ..., 'members' => ...]
listMessages(string $channel, ?int $limit = null, ?string $before = null)GET /v1/chat/{channel}/messagesYesa list of messages, oldest first
sendMessage(string $channel, ?string $text, ?string $type, ?string $from, ?string $to, mixed $metadata, ?bool $ephemeral)POST /v1/chat/{channel}/messagesNothe stored message
deleteMessage(string $channel, string $messageId)DELETE /v1/chat/{channel}/messages/{messageId}Yesnothing
muteChat(string $channel, string $identity)PUT /v1/chat/{channel}/mutes/{identity}Yesnothing
unmuteChat(string $channel, string $identity)DELETE /v1/chat/{channel}/mutes/{identity}Yesnothing
listChatMutes(string $channel)GET /v1/chat/{channel}/mutesYesa list of identities
getUsage()GET /v1/usageYes['minutesRemaining', 'today', 'month', 'lifetime', 'allowance', ...]
getUsageSeries(int $days = 30)GET /v1/usage/series?days={days}Yesa list of days, oldest first
getBalance()GET /v1/billing/balanceYesthe balance array
getRates()GET /api/v1/billing/rtc/rates/current on ratesBaseUrl, with no keyYesthe price list array
createStream(string $streamId, PlaybackPolicy|string|null $playback = null, mixed $metadata = null)POST /v1/streamsNothe stream, with publish
createStreamToken(string $streamId, StreamAction|string|null $action = null, ?string $identity = null, ?int $ttlSeconds = null)POST /v1/streams/{streamId}/tokensYes['token', 'expiresAt'] with whipUrl, or with llhls and webrtc
getStream(string $streamId)GET /v1/streams/{streamId}Yesthe stream
listStreams()GET /v1/streamsYesa list of streams
updateStream(string $streamId, PlaybackPolicy|string|null $playback = null, mixed $metadata = null)PATCH /v1/streams/{streamId}Yesthe stream
deleteStream(string $streamId)DELETE /v1/streams/{streamId}Yesnothing

Webhooks::parse() and Webhooks::verify() are static methods of their own class: see Webhooks. StreamClient::fromEnv(), ErrorCodes::ALL, ErrorCodes::REST, ErrorCodes::info() and ErrorCodes::isKnown() are helpers. Every request and reply is in the API reference; the stream methods are not offered on VoltKit cloud today.

Timeouts and retries

$rtc = new StreamClient(
    $baseUrl,
    $apiKey,
    timeoutMs: 5000,   // each attempt: headers and body (default 10000)
    retries: 1,        // at most one more attempt (default 2; 0 turns retries off)
);
  • Timeout. timeoutMs (default 10 seconds) is the deadline for each attempt, from sending the request to reading the last byte of the reply (CURLOPT_TIMEOUT_MS, which covers the body). A service that does not answer, or a reply that stalls after its headers, fails with network and "timed out after 5000ms".
  • What is retried. A call that is safe to repeat is tried again after a network failure (no answer, a refused or dropped connection, the timeout, a body that broke off), 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, a 2xx reply that is not JSON, and every error raised before sending.
  • How long a call can take. Up to (retries + 1) × timeoutMs, 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 queue workers, where waiting is fine.

When a StreamError reaches you with isRetryable() true, the SDK has already spent its retries. Try again later, from a queued job 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 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($streamId). If the stream is there, mint a publish URL with createStreamToken($streamId, StreamAction::Publish), since only createStream returns one. If it fails with isNotFound(), create it again.
sendMessagePut 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. @param array{id: string, from: string, name: string} $gift */
function announceGift(StreamClient $rtc, string $channel, array $gift): array
{
    $send = static fn (): array => $rtc->sendMessage(
        $channel,
        type: 'gift',
        from: $gift['from'],
        metadata: ['giftId' => $gift['id'], 'gift' => $gift['name']],
    );
    try {
        return $send();
    } catch (StreamError $e) {
        if (!$e->isRetryable()) {
            throw $e;
        }
        if ($e->getStatus() !== 429) {
            // It may have been stored before the reply was lost: look before sending again.
            foreach ($rtc->listMessages($channel, limit: 100) as $message) {
                if (($message['metadata']['giftId'] ?? null) === $gift['id']) {
                    return $message;
                }
            }
        }
        usleep(1_000_000);
        return $send();
    }
}

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, catch it:

try {
    $rtc->deleteRoom($roomId);
} catch (StreamError $e) {
    if (!$e->isNotFound()) {
        throw $e;
    }
    // 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).
  • A baseUrl without a scheme is refused by the constructor: given api.voltkit.in, curl would have guessed http:// and sent the secret in clear text.
  • It never appears in an error, in a log line the SDK writes (it writes none unless you pass a logger, and then only method, path, status, attempt and time), or in the client object: see Safe to log.

Every request carries User-Agent: voltkit-rtc-php/<version> and nothing else about your system.

Redirects are never followed

A request that carries your API secret never follows a redirect: HTTP clients keep custom headers across a redirect to another host, so following one would hand X-API-Key to whatever host it named. The curl transport has CURLOPT_FOLLOWLOCATION off for such requests (an option you pass cannot turn it back on), and 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 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. With the PSR-18 transport, the client you wrap decides, so it must be configured not to follow.

Safe to log

An error never carries your API secret, or the credentials of a URL (user:pass@ in baseUrl or ratesBaseUrl), so error_log($e->getMessage()), Log::warning($e->getMessage()) and report($e) can take it whole. Wherever curl, a dropped connection, a redirect or the service's reply quotes either one, the message and the 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. The key, the URLs as given and the redaction tables are not properties of any object the client holds, so a dumper that walks properties finds nothing: Laravel's dd($rtc) and dump($rtc), a reflection-based logger, an IDE's variable view. var_dump($rtc), print_r($rtc), json_encode($rtc), (string) $rtc and var_export($rtc) show baseUrl and ratesBaseUrl with any credentials as [redacted] and no part of the key; serialize($rtc) throws a \LogicException, since a serialized client would carry the key. The arguments a stack trace records (with zend.exception_ignore_args off) hold neither the service's reply nor the transport's error: a StreamError is built after those have been redacted and dropped, so serialize($e) and an error reporter that renders arguments see what the message shows. Requests still go to the URLs as given.

Log::info('rtc client ready', ['client' => (string) $rtc]);   // StreamClient(baseUrl: https://api.voltkit.in, ratesBaseUrl: null, timeoutMs: 10000, retries: 2)

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. A transport Request carries the real X-API-Key for the transport: never dump or log one either.

Errors

A call that fails throws a VoltKit\Rtc\StreamError, a \RuntimeException. Branch on the code or the predicates, and log the message. A mistake in your own setup is an \InvalidArgumentException, a \RangeException or a \TypeError 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 in plain PHP, mapping each answer from VoltKit to an answer for the client. The identity comes from your session, never from the request body: a client that can name its own identity can take anyone's seat.

<?php

declare(strict_types=1);

use VoltKit\Rtc\Role;
use VoltKit\Rtc\StreamClient;
use VoltKit\Rtc\StreamError;

require __DIR__ . '/vendor/autoload.php';

$rtc = StreamClient::fromEnv();   // one client for the process; in an application, make it once at boot and share it

function answer(int $status, array $body, array $headers = []): never
{
    http_response_code($status);
    header('Content-Type: application/json');
    header('Cache-Control: no-store');   // a token is a credential for one room
    foreach ($headers as $name => $value) {
        header("$name: $value");
    }
    echo json_encode($body, JSON_THROW_ON_ERROR);
    exit;
}

$userId = currentUserId();            // your session or verified bearer token, never the request body
if ($userId === null) {
    answer(401, ['error' => 'Sign in first']);
}
$input = json_decode((string) file_get_contents('php://input'), true);
$roomId = is_array($input) && is_string($input['roomId'] ?? null) ? $input['roomId'] : '';
if ($roomId === '') {
    answer(400, ['error' => 'roomId is required']);
}
$role = isHostOf($roomId, $userId) ? Role::Host : Role::Viewer;   // your rule; the token carries it

try {
    $token = $rtc->createToken($roomId, "user-$userId", $role);
} catch (StreamError $e) {
    error_log('createToken failed: ' . $e->getMessage());   // safe to log whole: the API secret is never in it
    if ($e->isNotFound()) {                                 // no-room
        answer(404, ['error' => 'This show has ended']);
    }
    if ($e->isOutOfMinutes()) {                             // 402: quota-exhausted, daily-limit-reached, monthly-limit-reached
        answer(402, ['error' => 'This show cannot take viewers right now']);
    }
    if ($e->getErrorCode() === 'viewer-quota') {            // 429: the room is full
        answer(429, ['error' => 'This show is full']);
    }
    if ($e->isCapacityExhausted()) {                        // 503 capacity-exhausted, no-nodes: alert, new rooms are failing right now
        answer(503, ['error' => 'Try again in a moment'], ['Retry-After' => '30']);
    }
    if ($e->isRetryable()) {                                // network, 429, 5xx: the SDK has already retried
        answer(503, ['error' => 'Try again in a moment'], ['Retry-After' => '10']);
    }
    answer(500, ['error' => 'Something went wrong']);       // unauthorized, tenant-suspended, bad-request, redirected: a problem on your side
}

answer(200, ['token' => $token['token'], 'wsUrl' => $token['wsUrl'], 'expiresAt' => $token['expiresAt'], 'role' => $token['role']]);

The same endpoint as a Laravel controller, with the client injected from a service provider that binds it as a singleton ($this->app->singleton(StreamClient::class, fn () => new StreamClient(config('services.voltkit.rtc_url'), config('services.voltkit.rtc_secret')))), behind auth:sanctum:

final class RtcTokenController extends Controller
{
    public function __construct(private readonly StreamClient $rtc)
    {
    }

    public function __invoke(Request $request): JsonResponse
    {
        $user = $request->user();                             // the signed-in user: never an identity from the body
        $roomId = $request->input('roomId');
        if (!is_string($roomId) || $roomId === '') {
            return new JsonResponse(['error' => 'roomId is required'], 400);
        }
        $role = $this->isHostOf($roomId, $user) ? Role::Host : Role::Viewer;

        try {
            $token = $this->rtc->createToken($roomId, 'user-' . $user->getAuthIdentifier(), $role);
        } catch (StreamError $e) {
            Log::warning('createToken failed', ['code' => $e->getErrorCode(), 'message' => $e->getMessage()]);
            if ($e->isNotFound()) {
                return new JsonResponse(['error' => 'This show has ended'], 404);
            }
            if ($e->isOutOfMinutes()) {
                return new JsonResponse(['error' => 'This show cannot take viewers right now'], 402);
            }
            if ($e->getErrorCode() === 'viewer-quota') {
                return new JsonResponse(['error' => 'This show is full'], 429);
            }
            if ($e->isRetryable()) {
                return new JsonResponse(['error' => 'Try again in a moment'], 503, ['Retry-After' => '10']);
            }
            return new JsonResponse(['error' => 'Something went wrong'], 500);
        }

        return new JsonResponse([
            'token' => $token['token'],
            'wsUrl' => $token['wsUrl'],
            'expiresAt' => $token['expiresAt'],
            'role' => $token['role'],
        ], 200, ['Cache-Control' => 'no-store']);
    }
}

Register it with Route::post('/rtc/token', RtcTokenController::class)->middleware('auth:sanctum'); in routes/api.php. The SDK's examples/plain/token-server.php and examples/laravel/ are these two as runnable programs, with a webhook receiver beside each.

What a StreamError carries

Method or propertyMeaning
getErrorCode(), $e->errorCodeThe 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.
getStatus(), $e->status, getCode()The HTTP status. 0 when nothing reached the service or the SDK refused the call itself; the 3xx for redirected. \Exception::$code is an int, so getCode() is the status and the string code is getErrorCode().
isRetryable(), $e->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.
getMessage()The method and path, then what went wrong: POST /v1/rooms/show-42/tokens: room not found. Safe to log whole.
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.

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-role400NoThe role 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 Webhooks::parse(): 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, a body that broke off. 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 PHP's own exceptions, never a StreamError, 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\InvalidArgumentException from the constructor
baseUrl or ratesBaseUrl not an absolute http(s) URL with a host (api.voltkit.in without a scheme, //host, ftp://, a leading space)\InvalidArgumentException from the constructor, never quoting the URL
apiKey that is only whitespace, such as a bare line break from a file\InvalidArgumentException from the constructor
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)\InvalidArgumentException saying to check how the key is loaded
timeoutMs of 0 or below; retries below 0\RangeException from the constructor
timeoutMs or retries that is not an int, such as a string from the environment\TypeError from PHP (in a declare(strict_types=1) file; in a coercive file a numeric string is converted)
An id of "", "." or "..", for a room, channel, message, identity or streamThe call throws StreamError 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 json_encode refuses: a resource, invalid UTF-8, or a recursive array in metadata\InvalidArgumentException naming the call, such as PUT /v1/rooms/show-42: the body cannot be sent as JSON: ..., with the \JsonException as its previous
getRates() without ratesBaseUrlThe call throws StreamError rates-unconfigured, status 0
Webhooks::verify() with no secret, an unknown option or a clock that is not a number or closure; a negative or NAN tolerance\InvalidArgumentException or \RangeException, a loud mistake rather than a false that would look like an attack

Whitespace around the secret is fine: it is trimmed before sending. A timeoutMs 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.

The error registry

Every code a VoltKit RTC SDK can report is in one shared registry, copied into the package in its order:

use VoltKit\Rtc\ErrorCodes;

ErrorCodes::ALL;                             // every code, 105 today
ErrorCodes::REST;                            // the ones a call from this SDK can fail with, 29 today
ErrorCodes::info('no-room');                 // ErrorInfo { origin: 'server', http: 404, terminal: true, retryable: false, rest: true }, or null
$label = ErrorCodes::isKnown($e->getErrorCode()) ? $e->getErrorCode() : 'other';   // a bounded label for your metrics

Two kinds of code are outside the registry and pass through unchanged: http-<status> when a reply names no code, and, from getRates(), the website API's own codes such as RATE_LIMITED or NOT_FOUND.

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. Webhooks::parse() verifies and decodes in one step, so a handler cannot forget to check:

<?php

declare(strict_types=1);

use VoltKit\Rtc\StreamError;
use VoltKit\Rtc\Webhooks;

require __DIR__ . '/vendor/autoload.php';

// The current secret, and the previous one during a rotation (see below). Empty values are skipped.
$secrets = array_filter([getenv('VOLTKIT_RTC_WEBHOOK_SECRET'), getenv('VOLTKIT_RTC_WEBHOOK_SECRET_OLD')]);

$rawBody = (string) file_get_contents('php://input');   // the bytes as received: the signature covers them exactly
$header = $_SERVER['HTTP_X_STREAM_SIGNATURE'] ?? null;

try {
    $event = Webhooks::parse($rawBody, $header, $secrets);
} catch (StreamError $e) {
    http_response_code(401);   // bad-signature: not from VoltKit, or too old; VoltKit retries
    exit;
}

http_response_code(200);       // answer first: a slow handler is retried
header('Content-Length: 0');
if (function_exists('fastcgi_finish_request')) {
    fastcgi_finish_request();  // PHP-FPM: the client has its answer; the work below runs after it
}
if (markSeen($event['id'])) {  // deduplicate: delivery is at-least-once
    handle($event);            // ['id' => ..., 'type' => 'room.ended', 'tenantId' => ..., 'roomId' => ..., 'createdAt' => ..., 'data' => [...]]
}

The same receiver as a Laravel controller, in routes/api.php (no CSRF middleware in the way) with the secrets in config/services.php:

final class VoltKitWebhookController extends Controller
{
    public function __invoke(Request $request): Response
    {
        // "whsec_new,whsec_old" in the .env file during a rotation; one secret otherwise.
        $secrets = array_filter(array_map('trim', explode(',', (string) config('services.voltkit.webhook_secrets'))));

        try {
            $event = Webhooks::parse(
                $request->getContent(),                 // the RAW body: never $request->all(), which decodes and re-encodes it
                $request->header('X-Stream-Signature'),
                $secrets,
            );
        } catch (StreamError) {
            return new Response('', 401);               // bad-signature: not from VoltKit, or too old; VoltKit retries
        }

        // Cache::add is atomic: false when the id was already there (delivery is at-least-once).
        if (Cache::add('voltkit-webhook:' . $event['id'], true, 86400)) {
            HandleVoltKitEvent::dispatch($event);       // a queued job, so the answer goes out at once
        }
        return new Response('', 200);
    }
}
  • Pass the raw body. The signature is HMAC-SHA256 over <timestamp>.<raw body>, so a parsed and re-serialised body can never verify. Read php://input in plain PHP, $request->getContent() in Laravel and Symfony, (string) $request->getBody() with PSR-7; never $request->all(), json_decode and back, or anything that re-encodes. Laravel does not alter getContent(), so no middleware change is needed. See Keep the raw body.
  • One header value. VoltKit sends the signature as one header. The header may be a string, a list of strings (joined with commas, as some frameworks hand headers over) or null.
  • What it checks. The timestamp must be within toleranceSeconds of your clock (default 300, 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 hash_equals, every secret against every signature with no early return. To widen the window, pass ['toleranceSeconds' => 600] as the fourth argument; 0 is allowed and strict. A negative or NAN tolerance throws a \RangeException rather than switching the check off, and a string is not coerced ('' from an unset variable would be a window of nothing).
  • What it throws. A StreamError with code bad-signature (status 401) when the delivery does not verify: answer 401, and VoltKit retries it. An \InvalidArgumentException (no secret, an unknown option), a \RangeException or a \TypeError is a mistake in your setup: let it reach your error handler.
  • Webhooks::verify() takes the same arguments and returns true or false instead of the event. The header grammar is exact: at most 4096 characters, one t of up to 15 digits, each v1 exactly 64 lower-case hex characters, unknown schemes skipped, anything else invalid.

The event is the decoded JSON: id, type, tenantId, roomId (when it concerns a room), createdAt (unix milliseconds) and data, 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 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/plain/webhook-receiver.php and examples/laravel/ are these handlers as runnable programs.

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::parse() and Webhooks::verify() 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.

Logging

The SDK logs nothing by default. Pass any PSR-3 logger (composer require psr/log and an implementation; in Laravel, Log::channel('stack') or the injected LoggerInterface) and every attempt is logged at debug:

$rtc = new StreamClient($baseUrl, $apiKey, logger: $logger);
// voltkit-rtc GET /v1/rooms: 200 (attempt 1, 48 ms)
// context: ['method' => 'GET', 'path' => '/v1/rooms', 'status' => 200, 'attempt' => 1, 'elapsedMs' => 48]

Exactly those five fields reach the logger: never a header, a body, a token, a URL with credentials or the key. A failure before any reply is logged with status 0; a retried call is logged once per attempt.

The PSR-18 transport

The default transport is ext-curl. To send through Guzzle, Symfony HttpClient or any PSR-18 client (for a proxy, mTLS, instrumentation, or because your application already has one), wrap it with PSR-17 factories (composer require psr/http-client psr/http-factory plus implementations, such as nyholm/psr7):

use GuzzleHttp\Client as Guzzle;
use Nyholm\Psr7\Factory\Psr17Factory;
use Symfony\Component\HttpClient\HttpClient;
use Symfony\Component\HttpClient\Psr18Client;
use VoltKit\Rtc\Transport\Psr18Transport;

$factory = new Psr17Factory();

// Guzzle: its PSR-18 sendRequest() never follows redirects, but it has no timeout by default. Give it one.
$transport = new Psr18Transport(new Guzzle(['timeout' => 10]), $factory, $factory);

// Symfony HttpClient: it follows redirects by default (and would hand the key to the redirect target), so switch them off.
$transport = new Psr18Transport(new Psr18Client(HttpClient::create(['max_redirects' => 0, 'timeout' => 10])), $factory, $factory);

$rtc = new StreamClient($baseUrl, $apiKey, transport: $transport);

Two things the adapter cannot do for you, because PSR-18 gives it no say: redirects (the SDK refuses a 3xx that comes back, but a client that followed one has already sent X-API-Key to the host it named: configure it never to follow) and the deadline (PSR-18 has no per-request timeout, so timeoutMs is not passed on: give the client a total timeout covering the whole reply, ideally the same value). A timeout the client reports is still a retryable network error, with the client's own words. For the curl transport itself, extra CURLOPT_* values go in its constructor, new CurlTransport([CURLOPT_PROXY => 'http://proxy.internal:3128', CURLOPT_CAINFO => '/etc/ssl/certs/ca.pem']); the options the SDK needs for correctness (redirects off, the deadline, the protocols) are set after yours and win.

Testing

Pass your own transport to test your code without a network. It is handed a VoltKit\Rtc\Transport\Request (method, url, headers, body, timeoutMs, followRedirects) and returns a Response (status, headers, body), or throws a TransportException for a failure in transit:

use VoltKit\Rtc\Transport\{Request, Response, TransportInterface};

final class FakeTransport implements TransportInterface
{
    /** @var list<string> */
    public array $calls = [];

    public function send(Request $request): Response
    {
        $this->calls[] = "$request->method $request->url";
        return new Response(404, [], '{"error":"no-room","message":"room not found"}');
    }
}

$transport = new FakeTransport();
$rtc = new StreamClient(baseUrl: 'https://rtc.test', apiKey: 'sk_test', retries: 0, transport: $transport);
try {
    $rtc->createToken('show-42', 'user-99', 'viewer');
} catch (StreamError $e) {
    $e->isNotFound();      // true
}
$transport->calls;         // ['POST https://rtc.test/v1/rooms/show-42/tokens']

Answer with the service's error body, {"error": "<code>", "message": "..."}, and its status to drive each branch of your error handling. Pass retries: 0 so a failing test stays fast, or a sleeper closure as the last constructor argument to observe the backoff without waiting. To test a webhook handler, sign a body yourself and pin the clock with the now option:

$body = '{"id":"evt_1","type":"webhook.test","tenantId":"t","createdAt":1787000000000}';
$ts = 1787000000;
$header = "t=$ts,v1=" . hash_hmac('sha256', "$ts.$body", 'whsec_test');
$event = Webhooks::parse($body, $header, 'whsec_test', ['now' => $ts]);   // ['id' => 'evt_1', ...]

What to do when

SituationHow it showsWhat to do
The key is wrong or revokedevery call unauthorized (401), not retriedCheck VOLTKIT_RTC_SECRET and that VOLTKIT_RTC_URL is this project's; a getUsage() at boot finds out before your users do
The key has whitespace around it, or came from a mangled filearound it: nothing, it is trimmed; a line break or an accented character inside: \InvalidArgumentException at constructionCheck how the key is loaded; the message says so and never quotes it
Out of minutes or credits, or at a limitisOutOfMinutes(): 402 quota-exhausted, daily-limit-reached, monthly-limit-reached, not retriedAnswer 402; the owner recharges or waits for the reset; getBalance() says so before anyone tries
Wallet billing not ready503 billing-unavailable, not retriedContact support; a retry does not help
The room has ended404 no-room (isNotFound())Tell the viewer; for a returning host, ensureRoom with the same id
The room id is taken409 room-exists from createRoomUse ensureRoom, or another id
The room is full, or you are at a limit429 viewer-quota, room-quota, rate-limited (isQuota()), retriedTell the viewer, end rooms you no longer need, spread calls out; never loop in the handler
The request timed out, or the connection droppednetwork, status 0, retryableThe retries are spent: answer 503 with Retry-After, try again from a job; see below for the three calls sent once
baseUrl redirectsredirected, status 301/302/307/308Set baseUrl to the exact scheme and host the Credentials tab shows
baseUrl is wrongnetwork (DNS, refused), http-<status> with a web page in the message, or unauthorized from another projectCompare with the Credentials tab; it must be the origin, with the scheme
baseUrl has no scheme\InvalidArgumentException at constructionAdd https://; without it, curl would have guessed http://
PHP is too oldComposer: voltkit/rtc-server requires php ^8.2PHP 8.2 or newer
curl is missingComposer: requires ext-curl; or Error: Call to undefined function curl_init()Install php-curl (php -m lists curl when it is there), or pass a PSR-18 transport

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, isRetryable() 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

Webhooks::parse() throws bad-signature, and VoltKit shows the 401s in the delivery log. In order of likelihood: the body was decoded and re-encoded before it reached parse() ($request->all(), $request->json(), a JSON middleware: pass $request->getContent() or php://input); 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 toleranceSeconds); a proxy split the header into two lines (pass the list, it is joined). VoltKit retries a 401, so a fix within the retry window drops nothing.

Behind a proxy or a redirect

For a forward proxy, a corporate CA or mTLS, pass the curl options: new CurlTransport([CURLOPT_PROXY => ..., CURLOPT_CAINFO => ...]), or a PSR-18 client set up for it. The secret travels inside TLS to VoltKit, which a proxy that only forwards the connection never sees. 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 and longer than many PHP-FPM request_terminate_timeout and load-balancer timeouts. For a client used in web requests, lower timeoutMs and retries (timeoutMs: 5000, retries: 1 is 10 seconds and a bit), answer 503 with Retry-After when the error is retryable, and never retry in the handler itself. Keep a second client with the default budget for queue workers, 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; an unauthorized after that means some instance still holds the old key.

Versions

StreamClient::VERSION is the package version, and User-Agent: voltkit-rtc-php/<version> goes on every request. Upgrade with composer update voltkit/rtc-server; the changelog ships with the package. The package follows semver: while 0.x, a minor bump may add API, and nothing public is removed or renamed without a deprecation release first.

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 or dumped client, redirects never followed with the key, a baseUrl without a scheme refused, webhooks with rotation, curl and PSR-18 transports, a PSR-3 logger.

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. ext-curl will do, as long as you keep what the SDK keeps: a deadline that covers the body (CURLOPT_TIMEOUT_MS), no redirects with the key (CURLOPT_FOLLOWLOCATION off), 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.

use VoltKit\Rtc\PlaybackPolicy;
use VoltKit\Rtc\StreamAction;

$live = $rtc->createStream('show-42', PlaybackPolicy::Public);
// ['streamId' => ..., 'streamKey' => ..., 'state' => 'idle', 'playbackUrls' => ['llhls' => ..., 'webrtc' => ...],
//  'publish' => ['whipUrl' => ..., 'token' => ..., 'expiresAt' => ...]]   publish is returned ONLY here: treat whipUrl like a password

// The publish URL is returned only at creation. Mint another when you need one:
$pub = $rtc->createStreamToken('show-42', StreamAction::Publish);                       // ['whipUrl' => ..., 'token' => ..., 'expiresAt' => ...]

// With PlaybackPolicy::Token, each viewer needs URLs minted for them. The action defaults to play.
$play = $rtc->createStreamToken('show-42', identity: 'user-99', ttlSeconds: 3600);      // ['llhls' => ..., 'webrtc' => ..., 'token' => ..., 'expiresAt' => ...]

$stream = $rtc->getStream('show-42');                                   // state, viewers, peakViewers; never publish
$streams = $rtc->listStreams();                                         // a list of streams
$stream = $rtc->updateStream('show-42', 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. 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.