Voice Client SDK — Best Practices for Long-Duration Calls Voice Client SDK — Best Practices for Long-Duration Calls

Voice Client SDK — Best Practices for Long-Duration Calls

Vonage API Support

Symptom

This guide outlines the best practices for applications using the Vonage Voice Client SDK where calls are expected to last longer than 10 minutes (e.g., 20+ minute banking, contact centre, or support calls).

Without proper session management, long-duration calls are at risk of a signalling session expiry mid-call. This causes call controls (Mute, Speaker, End Call) to stop working, remote hangup events to go undetected, and — depending on how the application handles the TokenExpired callback — a frozen call screen that requires an app restart to dismiss.

This guide covers the three areas that must be addressed: JWT lifetime, proactive session refresh, and graceful error recovery.

 

Applies To

  • Vonage Voice Client SDK (Android & iOS)
  • Branded Call Solution (BCS)

Background — How the Session and JWT Interact

It is important to understand the relationship between the JWT and the signalling session before implementing any of the practices below.

Component Role
JWT (exp claim) Controls how long the token is valid for WebSocket reconnect authentication
Signalling session (WebSocket) Kept alive by ping/pong — survives as long as the WebSocket stays connected and the JWT is valid for reconnects
WebRTC media stream Runs independently of the signaling session — audio continues even if the session expires

Key point: There is no fixed session TTL enforced by the SDK. The session will survive as long as the WebSocket stays connected. However, if the WebSocket drops and reconnects after the JWT has expired, the reconnect will fail to authenticate, and the session will be torn down. A short JWT exp (e.g., 2 minutes) is the most common cause of calls dropping at ~10 minutes.

 

Best Practice 1 — Set a Sufficient JWT Lifetime

This is the most important and simplest fix. The JWT exp claim must be at least as long as the expected call duration.

Rule of thumb: Set exp to the expected maximum call duration, plus a buffer.

Use Case Recommended JWT Lifetime
Calls up to 15 minutes 30 minutes
Calls up to 30 minutes 1 hour
Calls up to 60 minutes 2 hours
Long-duration / open-ended calls Up to 24 hours (Vonage maximum from iat)

What to avoid:

  • Do not set exp to 2 minutes or any value shorter than the expected call duration. This is the primary cause of the ~10-minute disconnect issue.
  • The expiresIn value returned by your backend represents the JWT exp claim — it does not control the Vonage session lifetime. These are independent.

Example JWT payload (correct):

{

  "sub": "61435927450",

  "acl": { "paths": { "/*/rtc/**": {}, "/*/sessions/**": {} } },

  "iat": 1776667803,

  "exp": 1776675003

}

json

In this example, expiat = 2 hours — suitable for calls up to 60 minutes.

 

Best Practice 2 — Implement Proactive Session Refresh

Even with a sufficiently long JWT, it is recommended to implement a proactive session refresh using a client-side timer. This ensures the signaling channel stays alive for very long calls and handles edge cases where the WebSocket reconnects near the end of the JWT lifetime.

How It Works

Before the JWT expires, fetch a new JWT from your backend and call refreshSession(newJwt). This renews the signaling channel without interrupting the active WebRTC call.

Recommended refresh interval: Every 10 minutes, or at approximately 80% of the JWT lifetime — whichever is sooner.

Android (Kotlin) Example

private var refreshTimer: Timer? = null

fun startSessionRefreshTimer() {

    refreshTimer = Timer()

    refreshTimer?.scheduleAtFixedRate(object : TimerTask() {

        override fun run() {

            val newToken = fetchNewTokenFromBackend() // your backend call

            client.refreshSession(newToken)

        }

    }, REFRESH_INTERVAL_MS, REFRESH_INTERVAL_MS) // e.g. 600_000L for 10 minutes

}

fun stopSessionRefreshTimer() {

    refreshTimer?.cancel()

    refreshTimer = null

}

kotlin

iOS (Swift) Example

var refreshTimer: Timer?

