Packages
livekit_sdk
0.1.0
Elixir server SDK for LiveKit. Mint access tokens, call Room/Egress/Ingress Twirp APIs, and verify webhook callbacks.
Current section
Files
Jump to
Current section
Files
livekit_sdk
README.md
README.md
# LiveKit Server SDK for Elixir
An idiomatic Elixir port of the [LiveKit](https://livekit.io) 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](https://github.com/livekit/protocol) as the
source of truth and the [Ruby server SDK](https://github.com/livekit/server-sdk-ruby) 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`:
```elixir
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](https://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.
```elixir
{: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
```elixir
{: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:
```elixir
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:
```elixir
{: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:
```elixir
{: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):
```elixir
{: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:
```elixir
{: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:
```elixir
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.
```elixir
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:
```elixir
%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:
```bash
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:
```elixir
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/1` output.
- `LiveKit.Error.sanitize/1` drops 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 an `Authorization` header cannot leak it.
- Service tokens created by `LiveKit.Auth` live for 10 minutes and carry only the grants the
specific call requires.
- Credentials live only in immutable `LiveKit.Config`/`LiveKit.Client` structs — 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_KEY` and
`LIVEKIT_API_SECRET`; this SDK takes credentials as explicit arguments.
- **Immutable token pipeline.** Instead of mutable accessors (`token.identity = ...`),
tokens are built with `with_*` functions that return new structs.
- **One shared client and transport.** Rather than a Twirp client class per service, all
services share `LiveKit.Client` and `LiveKit.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_TTL` constant 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/protobuf` rather than the
Twirp JSON encoding some SDKs use.
## Development
```bash
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](https://hex.pm/packages/bypass) server, and time is supplied through injectable
clock functions.