Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions config-sample.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,28 @@ keys:
# urls:
# - https://your-host.com/handler

# Room-level webhooks
# in addition to the global `webhook` config above, individual rooms can carry their own
# webhook endpoints. Set them per room on CreateRoom (`webhooks` field), or attach them to
# a named room preset here and create the room with `room_preset: <name>`.
# Room webhooks receive room_started, room_finished, participant_joined, participant_left,
# participant_connection_aborted, track_published and track_unpublished. Egress and ingress
# events are never delivered to them; those carry their own webhooks on the egress request.
# room:
# room_configurations:
# support-call:
# empty_timeout: 300
# webhooks:
# - url: https://your-host.com/support-hook
# # api key used to sign the message; must be one of the keys below.
# # leave unset to sign with the key from the global `webhook` config
# signing_key: <api_key>
# # optional; include_events takes precedence over exclude_events
# filter_params:
# include_events:
# - participant_joined
# - participant_left

# Signal Relay
# since v1.4.0, a more reliable, psrpc based signal relay is available
# this gives us the ability to reliably proxy messages between a signal server and RTC node
Expand Down
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -168,3 +168,5 @@ replace github.com/pion/webrtc/v4 => github.com/livekit/webrtc-pion/v4 v4.2.18-w
replace github.com/pion/dtls/v3 => github.com/livekit/dtls/v3 v3.1.5-warp.1

replace github.com/pion/ice/v4 => github.com/livekit/ice/v4 v4.4.0-warp.2

replace github.com/livekit/protocol => github.com/Darshak03/protocol v1.51.1-0.20260907191956-dcc0838d4b61
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ cel.dev/cel-go v0.32.0 h1:irvpFKr5EuGPyxeME03ERh0rii1TX+BDAnB9eL3IvNk=
cel.dev/cel-go v0.32.0/go.mod h1:DnVip7tpJSsgZymwfT+m1tnEVy3ivAjSMXPx12YrMkU=
cel.dev/expr v0.25.3 h1:A2jO8jwOugrrovveCWfj0KEZOfqiLgAcwjpHPhzIGw0=
cel.dev/expr v0.25.3/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
github.com/Darshak03/protocol v1.51.1-0.20260907191956-dcc0838d4b61 h1:6WEMZWB+K62S9xS58eyI949dAZNH7Jxgf8Wet76YE2o=
github.com/Darshak03/protocol v1.51.1-0.20260907191956-dcc0838d4b61/go.mod h1:zxowkRnQlJ2VMn6ZyinXMDi985wcKXuWNeXmEERqFAs=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ=
Expand Down Expand Up @@ -164,8 +166,6 @@ github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 h1:9x+U2HGLrSw5AT
github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731/go.mod h1:Rs3MhFwutWhGwmY1VQsygw28z5bWcnEYmS1OG9OxjOQ=
github.com/livekit/mediatransportutil v0.0.0-20260821083140-f234b534b095 h1:BcliKAXoMhl/nWmzQweQ5kmh4Qqagxl4s3Z5pvM/7AY=
github.com/livekit/mediatransportutil v0.0.0-20260821083140-f234b534b095/go.mod h1:o8CFmAdrVwzJNOCsQCLUzXRjokkufNshnQHOe4fRaqU=
github.com/livekit/protocol v1.51.1-0.20260905133529-a4f4b5c0c23f h1:+48IWNrsoTgbB0JGv+xJl4umx6e/vh1BX1Tcokuacwc=
github.com/livekit/protocol v1.51.1-0.20260905133529-a4f4b5c0c23f/go.mod h1:zxowkRnQlJ2VMn6ZyinXMDi985wcKXuWNeXmEERqFAs=
github.com/livekit/psrpc v0.7.6 h1:YG07lUMTtf+eaYI2goT9zcVZ0kGJNWN1K6ETNFtv1HQ=
github.com/livekit/psrpc v0.7.6/go.mod h1:DMw15RO7x5XmcgfwzWJYk2In605kx+wu1QRVbPfzf8M=
github.com/livekit/webrtc-pion/v4 v4.2.18-warp.1 h1:fH+v4W+NFp9FfPzON6FaUFNmazGcctaAhb2P+Ksf+1s=
Expand Down
5 changes: 3 additions & 2 deletions pkg/rtc/room.go
Original file line number Diff line number Diff line change
Expand Up @@ -1268,6 +1268,7 @@ func (r *Room) onStateChange(p types.LocalParticipant) {
false,
p.IsWarpEnabled(),
p.TelemetryGuard(),
r.Internal().GetWebhooks(),
)

