LiveKit Server SDK for Elixir

An idiomatic Elixir port of the LiveKit server SDKs. It lets an Elixir backend mint LiveKit access tokens and talk to LiveKit's Twirp server APIs, using the official LiveKit protocol definitions as the source of truth and the Ruby server SDK as a behavioral reference.

Milestone status

Milestones 1–4 are complete (shared foundation, RoomService, EgressService, IngressService, and WebhookReceiver).

ModulePurpose
LiveKitSmall public entry point for building clients
LiveKit.ConfigValidated URL and credentials
LiveKit.ClientImmutable handle holding config and HTTP options
LiveKit.GrantsVideo, SIP, agent and claim grant encoding
LiveKit.AccessTokenHS256 access token creation
LiveKit.AuthShort-lived service tokens for API calls
LiveKit.TokenVerifierHS256 JWT verification
LiveKit.TwirpShared protobuf-over-HTTP transport
LiveKit.ErrorSingle, redacted error type
LiveKit.Proto.*Generated protobuf messages
LiveKit.RoomServiceCreate and administer rooms and participants
LiveKit.EgressServiceStart and manage recordings and streams
LiveKit.IngressServiceCreate and manage RTMP/WHIP/URL ingress
LiveKit.WebhookReceiverValidate and decode webhook callbacks

Still to come: SIPService, AgentDispatchService, and ConnectorService.

Installation

Add the dependency to your mix.exs:

def deps do
[
{:livekit_sdk, "~> 0.1.0"}
]
end

Then run mix deps.get. Requires Elixir 1.15 or later.

Docs: hexdocs.pm/livekit_sdk.

Creating a client

A client is plain immutable data. It starts no process, opens no connection, and can be built once and reused everywhere.

{:ok, client} =
LiveKit.client(
url: System.fetch_env!("LIVEKIT_URL"),
api_key: System.fetch_env!("LIVEKIT_API_KEY"),
api_secret: System.fetch_env!("LIVEKIT_API_SECRET")
)

LiveKit.client!/1 raises LiveKit.Error instead of returning a tuple.

The SDK never reads environment variables itself; your application decides where credentials come from.

Generating access tokens

{:ok, jwt} =
LiveKit.AccessToken.new(
api_key: "api-key",
api_secret: "api-secret",
identity: "user-123"
)
|> LiveKit.AccessToken.with_name("Alice")
|> LiveKit.AccessToken.with_metadata(~s({"role":"host"}))
|> LiveKit.AccessToken.with_video_grant(%LiveKit.Grants.Video{
room_join: true,
room: "support-room"
})
|> LiveKit.AccessToken.to_jwt()

The signed payload contains iss (the API key), sub (the identity), iat, nbf, exp and the LiveKit grant claims. Tokens are signed with HS256 using the API secret; the secret itself never appears in the payload.

Grants are structs, not free-form maps, and are encoded with the camelCase claim names LiveKit expects:

LiveKit.Grants.to_claims(%LiveKit.Grants.Video{room_join: true, can_publish: false})
#=> %{"roomJoin" => true, "canPublish" => false}

Unset (nil) fields are omitted from the token, while an explicit false is preserved — LiveKit distinguishes "denied" from "unset" for permissions such as canPublish.

LiveKit.Grants.SIP and LiveKit.Grants.Agent cover the sip and agent claims, and are attached with with_sip_grant/2 and with_agent_grant/2.

Token lifetime

The default TTL is 6 hours (21_600 seconds), matching the Go and JavaScript SDKs. Set a custom lifetime with LiveKit.AccessToken.with_ttl/2 or the :ttl option. Zero and negative TTLs are rejected with a :validation error.

Calling the Room Service

LiveKit.RoomService wraps every RoomService RPC. Pass a client and keyword options; the module builds the protobuf request and signs a short-lived service token with only the grants that call needs:

{:ok, %LiveKit.Proto.Room{} = room} =
LiveKit.RoomService.create_room(client,
name: "support-room",
empty_timeout: 300,
max_participants: 20
)
{:ok, %LiveKit.Proto.ListRoomsResponse{rooms: rooms}} =
LiveKit.RoomService.list_rooms(client)
{:ok, %LiveKit.Proto.ListParticipantsResponse{participants: participants}} =
LiveKit.RoomService.list_participants(client, room: "support-room")

Other methods follow the same shape: delete_room/2, get_participant/2, remove_participant/2, mute_published_track/2, update_participant/2, update_subscriptions/2, send_data/2, update_room_metadata/2, forward_participant/2, move_participant/2, and perform_rpc/2.

Under the hood they use LiveKit.Twirp, which posts protobuf to /twirp/livekit.RoomService/<Method> with Authorization: Bearer <jwt>.

Egress and Ingress

Record a room and manage ingress the same way:

