# Join Call Interception

`CallJoinIntercepting` lets you run app-specific logic at the end of the join flow. The SDK calls it after the backend join request succeeds and the local `Call` state has been updated, but before the call is treated as fully joined.

This is useful in ringing flows where you want to delay the transition into the in-call UI until another participant is actually ready.

The `callReadyToJoin(_:)` callback has three possible outcomes:

- Return normally and the join continues.
- Throw and the join fails with a `CallJoinInterceptionError`. Your original error is preserved in `underlyingError`.
- Take too long and the SDK stops waiting, then continues the join automatically.

### Attaching the interceptor

If you use `CallViewModel`, assign the interceptor before you start, join, or accept a call:

```swift
import StreamVideo
import StreamVideoSwiftUI

let callViewModel = CallViewModel()
callViewModel.callJoinInterceptor = ParticipantReadyCallJoinInterceptor(streamVideo: streamVideo)
```

If you are joining calls without `CallViewModel`, pass the interceptor directly to `call.join(joinInterceptor:)`.

### Example: wait for another participant

The following sample mirrors the behavior used in our demo app. It only changes ringing calls. When the local user is ready to join, it sends a custom `participant.ready` event and waits until another participant sends the same signal.

```swift
import Combine
import StreamVideo

@MainActor
final class ParticipantReadyCallJoinInterceptor: CallJoinIntercepting {
    private let streamVideo: StreamVideo
    private let disposableBag = DisposableBag()
    private var ringingCallCancellable: AnyCancellable?
    private var customEventCancellable: AnyCancellable?
    private let customEventKey: String
    private let currentUserID: String

    private let hasOtherReadyParticipants = CurrentValueSubject<Bool, Never>(false)

    init(streamVideo: StreamVideo, customEventKey: String = "participant.ready") {
        self.streamVideo = streamVideo
        self.customEventKey = customEventKey
        currentUserID = streamVideo.state.user.id

        ringingCallCancellable = streamVideo
            .state
            .$ringingCall
            .receive(on: DispatchQueue.main)
            .removeDuplicates { $0?.cId == $1?.cId }
            .sinkTask(storeIn: disposableBag) { @MainActor [weak self] ringingCall in
                self?.didUpdate(ringingCall: ringingCall)
            }
    }

    func callReadyToJoin(_ call: Call) async throws {
        // Non-ringing calls should continue without extra coordination.
        guard customEventCancellable != nil else {
            return
        }

        do {
            try await call.sendCustomEvent([customEventKey: .string(currentUserID)])
        } catch {
            // Keep the example non-blocking even if the readiness ping fails.
        }

        _ = try? await hasOtherReadyParticipants
            .filter { $0 }
            .nextValue()
    }

    private func didUpdate(ringingCall: Call?) {
        cancelCustomEventObservation()

        guard let ringingCall else {
            return
        }

        customEventCancellable = ringingCall
            .eventPublisher(for: CustomVideoEvent.self)
            .compactMap { [customEventKey] in $0.custom[customEventKey]?.stringValue }
            .filter { [currentUserID] in $0 != currentUserID }
            .map { _ in true }
            .sinkTask(storeIn: disposableBag) { @MainActor [weak self] hasReadyParticipant in
                self?.hasOtherReadyParticipants.send(hasReadyParticipant)
            }
    }

    private func cancelCustomEventObservation() {
        customEventCancellable?.cancel()
        customEventCancellable = nil
        hasOtherReadyParticipants.send(false)
    }
}
```

This interceptor uses `streamVideo.state.ringingCall` to scope the behavior to ringing flows and `CustomVideoEvent` to coordinate readiness between participants. For non-ringing calls, the interceptor returns immediately and the join proceeds as usual.

<admonition type="note">

This sample is intentionally tolerant. If the readiness event cannot be sent, or if nobody else sends one back, the SDK eventually continues the join on its own. If your app must block entry until some prerequisite succeeds, throw from `callReadyToJoin(_:)` instead of ignoring the failure.

</admonition>

For more background, see the [ringing guide](/video/docs/ios/advanced/incoming-calls/ringing/) and [custom events](/video/docs/ios/guides/custom-events/).


---

This page was last updated at 2026-05-29T10:51:52.085Z.

For the most recent version of this documentation, visit [https://getstream.io/video/docs/ios/ui-cookbook/join-call-interception/](https://getstream.io/video/docs/ios/ui-cookbook/join-call-interception/).