func startSessionRefreshTimer() {

    refreshTimer = Timer.scheduledTimer(

        withTimeInterval: 600, // 10 minutes

        repeats: true

    ) { [weak self] _ in

        guard let self = self else { return }

        let newToken = self.fetchNewTokenFromBackend()

        self.client.refreshSession(newToken)

    }

}

func stopSessionRefreshTimer() {

    refreshTimer?.invalidate()

    refreshTimer = nil

}

swift

Important: Start the timer after a session is successfully created, and stop it when the call ends or the session is destroyed.

What refreshSession() Does and Does Not Do

Behaviour Detail
Renews the signaling WebSocket ✅ Yes
Preserves the active WebRTC call leg (audio, call ID) ✅ Yes — call state is managed independently
Affects mute state or audio ❌ No
Works after TokenExpired has already fired ❌ No — must be called before expiry

 

Best Practice 3 — Implement Graceful Error Recovery (onSessionError)

Proactive refresh covers the normal case. However, you must also handle the scenario where the refresh is missed or fails — for example, due to a network interruption or a backend error.

Do NOT Destroy Call State on TokenExpired

The most common application-level mistake is to tear down the call state (null the active call reference, stop the timer, destroy the Telecom/CallKit connection) when TokenExpired fires. This leaves the user on a frozen screen with no working controls, even though the WebRTC audio is still active.

Incorrect pattern (Android):

// ❌ DO NOT DO THIS

override fun onSessionError(sessionError: SessionError) {

    if (sessionError.reason == SessionErrorReason.TokenExpired) {

        activeCall = null       // destroys call state

        callTimer.stop()        // resets timer to 0:00

        telecomConnection.destroy() // freezes the screen

    }

}

kotlin

Correct pattern — reactive session re-creation:

// ✅ Correct approach

override fun onSessionError(sessionError: SessionError) {

    if (sessionError.reason == SessionErrorReason.TokenExpired) {

        // Do NOT destroy call state — WebRTC audio is still active

        // Fetch a new token and re-create the session

        val newToken = fetchNewTokenFromBackend()

        client.createSession(newToken) // re-establishes signaling channel

    }

}

kotlin

Why createSession() and Not refreshSession() After Expiry

Once onSessionError(TokenExpired) is delivered to the app, the SDK has already torn the session down internally. refreshSession() requires a live, active session — calling it after expiry will fail with a NO_ACTIVE_SESSION error. The correct recovery path after expiry is createSession(newToken).

Method When to Use
refreshSession(newJwt) Before TokenExpired fires — proactive refresh while session is alive
createSession(newToken) After TokenExpired fires — reactive recovery to re-establish signaling

 

Best Practice 4 — Restrict ACL Paths in the JWT

When generating JWTs for Client SDK users, restrict the ACL to only the paths required. Do not include paths such as /*/calls/** or /*/reports/** in end-user WebRTC tokens, as these allow the token holder to make outbound calls or access call history.

Recommended ACL for a Client SDK user:

{

  "acl": {

    "paths": {

      "/*/rtc/**": {},

      "/*/sessions/**": {},

      "/*/users/**": {},

      "/*/conversations/**": {},

      "/*/devices/**": {},

      "/*/image/**": {},

      "/*/media/**": {},

      "/*/knocking/**": {},

      "/*/legs/**": {}

    }

  }

}

json

 

Best Practice 5 — Enable Verbose Logging During Development and Testing

Enable verbose logging on the Client SDK during development and UAT. This provides detailed information from the client side (session lifecycle, WebSocket events, reconnect attempts) that is not visible in server-side logs.

Common Symptoms and Root Causes

Symptom Likely Root Cause
Call controls freeze after ~10 minutes Short JWT exp + application destroys call state on TokenExpired
Timer resets to 0:00 mid-call Application stops the call timer on TokenExpired
Call screen cannot be dismissed Application destroys Telecom/CallKit connection on TokenExpired
Remote hangup not detected after 10 min Signaling session expired — remote events no longer dispatched
NO_ACTIVE_SESSION error on refreshSession() refreshSession() called after TokenExpired has already fired
Disconnect at ~10 min despite 15-min documented TTL JWT exp is shorter than the session TTL — WebSocket reconnect fails to authenticate

Additional Information