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).
| Module | Purpose |
|---|---|
LiveKit | Small public entry point for building clients |
LiveKit.Config | Validated URL and credentials |
LiveKit.Client | Immutable handle holding config and HTTP options |
LiveKit.Grants | Video, SIP, agent and claim grant encoding |
LiveKit.AccessToken | HS256 access token creation |
LiveKit.Auth | Short-lived service tokens for API calls |
LiveKit.TokenVerifier | HS256 JWT verification |
LiveKit.Twirp | Shared protobuf-over-HTTP transport |
LiveKit.Error | Single, redacted error type |
LiveKit.Proto.* | Generated protobuf messages |
LiveKit.RoomService | Create and administer rooms and participants |
LiveKit.EgressService | Start and manage recordings and streams |
LiveKit.IngressService | Create and manage RTMP/WHIP/URL ingress |
LiveKit.WebhookReceiver | Validate 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:
| Option | Required | Default | Notes |
|---|---|---|---|
:url | yes | — | http, https, ws or wss. ws/wss are normalized to http/https, trailing slashes are removed, and default ports are dropped |
:api_key | yes | — | Must be a non-blank string |
:api_secret | yes | — | Must be a non-blank string |
:timeout | no | 15_000 | Connect timeout in milliseconds |
:receive_timeout | no | 30_000 | Response 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
- API secrets are never logged, never placed in token claims, and never included in error
messages, error metadata or
inspect/1output. LiveKit.Error.sanitize/1drops credential-shaped keys (api_secret,token,authorization, …) and replaces JWT-shaped values with"[REDACTED]". All error metadata passes through it, so server responses that echo anAuthorizationheader cannot leak it.- Service tokens created by
LiveKit.Authlive for 10 minutes and carry only the grants the specific call requires. - Credentials live only in immutable
LiveKit.Config/LiveKit.Clientstructs — never in application-global mutable state — and are never read at compile time or from the environment by the SDK. - Required credentials are validated before any request is made.
Design notes and differences from the Ruby SDK
- Tagged tuples over exceptions. The default API returns
{:ok, _}/{:error, _}; raising is opt-in through bang functions. - No implicit environment lookups. The Ruby SDK falls back to
LIVEKIT_API_KEYandLIVEKIT_API_SECRET; this SDK takes credentials as explicit arguments. - Immutable token pipeline. Instead of mutable accessors (
token.identity = ...), tokens are built withwith_*functions that return new structs. - One shared client and transport. Rather than a Twirp client class per service, all
services share
LiveKit.ClientandLiveKit.Twirp. - Short-lived service tokens. Administrative tokens default to 10 minutes, while
participant tokens default to 6 hours (matching the Go and JavaScript SDKs; the Ruby
SDK's
DEFAULT_TTLconstant is 10 minutes). - A grant is not required to sign a token. The Ruby SDK raises unless a video or SIP grant is present; this SDK follows the Go implementation and leaves that policy to the caller.
- Protobuf transport. Requests and responses use
application/protobufrather than the Twirp JSON encoding some SDKs use.
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.