livqeno/livqeno-php is for your PHP backend (Laravel, Symfony, or a
plain script behind PHP-FPM). It holds your secret
API key and talks to the Livqeno REST API for you.
Its main job is making tokens. Your app (web, React,
React Native, Flutter, iOS or Android) can't join a call or chat without
one, and only a backend holding the API key can make one. In a typical app,
this SDK sits behind an endpoint like POST /join-room: your app calls it,
your code checks who the user is, and this SDK returns a token for that user.
It has the same resources, methods and wire fields as the TypeScript SDK. Parameters are plain arrays with the same keys as the TypeScript parameter types, and responses are typed, readonly objects.
You need: PHP 8.1 or later with ext-curl and ext-json, a project,
and an API key. See API credentials.
Install
composer require livqeno/livqeno-phpThe package isn't on Packagist yet. Until it is, install it from a checkout of the repository with a path repository:
{
"repositories": [{ "type": "path", "url": "../Livqeno/sdks/php" }],
"require": { "livqeno/livqeno-php": "*" }
}It needs nothing beyond ext-curl and ext-json: no Guzzle, no PSR-18
adapter.
Set up the client
On your backend, create one client and reuse it for every request (for example, bind it as a singleton in your container).
<?php
require 'vendor/autoload.php';
$client = new \Livqeno\Client(
apiKey: getenv('LIVQENO_API_KEY'),
baseUrl: getenv('LIVQENO_API_URL'), // https://api.livqeno.com
);Pass the API key yourself. The SDK never reads it from the environment on
its own. A missing apiKey (including getenv() returning false) throws
RAVEN_INVALID_CONFIG.
| Argument | Default | Notes |
|---|---|---|
apiKey | required | Sent as Authorization: Bearer <apiKey>. |
baseUrl | http://localhost:4100 | Required when APP_ENV is production or prod; leaving it out then throws RAVEN_INVALID_CONFIG. |
timeout | 10.0 | Seconds per attempt. |
maxRetries | 2 | Network errors and 429/502/503/504 only. |
transport | ext-curl | A Livqeno\Http\TransportInterface, for tests or a custom HTTP stack. |
Redirects are never followed, so the bearer token only ever goes to
baseUrl.
RTC tokens
Call this when a user is about to join a call, once per user, per join. Take
identity from your own login session, never from the request body, or any
user could join as anyone.
// Every participant calls this. getOrCreate returns the existing room
// to the second caller instead of a 409.
$room = $client->rooms->create(['name' => 'demo-room', 'getOrCreate' => true]);
$grant = $client->tokens->create([
'room' => $room->id, // the room's id, not its name
'identity' => $currentUser->id, // from your session, never the body
'permissions' => ['join' => true, 'subscribe' => true, 'publish' => true],
'expiresIn' => 600, // seconds, 30 to 21600
]);
header('Content-Type: application/json');
echo json_encode($grant); // forward the whole objectjson_encode() on any response object gives back the exact API payload:
token, endpoint, iceServers, telemetryUrl, expiresAt and the rest.
In Laravel, return response()->json($grant); does the same. Return it
whole. The iceServers part is what lets calls work on mobile networks.
Moderating a live call
These methods only work on a participant who is connected to the call right
now. If they're not connected, you'll get a 404 with the code
RAVEN_PARTICIPANT_NOT_CONNECTED. For $roomId, you can pass either the
room's id or its name.
$client->rooms->participants->mute('demo-room', 'alice', ['kind' => 'audio', 'muted' => true]); // or 'trackId'
$client->rooms->participants->updatePermissions('demo-room', 'alice', ['publish' => false]); // returns the result
$client->rooms->participants->remove('demo-room', 'alice'); // doesn't revoke their tokenparticipants->list($roomId) and ->get($roomId, $identity) return null
when they can't reach the SFU (the server that routes the call's audio and
video). That's different from a room that's genuinely empty, which comes
back as []. See Rooms & participants for
more.
Chat
Your backend creates conversations, decides who is a member, and mints a chat token for each user. The browser never creates conversations or adds members itself.
$conv = $client->chat->createConversation([
'name' => 'support-room-42',
'type' => 'CHANNEL',
'members' => [['userId' => 'alice', 'role' => 'ADMIN'], ['userId' => 'bob']], // a conversation with no members is unusable
'getOrCreate' => true,
]);
$chatToken = $client->chat->createToken([
'userId' => $currentUser->id,
'conversations' => [$conv->publicId],
'scopes' => ['chat:read', 'chat:send'], // can only narrow the user's role
'expiresIn' => 900,
]);
// Hand $chatToken->token to the browser. Nothing else.Members, messages and moderation:
$client->chat->addMember('support-room-42', ['userId' => 'carol', 'role' => 'MODERATOR']);
$client->chat->removeMember('support-room-42', 'carol');
$client->chat->sendMessage('support-room-42', [
'senderId' => 'system', 'type' => 'system', 'text' => 'Maintenance in 5 minutes',
'clientMessageId' => 'maint-1', // idempotency key
]);
$page = $client->chat->listMessages('support-room-42', ['limit' => 50]);
$page->data; $page->nextCursor; $page->hasMore;
$client->chat->deleteMessage('msg_abc'); // soft deleteLive streaming
A live stream is one main host, optional co-hosts, and many viewers who only watch. On your backend: create the stream, start it, then give each person the right credential.
$stream = $client->liveStreams->create(['title' => 'Friday Q&A', 'hostIdentity' => 'user-123']);
$client->liveStreams->start($stream->id); // CREATED -> LIVE
// Registering a co-host mints full-publish RTC + moderator chat
// credentials in one call. Hand the result to the client SDK unchanged.
$host = $client->liveStreams->addHost($stream->id, ['identity' => 'user-456', 'role' => 'CO_HOST']);
// A viewer credential is always subscribe-only.
$viewer = $client->liveStreams->createViewerToken($stream->id, 'user-789');
$client->liveStreams->removeHost($stream->id, 'user-456');
$client->liveStreams->end($stream->id); // LIVE -> ENDED, terminal$client->liveis the same object as$client->liveStreams.- The method you call decides the role:
addHost()makes a host,createViewerToken()makes a viewer. Nothing in the request can change that. - For a
BROADCASTstream, pass['acceptPlayback' => true]and read$viewer->playback?->llhlsUrl(or->url) instead of getting a 409. See Viewers. $client->liveStreams->stage,->restreamsand->ingresscover the stage, restreaming and OBS ingest.
Webhooks
Livqeno signs every delivery with swalvo-signature: t=<unix>,v1=<hmac>.
Livqeno\Webhook compares in constant time (hash_equals) and rejects a
timestamp more than 300 s from now, which blocks replays. Always verify the
raw body.
use Livqeno\LivqenoException;
use Livqeno\Webhook;
$webhook = new Webhook(getenv('LIVQENO_WEBHOOK_SECRET'));
$rawBody = file_get_contents('php://input');
$signature = $_SERVER['HTTP_LIVQENO_SIGNATURE'] ?? null; // Webhook::SIGNATURE_HEADER
$eventId = $_SERVER['HTTP_LIVQENO_EVENT_ID'] ?? null; // dedupe on it
try {
$event = $webhook->constructEvent($rawBody, $signature); // verified and json-decoded
} catch (LivqenoException $e) { // RAVEN_WEBHOOK_SIGNATURE_INVALID
http_response_code(400);
exit;
}
if ($event['type'] === 'live_stream.started') {
// ...
}
http_response_code(204);In Laravel, verify $request->getContent() against
$request->header('swalvo-signature') with $webhook->verify(...), which
returns a boolean. Webhook::sign($body, $secret, $t) produces a header
exactly as the API does, which helps when you test your own receiver. More:
Webhooks.
Errors
Every failure is a Livqeno\LivqenoException. Branch on errorCode, and
log requestId: support can use it to find the exact request.
use Livqeno\LivqenoException;
try {
$client->rooms->get('2f1b6c3e-8d4a-4b7e-9c21-5a0e7d3f9b10'); // room ids are UUIDs
} catch (LivqenoException $e) {
$e->errorCode; // e.g. 'RAVEN_ROOM_NOT_FOUND'
$e->statusCode; // e.g. 404, or null for a network or config error
$e->requestId; // matches the API's x-request-id
$e->details; // the decoded error body
}The API's own code is passed through. If a proxy strips the JSON body, the
code is derived from the status (RAVEN_AUTH_ERROR, RAVEN_NOT_FOUND,
RAVEN_RATE_LIMITED, RAVEN_INTERNAL_ERROR, ...). The SDK adds
RAVEN_TIMEOUT, RAVEN_NETWORK_ERROR and RAVEN_INVALID_CONFIG, and
throws VALIDATION_ERROR before sending when a required parameter is
missing. All codes: Error Codes.
Retries, timeouts, and lists
| Setting | Behavior |
|---|---|
| Retries | Network errors and HTTP 429, 502, 503 and 504 only, with exponential backoff (300 ms, then 600 ms). maxRetries defaults to 2. Never on 400/401/403/404/409/422/500. |
| Timeouts | timeout, default 10 s, per attempt. |
| Lists | Chat history uses cursors ($page->nextCursor, $page->hasMore). Every other list returns a plain array of objects. |
$model->toArray() returns the raw payload, including any field newer than
your SDK version.
Security
The API key isn't a property of any object, so var_dump, print_r,
json_encode and (array) casts of the client can't reach it, and
serialize($client) throws. Error messages and details are scrubbed of the
key. Constructor parameters are marked #[\SensitiveParameter], which hides
the key from stack traces on PHP 8.2+. See Authentication.
Next steps
- Build a token endpoint and a working call: Quickstart (choose the PHP tab).
- See everything a token can allow: Access tokens.
- Set up the app side: Next.js, React, React Native, or Flutter.