{:ok, %LiveKit.Proto.EgressInfo{} = egress} =
LiveKit.EgressService.start_room_composite_egress(client,
room_name: "support-room",
layout: "speaker",
output: %LiveKit.Proto.EncodedFileOutput{filepath: "support-room.mp4"}
)
{:ok, %LiveKit.Proto.ListEgressResponse{items: items}} =
LiveKit.EgressService.list_egress(client, room_name: "support-room")
{:ok, %LiveKit.Proto.IngressInfo{} = ingress} =
LiveKit.IngressService.create_ingress(client,
input_type: :RTMP_INPUT,
name: "camera",
room_name: "support-room",
participant_identity: "camera"
)
{:ok, %LiveKit.Proto.ListIngressResponse{items: ingresses}} =
LiveKit.IngressService.list_ingress(client)

Webhooks

LiveKit POSTs JSON events with Content-Type: application/webhook+json and an Authorization JWT whose sha256 claim matches the raw body. Always pass the raw body string (not a decoded map):

{:ok, receiver} =
LiveKit.WebhookReceiver.new(
api_key: System.fetch_env!("LIVEKIT_API_KEY"),
api_secret: System.fetch_env!("LIVEKIT_API_SECRET")
)
# In a Plug/Phoenix endpoint, use the raw body:
{:ok, %LiveKit.Proto.WebhookEvent{} = event} =
LiveKit.WebhookReceiver.receive(receiver, raw_body, auth_header)
case event.event do
"room_started" -> handle_room_started(event)
"participant_joined" -> handle_participant_joined(event)
other -> Logger.debug("unhandled webhook: #{other}")
end

LiveKit.TokenVerifier is also available for verifying access tokens in general.

You can also mint a service token directly:

{:ok, jwt} =
LiveKit.Auth.service_token(client, video_grant: %LiveKit.Grants.Video{room_list: true})

Configuration options

LiveKit.Config.new/1 (and therefore LiveKit.Client.new/1) accepts:

OptionRequiredDefaultNotes
:urlyeshttp, https, ws or wss. ws/wss are normalized to http/https, trailing slashes are removed, and default ports are dropped
:api_keyyesMust be a non-blank string
:api_secretyesMust be a non-blank string
:timeoutno15_000Connect timeout in milliseconds
:receive_timeoutno30_000Response timeout in milliseconds

LiveKit.Client.new/1 additionally accepts :request_options, a keyword list merged into every Req request. This is the seam for tests and for advanced HTTP tuning:

LiveKit.Client.new!(
url: "http://localhost:7880",
api_key: "devkey",
api_secret: "secret",
request_options: [plug: {Req.Test, MyStub}]
)

Error handling

Every public function returns {:ok, value} or {:error, %LiveKit.Error{}}. Bang variants (client!/1, new!/1, to_jwt!/1) raise the same struct.

case LiveKit.RoomService.list_rooms(client) do
{:ok, response} -> handle(response)
{:error, %LiveKit.Error{retryable: true} = error} -> retry_later(error)
{:error, error} -> Logger.error(Exception.message(error))
end

LiveKit.Error carries a type, human-readable message, and — where available — the Twirp code, HTTP status, sanitized meta and a retryable flag:

%LiveKit.Error{
type: :twirp,
code: "not_found",
status: 404,
message: "room not found",
meta: %{},
retryable: false
}

Types are :configuration, :validation, :authentication, :transport, :timeout, :http, :twirp, :encoding, :decoding, :protobuf and :unknown. Transport failures, timeouts, HTTP 5xx/408/429 and Twirp codes such as unavailable are marked retryable.

Protobuf generation

The protocol definitions are vendored in priv/proto, and the generated modules in lib/livekit/proto are committed — a fresh clone compiles without protoc.

Protocol version:@livekit/protocol@1.50.4 (commit 2172178d20d4c8d14d6873b4e9560cc38cf48c0c), recorded in priv/proto/REVISION.

To refresh them you need protoc and the protoc-gen-elixir plugin:

brew install protobuf # or your platform's equivalent
mix escript.install hex protobuf 0.14.1
export PATH="$HOME/.mix/escripts:$PATH"
mix proto.fetch # vendor the pinned .proto files into priv/proto
mix proto.generate # regenerate lib/livekit/proto

mix proto.fetch --ref <commit-or-tag> vendors a different protocol revision.

Generation is a development-time step: it never runs during compilation or application startup. All files are compiled in one protoc invocation, and generated modules are flattened from the protobuf livekit package into a predictable namespace:

LiveKit.Proto.ListRoomsRequest
LiveKit.Proto.Room
LiveKit.Proto.ParticipantInfo.Kind

Enums, oneofs, maps, repeated fields and optional presence are all preserved.

Security notes

Design notes and differences from the Ruby SDK

Development

mix deps.get
mix compile --warnings-as-errors
mix format --check-formatted
mix test

The test suite is fully offline and deterministic: HTTP is exercised through a local Bypass server, and time is supplied through injectable clock functions.