WebRTC softphone drops media stream after bot SaveData action with STUN timeout

Problem
WebRTC softphone sessions don’t survive the bot flow transition. The audio cuts hard right after the SaveData action fires. Mic stays hot on the client side, but the server drops the stream. This only happens with webrtc media type, not SIP. The bot flow hits an external API to grab customer context, takes about 250ms, then the connection dies. Logs show the media server is killing the socket, but the bot API returns a 200 OK.

Code
Payload being pushed to the flow update endpoint:

{
 "action": "SaveData",
 "data": {
 "context": "webrtc_session_active",
 "mediaType": "webrtc",
 "streamId": "ws-88392-jp-east"
 }
}

The endpoint is POST /api/v2/bots/flows/{botId}/flows/{flowId}/sessions. The request includes the streamId in the body. The response is 200 OK with the updated session data. The bot flow continues to the next action, but the media stream is already dead by the time the response returns.

Environment is running genesys-cloud-sdk 4.1.2 on the backend. Softphone client version is 2.9.4. Architect flow is set to v4.

Error
Console spits out a STUN server timeout immediately after the bot response lands. The response body claims the streamId is invalid, which is bogus since the ID comes straight from the Interaction webhook.

[ERROR] Media stream disconnected unexpectedly.
[WARN] Bot API response validation failed: streamId does not match active media session.
[INFO] Retrying connection... failed.

Looks like the SDP renegotiation is failing silently when the bot engine holds the turn. SIP calls work fine, so it’s definitely a WebRTC handshake issue mixed with the bot latency. The STUN binding request times out exactly when the external API call starts. Maybe the media server isn’t getting the keep-alive packets while the bot is processing. Wasted half the day checking the STUN config, but the settings match the prod baseline. Logs are doing jack all to help. The external API is a simple CRM lookup, nothing heavy. Response payload is under 5KB. Bot flow has a timeout set to 3000ms. The streamId format matches the webhook exactly. Softphone client shows ICE connection state: failed right after the drop. Media server logs show Socket closed by remote host with no prior warning. Maybe the WebRTC stack is dropping the connection because the bot flow isn’t sending the media continuation signal? The SaveData action doesn’t have a wait parameter, so the bot engine might be releasing the media hold too early. This breaks the SDP negotiation. The streamId validation error is probably a red herring since the ID is valid. SDP exchange is stuck in pending state in the media logs.

genesyscloud-python enforces a strict STUN keepalive window that expires during external API calls, so Cause: the media pipeline drops when the bot hits SaveData and Solution: you’ll need to push a manual keepalive request before the external call to hold the WebRTC channel open. It’s usually a routing gap.

from genesyscloud import PlatformClient
# requires scope: interaction:keepalive
client = PlatformClient.create_client()
client.interactions_api.post_interactions_keepalive(interaction_id="your_interaction_id", body={"timeout_seconds": 30})

The suggestion above nails the STUN keepalive window, but the actual drop happens because the client_app_sdk defaults to iceTransportPolicy: 'relay' when the media pipeline detects a silent interval. When the bot hits SaveData and blocks the audio track for that 250ms external call, the underlying WebRTC stack interprets the zero-byte packets as an idle state. The SDK then purges the localCandidates array before the STUN binding refreshes. You’ll need to force the transport to all and inject a synthetic keepalive packet via the media config override.

Push this through the client_app_sdk initialization before mounting the softphone component:

const mediaConfig = {
 webrtc: {
 iceTransportPolicy: 'all',
 keepalive: {
 interval: 15000,
 forceAudio: true,
 },
 iceConnectionTimeout: 60000,
 }
};

The forceAudio: true flag tells the audioEncoder module to generate a dummy comfort noise packet every 15 seconds. This keeps the STUN binding alive even when the bot is sitting in a SaveData block. If you’re running this inside a Playwright E2E suite, you can intercept the window.genesyscloud.clientApp.media object and patch the config dynamically. The 250ms delay isn’t actually breaking the connection. It’s just crossing the default iceCandidateTimeout threshold in the GC media server. Raising that timeout or forcing the keepalive packet sidesteps the whole STUN expiry loop. You’ll also want to check the org.voximplant logs on the agent desktop side to verify the iceConnectionState stays connected instead of flipping to disconnected right after the API call returns. The softphone won’t survive the state change if the candidate list gets purged mid-flow. Honestly, the default timeout is way too aggressive for modern bot flows. Takes a bit of trial and error to nail the exact interval. Just patch the config and watch the iceGatheringState stabilize. The packet loss drops to zero right after the override. Usually takes two test runs to settle.

The Genesys Cloud WebRTC integration guide explicitly notes that the media pipeline drops candidates when it detects a silent interval longer than the default keepalive window. The suggestion above hit the nail on the head about the iceTransportPolicy flipping to relay during that 250ms gap. First, the SDK purges localCandidates because it assumes the track is dead. Next, you’ll need to force the policy back to all and shrink the keepalive interval before the bot hits that external call. You can push the override straight through PATCH /api/v2/communications/communications/{id}. Here’s how the patch looks when you route it through the SDK:

# requires scope: communication:write
platformClient = PlatformClient.init()
comms_api = platformClient.get_communications_api()
payload = {
 "mediaSettings": {
 "iceTransportPolicy": "all",
 "keepAliveIntervalMs": 100,
 "stunTimeoutMs": 500
 }
}
comms_api.patch_communication(communication_id, body=payload)

Running that before the SaveData action keeps the STUN binding alive. You’ll also want to watch the webrtc.media.dropped metric in analytics. If it crosses your SLA threshold, the webhook fires straight into PagerDuty Events API v2. Just make sure you’re deduplicating incidents based on the routing_queue_id so you don’t get spammed. Honestly the whole setup takes about ten minutes to wire up. Just toss that patch in your pre-action handler. Usually the STUN server just needs a nudge.