p.GetReporter().Tx(func(tx roomobs.ParticipantSessionTx) {
Expand Down Expand Up @@ -2057,11 +2058,11 @@ func (l participantTelemetryListener) OnTrackPublishRequested(pID livekit.Partic
}

func (l participantTelemetryListener) OnTrackPublished(pID livekit.ParticipantID, identity livekit.ParticipantIdentity, ti *livekit.TrackInfo, shouldSendEvent bool) {
l.room.telemetry.TrackPublished(context.Background(), l.eventRoom(), pID, identity, ti, shouldSendEvent)
l.room.telemetry.TrackPublished(context.Background(), l.eventRoom(), pID, identity, ti, shouldSendEvent, l.room.Internal().GetWebhooks())
}

func (l participantTelemetryListener) OnTrackUnpublished(pID livekit.ParticipantID, identity livekit.ParticipantIdentity, ti *livekit.TrackInfo, wasPublishedLocally bool, shouldSendEvent bool) {
l.room.telemetry.TrackUnpublished(context.Background(), l.eventRoom(), pID, identity, ti, wasPublishedLocally, shouldSendEvent)
l.room.telemetry.TrackUnpublished(context.Background(), l.eventRoom(), pID, identity, ti, wasPublishedLocally, shouldSendEvent, l.room.Internal().GetWebhooks())
}

func (l participantTelemetryListener) OnTrackSubscribeRequested(pID livekit.ParticipantID, ti *livekit.TrackInfo) {
Expand Down
2 changes: 1 addition & 1 deletion pkg/rtc/signalanddatastats.go
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,7 @@ func (s *BytesSignalStats) maybeStart() {

func (s *BytesSignalStats) worker() {
s.BytesTrackStats.worker()
s.telemetry.ParticipantLeft(s.ctx, s.ri, s.pi, false, s.guard)
s.telemetry.ParticipantLeft(s.ctx, s.ri, s.pi, false, s.guard, nil)
close(s.stopped)
}

Expand Down
9 changes: 9 additions & 0 deletions pkg/service/roomallocator.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,9 @@ func (r *StandardRoomAllocator) CreateRoom(ctx context.Context, req *livekit.Cre
if req.Agents != nil {
internal.AgentDispatches = req.Agents
}
if req.Webhooks != nil {
internal.Webhooks = req.Webhooks
}
if req.MinPlayoutDelay > 0 || req.MaxPlayoutDelay > 0 {
internal.PlayoutDelay = &livekit.PlayoutDelay{
Enabled: true,
Expand Down Expand Up @@ -243,6 +246,12 @@ func (r *StandardRoomAllocator) applyNamedRoomConfiguration(req *livekit.CreateR
if clone.Metadata == "" {
clone.Metadata = conf.Metadata
}
if clone.Webhooks == nil && len(conf.Webhooks) > 0 {
clone.Webhooks = make([]*livekit.WebhookConfig, 0, len(conf.Webhooks))
for _, wh := range conf.Webhooks {
clone.Webhooks = append(clone.Webhooks, utils.CloneProto(wh))
}
}

return clone, nil
}
6 changes: 3 additions & 3 deletions pkg/service/roommanager.go
Original file line number Diff line number Diff line change
Expand Up @@ -617,7 +617,7 @@ func (r *RoomManager) StartSession(
// update room store with new numParticipants
proto := room.ToProto()
persistRoomForParticipantCount(proto)
r.telemetry.ParticipantLeft(ctx, proto, p.ToProto(), true, participant.TelemetryGuard())
r.telemetry.ParticipantLeft(ctx, proto, p.ToProto(), true, participant.TelemetryGuard(), room.Internal().GetWebhooks())
})
participant.OnClaimsChanged(func(participant types.LocalParticipant) {
pLogger.Debugw("refreshing client token after claims change")
Expand Down Expand Up @@ -697,7 +697,7 @@ func (r *RoomManager) getOrCreateRoom(ctx context.Context, createRoom *livekit.C
killDispServer()

roomInfo := newRoom.ToProto()
r.telemetry.RoomEnded(ctx, roomInfo, reason.ToProto())
r.telemetry.RoomEnded(ctx, roomInfo, reason.ToProto(), newRoom.Internal().GetWebhooks())
prometheus.RoomEnded(time.Unix(roomInfo.CreationTime, 0))
if err := r.deleteRoom(ctx, roomName); err != nil {
newRoom.Logger().Errorw("could not delete room", err)
Expand Down Expand Up @@ -726,7 +726,7 @@ func (r *RoomManager) getOrCreateRoom(ctx context.Context, createRoom *livekit.C

newRoom.Hold()

r.telemetry.RoomStarted(ctx, newRoom.ToProto())
r.telemetry.RoomStarted(ctx, newRoom.ToProto(), newRoom.Internal().GetWebhooks())
prometheus.RoomStarted()

if created && createRoom.GetEgress().GetRoom() != nil {
Expand Down
25 changes: 25 additions & 0 deletions pkg/service/roomservice.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,15 @@ package service
import (
"context"
"fmt"
"net/url"
"strconv"

"github.com/twitchtv/twirp"

"github.com/livekit/livekit-server/pkg/config"
"github.com/livekit/livekit-server/pkg/routing"
"github.com/livekit/livekit-server/pkg/rtc"
"github.com/livekit/protocol/auth"
"github.com/livekit/protocol/livekit"
"github.com/livekit/protocol/logger"
"github.com/livekit/protocol/rpc"
Expand All @@ -33,6 +35,7 @@ import (
type RoomService struct {
limitConf config.LimitConfig
apiConf config.APIConfig
keyProvider auth.KeyProvider
router routing.MessageRouter
roomAllocator RoomAllocator
roomStore ServiceStore
Expand All @@ -48,6 +51,7 @@ type RoomService struct {
func NewRoomService(
limitConf config.LimitConfig,
apiConf config.APIConfig,
keyProvider auth.KeyProvider,
router routing.MessageRouter,
roomAllocator RoomAllocator,
serviceStore ServiceStore,
Expand All @@ -59,6 +63,7 @@ func NewRoomService(
svc = &RoomService{
limitConf: limitConf,
apiConf: apiConf,
keyProvider: keyProvider,
router: router,
roomAllocator: roomAllocator,
roomStore: serviceStore,
Expand Down Expand Up @@ -97,6 +102,10 @@ func (s *RoomService) CreateRoom(ctx context.Context, req *livekit.CreateRoomReq
}
}

if err := s.validateWebhooks(req.Webhooks); err != nil {
return nil, err
}

err := s.roomAllocator.SelectRoomNode(ctx, livekit.RoomName(req.Name), livekit.NodeID(req.NodeId))
if err != nil {
return nil, err
Expand All @@ -107,6 +116,22 @@ func (s *RoomService) CreateRoom(ctx context.Context, req *livekit.CreateRoomReq
return room, err
}

// validateWebhooks rejects room-scoped webhook configs that could never deliver, so
// the caller learns at CreateRoom time rather than from a per-event warning log.
func (s *RoomService) validateWebhooks(webhooks []*livekit.WebhookConfig) error {
for _, wh := range webhooks {
u, err := url.Parse(wh.Url)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return twirp.InvalidArgumentError("webhooks.url", "must be an absolute http(s) URL")
}
// an empty signing key means "use the default key from the webhook config"
if wh.SigningKey != "" && s.keyProvider.GetSecret(wh.SigningKey) == "" {
return twirp.InvalidArgumentError("webhooks.signing_key", "unknown api key")
}
}
return nil
}

func (s *RoomService) ListRooms(ctx context.Context, req *livekit.ListRoomsRequest) (*livekit.ListRoomsResponse, error) {
RecordRequest(ctx, req)

Expand Down
1 change: 1 addition & 0 deletions pkg/service/roomservice_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ func newTestRoomService(limitConf config.LimitConfig) *TestRoomService {
svc, err := service.NewRoomService(
limitConf,
config.APIConfig{ExecutionTimeout: 2},
auth.NewFileBasedKeyProviderFromMap(map[string]string{"APIkey": "secret-that-is-long-enough"}),
router,
allocator,
store,
Expand Down
145 changes: 145 additions & 0 deletions pkg/service/roomwebhook_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
// Copyright 2026 LiveKit, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package service_test

import (
"context"
"testing"

"github.com/stretchr/testify/require"
"github.com/twitchtv/twirp"

"github.com/livekit/protocol/auth"
"github.com/livekit/protocol/livekit"

"github.com/livekit/livekit-server/pkg/config"
"github.com/livekit/livekit-server/pkg/routing"
"github.com/livekit/livekit-server/pkg/service"
)

func TestCreateRoomWebhooks(t *testing.T) {
newAllocator := func(t *testing.T, mut func(*config.Config)) (service.RoomAllocator, *config.Config) {
conf, err := config.NewConfig("", true, nil, nil)
require.NoError(t, err)
if mut != nil {
mut(conf)
}
node, err := routing.NewLocalNode(conf)
require.NoError(t, err)
ra, conf := newTestRoomAllocator(t, conf, node.Clone())
return ra, conf
}

reqWebhook := &livekit.WebhookConfig{Url: "https://example.com/req"}
presetWebhook := &livekit.WebhookConfig{Url: "https://example.com/preset"}

t.Run("webhooks on the request land on RoomInternal", func(t *testing.T) {
ra, _ := newAllocator(t, nil)

_, internal, _, err := ra.CreateRoom(context.Background(), &livekit.CreateRoomRequest{
Name: "myroom",
Webhooks: []*livekit.WebhookConfig{reqWebhook},
}, true)
require.NoError(t, err)
require.Len(t, internal.GetWebhooks(), 1)
require.Equal(t, reqWebhook.Url, internal.GetWebhooks()[0].Url)
})

t.Run("a room with no webhooks configured gets none", func(t *testing.T) {
ra, _ := newAllocator(t, nil)

_, internal, _, err := ra.CreateRoom(context.Background(), &livekit.CreateRoomRequest{Name: "myroom"}, true)
require.NoError(t, err)
require.Empty(t, internal.GetWebhooks())
})

t.Run("preset webhooks apply when the request has none", func(t *testing.T) {
ra, _ := newAllocator(t, func(conf *config.Config) {
conf.Room.RoomConfigurations = map[string]*livekit.RoomConfiguration{
"support": {Webhooks: []*livekit.WebhookConfig{presetWebhook}},
}
})

_, internal, _, err := ra.CreateRoom(context.Background(), &livekit.CreateRoomRequest{
Name: "myroom",
RoomPreset: "support",
}, true)
require.NoError(t, err)
require.Len(t, internal.GetWebhooks(), 1)
require.Equal(t, presetWebhook.Url, internal.GetWebhooks()[0].Url)
})

t.Run("request webhooks win over the preset", func(t *testing.T) {
ra, _ := newAllocator(t, func(conf *config.Config) {
conf.Room.RoomConfigurations = map[string]*livekit.RoomConfiguration{
"support": {Webhooks: []*livekit.WebhookConfig{presetWebhook}},
}
})

_, internal, _, err := ra.CreateRoom(context.Background(), &livekit.CreateRoomRequest{
Name: "myroom",
RoomPreset: "support",
Webhooks: []*livekit.WebhookConfig{reqWebhook},
}, true)
require.NoError(t, err)
require.Len(t, internal.GetWebhooks(), 1)
require.Equal(t, reqWebhook.Url, internal.GetWebhooks()[0].Url)
})
}

func TestCreateRoomWebhookValidation(t *testing.T) {
createCtx := func() context.Context {
return service.WithGrants(context.Background(), &auth.ClaimGrants{Video: &auth.VideoGrant{RoomCreate: true}}, "")
}
requireInvalidArg := func(t *testing.T, err error) {
t.Helper()
terr, ok := err.(twirp.Error)
require.True(t, ok, "expected twirp error, got %T (%v)", err, err)
require.Equal(t, twirp.InvalidArgument, terr.Code())
}

// newTestRoomService registers "APIkey" with the key provider
for _, tc := range []struct {
name string
wh *livekit.WebhookConfig
}{
{"empty url", &livekit.WebhookConfig{}},
{"relative url", &livekit.WebhookConfig{Url: "/hook"}},
{"non-http scheme", &livekit.WebhookConfig{Url: "ftp://example.com/hook"}},
{"no host", &livekit.WebhookConfig{Url: "https:///hook"}},
{"unknown signing key", &livekit.WebhookConfig{Url: "https://example.com/hook", SigningKey: "nope"}},
} {
t.Run(tc.name, func(t *testing.T) {
svc := newTestRoomService(config.LimitConfig{})
_, err := svc.CreateRoom(createCtx(), &livekit.CreateRoomRequest{
Name: "myroom",
Webhooks: []*livekit.WebhookConfig{tc.wh},
})
requireInvalidArg(t, err)
})
}

t.Run("valid webhook is accepted", func(t *testing.T) {
svc := newTestRoomService(config.LimitConfig{})
_, err := svc.CreateRoom(createCtx(), &livekit.CreateRoomRequest{
Name: "myroom",
Webhooks: []*livekit.WebhookConfig{
{Url: "https://example.com/hook", SigningKey: "APIkey"},
{Url: "http://example.com/hook2"}, // empty signing key means the default
},
})
require.NoError(t, err)
})
}
10 changes: 5 additions & 5 deletions pkg/service/wire_gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading