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, exp − iat = 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.
- Android/Kotlin: https://developer.vonage.com/en/vonage-client-sdk/configure-logging-level?source=vonage-client-sdk&lang=kotlin
- iOS/Swift: https://developer.vonage.com/en/vonage-client-sdk/configure-logging-level?source=vonage-client-sdk&lang=swift
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
- Vonage Sessions documentation: https://developer.vonage.com/en/vonage-client-sdk/sessions
- Reference implementation (Android): https://github.com/Vonage-Community/reference-client_sdk-ios-android-js-node-deno-usecases/tree/main/contact-center/android-voice
- Reference implementation (iOS): https://github.com/Vonage-Community/reference-client_sdk-ios-android-js-node-deno-usecases/tree/main/contact-center/ios-voice
Articles in this section
- Session failed due to a timeout expiration
- Does Deleting a User Remove Their Registered Device?
- Voice Client SDK — Best Practices for Long-Duration Calls
- Unable to initialize Client SDK on Android App API Level 31
- User:Error:Not-Found Username Doesn’t Exist When Logging in or Connecting Calls to an App User
- Why do I receive an answered event when I start an outbound call?
- Outbound In-App Voice Calls Fail to Complete
- Calls to an App User Don’t Time Out