> ## Documentation Index
> Fetch the complete documentation index at: https://docs.retellai.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Web calls in native apps

> Add Retell AI voice agents to iOS, Android, React Native, and Flutter apps over WebRTC: create the web call on your server, then connect from the app.

An iOS, Android, React Native, or Flutter app can talk to your Retell voice agent over WebRTC, with no browser and no phone number. Your server creates a [web call](https://docs.retellai.com/deploy/web-call.md), and the app connects to it with a standard WebRTC library using a short HTTP signaling exchange.

## When to use it

* **Support inside your app.** Add a "Talk to us" button to a help or order screen, so users reach the agent without leaving the app or dialing a number.
* **Voice-driven flows.** Let users book, reschedule, or check a status by voice, with the call controls and visuals in your own native UI.

In a browser, use the [Web SDK](https://docs.retellai.com/deploy/web-call.md) instead, since it handles all of this for you. To reach users on their phone number rather than in your app, see [outbound calls](https://docs.retellai.com/deploy/outbound-call.md).

For example, a pharmacy app adds a "Talk to us" button to its refill screen. The app asks its own backend for a call, the backend passes the patient's name and prescription ID as [dynamic variables](https://docs.retellai.com/build/dynamic-variables.md), and the agent can check the refill status during the conversation.

## How does a native web call work?

1. Your app asks your backend for a call.
2. Your backend calls [Create Web Call](https://docs.retellai.com/api-references/create-web-call.md) with your API key and returns the connection details to the app.
3. The app creates a WebRTC peer connection, adds the microphone, and opens a data channel named `control`.
4. The app posts its SDP offer to Retell and applies the SDP answer it gets back.
5. Audio flows directly between the app and Retell. The `control` channel reports when it opens and when the call ends.
6. The app ends the call by sending a `DELETE` request, or the agent ends it.

Signaling goes through `api.retellai.com` over HTTPS. Media is Opus audio over DTLS-SRTP, relayed through the TURN servers in `ice_servers` when the network requires it.

## Prerequisites

* A voice agent and its agent ID.

* A Retell [API key](https://docs.retellai.com/accounts/api-keys-overview.md) on your backend.

* A WebRTC library for your platform. The examples on this page use:

  | Platform | Library |
  | - | - |
  | iOS | [`stasel/WebRTC`](https://github.com/stasel/WebRTC) through Swift Package Manager |
  | Android | [`io.getstream:stream-webrtc-android`](https://github.com/GetStream/webrtc-android) |
  | React Native | [`react-native-webrtc`](https://github.com/react-native-webrtc/react-native-webrtc) and [`react-native-incall-manager`](https://github.com/react-native-webrtc/react-native-incall-manager) |
  | Flutter | [`flutter_webrtc`](https://pub.dev/packages/flutter_webrtc) and [`http`](https://pub.dev/packages/http) |

* On iOS, `NSMicrophoneUsageDescription` in `Info.plist`.

* On Android, these permissions in `AndroidManifest.xml`: `INTERNET`, `RECORD_AUDIO`, `MODIFY_AUDIO_SETTINGS`, and `ACCESS_NETWORK_STATE`. React Native apps that use `react-native-incall-manager` also need `WAKE_LOCK`. Without `ACCESS_NETWORK_STATE` or `WAKE_LOCK`, the app crashes when the call starts.

> [!WARNING]
>
> Create native web calls on your server with an API key. Never ship an API key inside an app, since anyone can extract it. [Public keys](https://docs.retellai.com/accounts/public-keys.md) don't help either: they limit calls to your allowed domains by checking the `Origin` or `Referer` header, which a native app can set to anything.

## Connect a native app

**1. Create the call on your server**

Add an endpoint your app can call. It creates the web call and returns the fields the app needs.

```javascript server.js theme={"dark"}
import express from "express";

const app = express();
app.use(express.json());

app.post("/voice-call", async (req, res) => {
  // Authenticate your user first: every call uses your minutes and concurrency.
  const response = await fetch("https://api.retellai.com/v3/create-web-call", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.RETELL_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      agent_id: "agent_oBeDLoLOeuAbiuaMFXRtDOLriTJ5tSxD",
      retell_llm_dynamic_variables: { customer_name: "John Doe" },
      metadata: { internal_customer_id: "cust_123" },
    }),
  });
  if (!response.ok) {
    return res.status(response.status).send(await response.text());
  }

  const call = await response.json();
  res.json({
    call_id: call.call_id,
    access_token: call.access_token,
    ice_servers: call.ice_servers,
  });
});

app.listen(3000);
```

Create Web Call returns these fields. The example forwards only the three the app needs.

| Field | What it's for |
| - | - |
| `call_id` | Builds the signaling URL. Also identifies the call in [webhooks](https://docs.retellai.com/features/register-webhook.md) and [Get Call](https://docs.retellai.com/api-references/get-call.md). |
| `access_token` | Sent as `Authorization: Bearer <access_token>` on every signaling request. It works only for this call. |
| `ice_servers` | Passed to the peer connection configuration. Includes TURN credentials issued for this call. |
| `expires_at` | Unix time in milliseconds when `access_token` expires, 130 minutes after creation (as of October 2026). |
| `transport` | Always `gateway` for calls created with v3. |

Connect right after creating the call. If the app doesn't connect within 5 minutes, the call ends with the disconnection reason `error_user_not_joined` (as of October 2026).

**2. Create the peer connection**

Configure the peer connection with the `ice_servers` from your backend, add a microphone audio track, and create a data channel labeled `control` before you create the offer. The data channel must exist in the first offer, or the call has no event channel.

Put the device in voice-call audio mode: `playAndRecord` with the `voiceChat` mode on iOS, and `MODE_IN_COMMUNICATION` on Android. This turns on the device's echo cancellation, so the agent doesn't hear itself.

| Library | How to set the audio mode |
| - | - |
| iOS and Android SDKs | Set it yourself, as in the examples below. |
| React Native | `react-native-webrtc` sets it on iOS. On Android, call `InCallManager.start({ media: "audio" })`. |
| Flutter | `flutter_webrtc` sets it on both platforms. |

**3. Send the offer**

Create an offer and set it as the local description. Wait 1 second for ICE gathering so the offer contains your candidates, then post the current local description. Don't wait for gathering to finish without a limit: in our tests on the iOS Simulator and Android Emulator over Wi-Fi, every candidate arrived within 200 ms, but gathering didn't report complete until 5 seconds later. To connect even sooner, use [trickle ICE](#trickle-ice).

```http theme={"dark"}
POST https://api.retellai.com/webrtc-proxy/{call_id}/v1/webrtc/sessions
Authorization: Bearer {access_token}
Content-Type: application/json

{"identity": "client", "sdp": "v=0\r\no=- 4611731400430051336 2 IN IP4 127.0.0.1\r\n..."}
```

`identity` must be `client`, the identity the access token was issued for. A successful response is `201 Created`:

```json theme={"dark"}
{
  "session_id": "3f0c1b8e-6f7a-4d2e-9b1a-8c5d2e7f4a10",
  "sdp": "v=0\r\no=- 2913415325 1730000000 IN IP4 0.0.0.0\r\n..."
}
```

Set `sdp` as the remote description with type `answer`. The answer already contains all of Retell's candidates, so there are none to wait for. Keep `session_id`; you need it to end the call.

If the peer connection doesn't reach `connected` within 15 seconds of the `POST`, Retell closes the session (as of October 2026).

**4. Handle call events**

Read JSON messages from the `control` data channel:

| Message | Meaning |
| - | - |
| `{"type":"status","state":"connected"}` | The control channel is open. The agent can join a moment later. |
| `{"type":"status","state":"ended"}` | The call ended on Retell's side, for example because the agent hung up. Close the peer connection. |
| `{"type":"status","state":"replaced"}` | Another connection joined with the same access token and took over this call. Close this one. |
| `{"type":"pong","ts":1730000000000}` | Reply to a `ping`, echoing its `ts`. |

Also watch the peer connection state, since some endings send no message. If it reaches `failed` or `closed`, the call is over. If Retell's side of the connection stays disconnected for 5 seconds, Retell ends the call (as of October 2026); see [reconnect after a network change](#reconnect-after-a-network-change).

You can send these messages on the same channel:

| Message | What it does |
| - | - |
| `{"type":"mute","muted":true}` | Retell ignores the app's audio until you send `"muted":false`. |
| `{"type":"dtmf","digit":"5"}` | Sends a keypad digit to the agent. |
| `{"type":"ping","ts":1730000000000}` | Retell answers with `pong`. Use it to measure round-trip time. |
| `{"type":"hangup"}` | Ends the call. No `ended` message follows, so close the peer connection yourself. |

To mute, you can also disable the local audio track; either way the call stays connected.

**5. End the call**

```http theme={"dark"}
DELETE https://api.retellai.com/webrtc-proxy/{call_id}/v1/webrtc/sessions/{session_id}
Authorization: Bearer {access_token}
```

Retell returns `204 No Content`, including when the call has already ended, or `401` if the access token has expired. Close the peer connection and release the microphone too. The examples close locally before sending the `DELETE`, so the audio stops right away.

## Platform examples

Each example shows the core of a call: `connect()` takes the `call_id`, `access_token`, and `ice_servers` from your backend endpoint, and `hangUp()` ends the call. App plumbing, such as calling your backend and converting `ice_servers` into the library's type, is left out.

The examples are trimmed from apps tested on the iOS Simulator and Android Emulator with `stasel/WebRTC` 156.0.0, `stream-webrtc-android` 1.3.10, `react-native-webrtc` 155.0.1 on React Native 0.88, and `flutter_webrtc` 1.6.3 (as of October 2026).

```swift iOS (Swift) theme={"dark"}
import WebRTC // https://github.com/stasel/WebRTC

// Create a new RetellCall for each call; don't reuse one after hangUp().
final class RetellCall: NSObject {
  private let factory = RTCPeerConnectionFactory()
  private var peerConnection: RTCPeerConnection?
  private var control: RTCDataChannel? // keep a reference, or no events arrive
  private var sessionsURL: URL!
  private var accessToken = ""
  private var sessionId = ""

  // callId, accessToken, and iceServers come from your backend endpoint.
  func connect(callId: String, accessToken: String, iceServers: [RTCIceServer]) async throws {
    // Voice-call audio mode turns on echo cancellation.
    let audio = RTCAudioSession.sharedInstance()
    do {
      audio.lockForConfiguration()
      defer { audio.unlockForConfiguration() }
      try audio.setCategory(.playAndRecord, with: [.allowBluetooth, .defaultToSpeaker])
      try audio.setMode(.voiceChat)
      try audio.setActive(true)
    }

    // Peer connection with a mic track and the control channel.
    let config = RTCConfiguration()
    config.sdpSemantics = .unifiedPlan
    config.iceServers = iceServers
    let constraints = RTCMediaConstraints(mandatoryConstraints: nil, optionalConstraints: nil)
    let pc = factory.peerConnection(with: config, constraints: constraints, delegate: self)!
    let mic = factory.audioTrack(with: factory.audioSource(with: constraints), trackId: "mic")
    pc.add(mic, streamIds: ["mic"])
    control = pc.dataChannel(forLabel: "control", configuration: RTCDataChannelConfiguration())
    control?.delegate = self
    peerConnection = pc

    do {
      // Offer, give ICE gathering 1 second, post it, apply the answer.
      try await pc.setLocalDescription(try await pc.offer(for: constraints))
      try await Task.sleep(nanoseconds: 1_000_000_000)
      guard peerConnection === pc else { return } // hung up while waiting

      self.accessToken = accessToken
      sessionsURL = URL(string: "https://api.retellai.com/webrtc-proxy/\(callId)/v1/webrtc/sessions")!
      let answer = try await request("POST", sessionsURL, body: ["identity": "client", "sdp": pc.localDescription!.sdp])
      guard let id = answer["session_id"], let sdp = answer["sdp"] else { throw URLError(.badServerResponse) }
      sessionId = id
      guard peerConnection === pc else { // hung up during the POST
        _ = try? await request("DELETE", sessionsURL.appendingPathComponent(id))
        return
      }
      try await pc.setRemoteDescription(RTCSessionDescription(type: .answer, sdp: sdp))
    } catch {
      close()
      throw error
    }
  }

  func hangUp() async {
    close()
    guard !sessionId.isEmpty else { return }
    _ = try? await request("DELETE", sessionsURL.appendingPathComponent(sessionId))
  }

  func close() {
    guard let pc = peerConnection else { return } // pc.close() calls back into close()
    peerConnection = nil
    pc.close()
  }

  private func request(_ method: String, _ url: URL, body: [String: String]? = nil) async throws -> [String: String] {
    var request = URLRequest(url: url)
    request.httpMethod = method
    request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
    if let body {
      request.setValue("application/json", forHTTPHeaderField: "Content-Type")
      request.httpBody = try JSONSerialization.data(withJSONObject: body)
    }
    let (data, _) = try await URLSession.shared.data(for: request)
    return (try? JSONSerialization.jsonObject(with: data) as? [String: String]) ?? [:]
  }
}

extension RetellCall: RTCDataChannelDelegate {
  func dataChannel(_ channel: RTCDataChannel, didReceiveMessageWith buffer: RTCDataBuffer) {
    let message = try? JSONSerialization.jsonObject(with: buffer.data) as? [String: Any]
    if let state = message?["state"] as? String, state == "ended" || state == "replaced" {
      DispatchQueue.main.async { self.close() }
    }
  }

  func dataChannelDidChangeState(_ channel: RTCDataChannel) {}
}

// Also adopt RTCPeerConnectionDelegate and call close() when the
// connection state becomes .failed or .closed.
```

```kotlin Android (Kotlin) theme={"dark"}
// implementation("io.getstream:stream-webrtc-android:1.3.10")
// implementation("com.squareup.okhttp3:okhttp:4.12.0")
// Create a new RetellCall for each call; don't reuse one after hangUp().
class RetellCall(private val context: Context) {
  private val http = OkHttpClient()
  private val closer = Executors.newSingleThreadExecutor()
  private var peerConnection: PeerConnection? = null
  private var sessionsUrl = ""
  private var accessToken = ""
  private var sessionId = ""

  // callId, accessToken, and iceServers come from your backend endpoint.
  // Call after the user grants RECORD_AUDIO.
  suspend fun connect(callId: String, accessToken: String, iceServers: List<PeerConnection.IceServer>) {
    // Voice-call audio mode turns on echo cancellation.
    val audio = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
    audio.mode = AudioManager.MODE_IN_COMMUNICATION

    // Peer connection with a mic track and the control channel.
    PeerConnectionFactory.initialize(
      PeerConnectionFactory.InitializationOptions.builder(context).createInitializationOptions()
    )
    val factory = PeerConnectionFactory.builder().createPeerConnectionFactory()
    val config = PeerConnection.RTCConfiguration(iceServers)
    config.sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN
    val pc = factory.createPeerConnection(config, observer)!!
    pc.addTrack(factory.createAudioTrack("mic", factory.createAudioSource(MediaConstraints())), listOf("mic"))
    pc.createDataChannel("control", DataChannel.Init()).registerObserver(controlObserver)
    peerConnection = pc

    try {
      // Offer, give ICE gathering 1 second, post it, apply the answer.
      val offer = sdp { pc.createOffer(it, MediaConstraints()) }!!
      sdp { pc.setLocalDescription(it, offer) }
      delay(1_000)
      if (peerConnection !== pc) return // hung up while waiting

      this.accessToken = accessToken
      sessionsUrl = "https://api.retellai.com/webrtc-proxy/$callId/v1/webrtc/sessions"
      val body = JSONObject().put("identity", "client").put("sdp", pc.localDescription.description)
      val answer = JSONObject(request(sessionsUrl, "POST", body.toString()))
      sessionId = answer.getString("session_id")
      if (peerConnection !== pc) { // hung up during the POST
        runCatching { request("$sessionsUrl/$sessionId", "DELETE", null) }
        return
      }
      val remote = SessionDescription(SessionDescription.Type.ANSWER, answer.getString("sdp"))
      sdp { pc.setRemoteDescription(it, remote) }
    } catch (e: Exception) {
      close()
      throw e
    }
  }

  suspend fun hangUp() {
    close()
    if (sessionId.isNotEmpty()) runCatching { request("$sessionsUrl/$sessionId", "DELETE", null) }
  }

  @Synchronized
  private fun close() {
    val pc = peerConnection ?: return // pc.close() calls back into close()
    peerConnection = null
    pc.close()
    (context.getSystemService(Context.AUDIO_SERVICE) as AudioManager).mode = AudioManager.MODE_NORMAL
  }

  private suspend fun request(url: String, method: String, json: String?): String =
    withContext(Dispatchers.IO) {
      val body = json?.toRequestBody("application/json".toMediaType())
      val request = Request.Builder().url(url).method(method, body)
        .header("Authorization", "Bearer $accessToken").build()
      http.newCall(request).execute().use { response ->
        check(response.isSuccessful) { "HTTP ${response.code}" }
        response.body!!.string()
      }
    }

  // Wraps WebRTC's callback-style SDP calls.
  private suspend fun sdp(call: (SdpObserver) -> Unit): SessionDescription? =
    suspendCoroutine { cont ->
      call(object : SdpObserver {
        override fun onCreateSuccess(sdp: SessionDescription) = cont.resume(sdp)
        override fun onSetSuccess() = cont.resume(null)
        override fun onCreateFailure(error: String?) = cont.resumeWithException(IllegalStateException(error))
        override fun onSetFailure(error: String?) = cont.resumeWithException(IllegalStateException(error))
      })
    }

  private val controlObserver = object : DataChannel.Observer {
    override fun onMessage(buffer: DataChannel.Buffer) {
      val bytes = ByteArray(buffer.data.remaining()).also { buffer.data.get(it) }
      val state = JSONObject(String(bytes)).optString("state")
      // Never close the peer connection on a WebRTC callback thread.
      if (state == "ended" || state == "replaced") closer.execute { close() }
    }
    override fun onBufferedAmountChange(previousAmount: Long) {}
    override fun onStateChange() {}
  }

  // Also define `observer`, a PeerConnection.Observer whose onConnectionChange
  // calls closer.execute { close() } on FAILED or CLOSED.
}
```

```javascript React Native theme={"dark"}
// npm install react-native-webrtc react-native-incall-manager
import { RTCPeerConnection, mediaDevices } from "react-native-webrtc";
import InCallManager from "react-native-incall-manager";

// Create a new RetellCall for each call; don't reuse one after hangUp().
export class RetellCall {
  // callId, accessToken, and iceServers come from your backend endpoint.
  async connect(callId, accessToken, iceServers) {
    // Voice-call audio mode turns on echo cancellation.
    InCallManager.start({ media: "audio" });

    // Peer connection with a mic track and the control channel.
    const pc = new RTCPeerConnection({ iceServers });
    this.pc = pc;
    try {
      pc.addEventListener("connectionstatechange", () => {
        if (pc.connectionState === "failed" || pc.connectionState === "closed") this.close();
      });
      this.mic = await mediaDevices.getUserMedia({ audio: true, video: false });
      if (this.pc !== pc) return this.mic.getTracks().forEach((track) => track.stop());
      this.mic.getAudioTracks().forEach((track) => pc.addTrack(track, this.mic));
      pc.createDataChannel("control").addEventListener("message", (event) => {
        const { state } = JSON.parse(event.data);
        if (state === "ended" || state === "replaced") this.close();
      });

      // Offer, give ICE gathering 1 second, post it, apply the answer.
      await pc.setLocalDescription(await pc.createOffer());
      await new Promise((resolve) => setTimeout(resolve, 1000));
      if (this.pc !== pc) return; // hung up while waiting

      this.sessionsUrl = `https://api.retellai.com/webrtc-proxy/${callId}/v1/webrtc/sessions`;
      this.auth = { Authorization: `Bearer ${accessToken}` };
      const res = await fetch(this.sessionsUrl, {
        method: "POST",
        headers: { ...this.auth, "Content-Type": "application/json" },
        body: JSON.stringify({ identity: "client", sdp: pc.localDescription.sdp }),
      });
      if (res.status !== 201) throw new Error(`Signaling error ${res.status}`);
      const { session_id, sdp } = await res.json();
      this.sessionId = session_id;
      if (this.pc !== pc) return this.hangUp(); // hung up during the POST
      await pc.setRemoteDescription({ type: "answer", sdp });
    } catch (error) {
      this.close();
      throw error;
    }
  }

  async hangUp() {
    this.close();
    if (!this.sessionId) return;
    await fetch(`${this.sessionsUrl}/${this.sessionId}`, { method: "DELETE", headers: this.auth })
      .catch(() => {});
  }

  close() {
    const pc = this.pc;
    if (!pc) return; // pc.close() calls back into close()
    this.pc = undefined;
    this.mic?.getTracks().forEach((track) => track.stop());
    pc.close();
    InCallManager.stop();
  }
}
```

```dart Flutter theme={"dark"}
// flutter pub add flutter_webrtc http
import 'dart:convert';

import 'package:flutter_webrtc/flutter_webrtc.dart';
import 'package:http/http.dart' as http;

// Create a new RetellCall for each call; don't reuse one after hangUp().
class RetellCall {
  RTCPeerConnection? _pc;
  MediaStream? _mic;
  String _sessionsUrl = '';
  Map<String, String> _auth = {};
  String _sessionId = '';

  // callId, accessToken, and iceServers come from your backend endpoint.
  // flutter_webrtc turns on the voice-call audio mode by itself.
  Future<void> connect(String callId, String accessToken, List<dynamic> iceServers) async {
    // Peer connection with a mic track and the control channel.
    final pc = await createPeerConnection({'iceServers': iceServers, 'sdpSemantics': 'unified-plan'});
    _pc = pc;
    try {
      pc.onConnectionState = (state) {
        if (state == RTCPeerConnectionState.RTCPeerConnectionStateFailed ||
            state == RTCPeerConnectionState.RTCPeerConnectionStateClosed) {
          close();
        }
      };
      final mic = await navigator.mediaDevices.getUserMedia({'audio': true, 'video': false});
      _mic = mic;
      if (_pc != pc) {
        for (final track in mic.getTracks()) {
          await track.stop();
        }
        return;
      }
      for (final track in mic.getAudioTracks()) {
        await pc.addTrack(track, mic);
      }
      final control = await pc.createDataChannel('control', RTCDataChannelInit());
      control.onMessage = (message) {
        final state = jsonDecode(message.text)['state'];
        if (state == 'ended' || state == 'replaced') close();
      };

      // Offer, give ICE gathering 1 second, post it, apply the answer.
      await pc.setLocalDescription(await pc.createOffer());
      await Future.delayed(const Duration(seconds: 1));
      if (_pc != pc) return; // hung up while waiting

      _sessionsUrl = 'https://api.retellai.com/webrtc-proxy/$callId/v1/webrtc/sessions';
      _auth = {'Authorization': 'Bearer $accessToken'};
      final local = await pc.getLocalDescription();
      final res = await http.post(
        Uri.parse(_sessionsUrl),
        headers: {..._auth, 'Content-Type': 'application/json'},
        body: jsonEncode({'identity': 'client', 'sdp': local!.sdp}),
      );
      if (res.statusCode != 201) throw Exception('Signaling error ${res.statusCode}');
      final answer = jsonDecode(res.body);
      _sessionId = answer['session_id'];
      if (_pc != pc) return await hangUp(); // hung up during the POST
      await pc.setRemoteDescription(RTCSessionDescription(answer['sdp'], 'answer'));
    } catch (_) {
      await close();
      rethrow;
    }
  }

  Future<void> hangUp() async {
    await close();
    if (_sessionId.isEmpty) return;
    try {
      await http.delete(Uri.parse('$_sessionsUrl/$_sessionId'), headers: _auth);
    } catch (_) {}
  }

  Future<void> close() async {
    final pc = _pc;
    if (pc == null) return; // pc.close() calls back into close()
    _pc = null;
    for (final track in _mic?.getTracks() ?? <MediaStreamTrack>[]) {
      await track.stop();
    }
    await pc.close();
  }
}
```

Remote audio plays through the device's audio output automatically in all four libraries; you don't need to attach the incoming track to a player.

### Common pitfalls

* **`urls` can be a string or an array.** When you convert `ice_servers` for the iOS or Android SDK, handle both forms. React Native and Flutter accept either.
* **Cap the wait for ICE gathering.** Gathering can take 5 seconds to report complete even though the candidates arrive within 200 ms. Wait 1 second, or use [trickle ICE](#trickle-ice). If calls fail on networks that block UDP, switch to trickle ICE, since the TURN-over-TLS candidate can arrive after the wait.
* **Make `close()` safe to call twice.** Closing the peer connection fires its state callback, which calls `close()` again. Clear your reference before you close it.
* **Clean up when `connect()` fails or the user hangs up mid-connect.** Close the peer connection on any error. After each wait, such as the microphone prompt, the ICE wait, or the `POST`, check that the user didn't hang up in the meantime, and release what you just acquired if they did.
* **On Android, don't close the peer connection on a WebRTC callback thread.** It can deadlock. Hand the close off to another thread, as the Kotlin example does.

## Signaling reference

All signaling requests go to `https://api.retellai.com/webrtc-proxy/{call_id}/v1/webrtc/sessions` and carry `Authorization: Bearer {access_token}`.

| Request | Body | Success |
| - | - | - |
| `POST /sessions` | JSON: `identity` (`client`) and `sdp` (your offer). | `201` with JSON `session_id` and `sdp` (the answer). |
| `PATCH /sessions/{session_id}` | JSON `{"candidate": {...}}` to add one of your ICE candidates. | `204` |
| `PATCH /sessions/{session_id}` | `Content-Type: application/sdp` with a new offer, to renegotiate. | `200` with the SDP answer as the body. |
| `DELETE /sessions/{session_id}` | None. | `204`, also when the session no longer exists. |

### Trickle ICE

Waiting briefly for ICE gathering before the `POST` is simplest. To connect as fast as possible, post the offer right away and send each later candidate with `PATCH` once you have the `session_id`; in the same tests, this connected in under 100 ms. Queue candidates that arrive before the `POST` returns, then send them in order. The fields are `candidate`, `sdpMid`, and `sdpMLineIndex`, as in the standard `RTCIceCandidateInit`.

### Errors

| Status | Cause | What to do |
| - | - | - |
| `400` | Missing `identity` or `sdp`, or an offer Retell can't accept. | Check that the offer has an audio track and is sent as `sdp`. |
| `401` | The `Authorization` header is missing, or the access token is invalid or expired. | Send `Bearer {access_token}` from the current call. After `expires_at`, create a new call. |
| `403` | The token doesn't match this call, session, or `identity`. | Use the `access_token` and `call_id` from the same call, with `identity` set to `client`. |
| `404` | No live web call has this `call_id`, or, on `PATCH`, no session has this `session_id`. | For an unknown call, create a new call. For an unknown session on a live call, `POST` a new offer with the same `access_token`. |
| `409` | A renegotiation is already in progress, or the `identity` is in use by a different kind of participant. | Wait for the renegotiation to finish, then retry. Check that `identity` is `client`. |
| `429` | More than 300 signaling requests per minute from one IP address (as of October 2026). | Back off and retry. |
| `502`, `503` | Retell's media server is unreachable, didn't answer within 10 seconds, or is at capacity. | Retry after a short delay. |

## Reconnect after a network change

When the app switches networks, the peer connection goes `disconnected`. Reconnect right away: create a new peer connection, `POST` a new offer with the same `call_id` and `access_token`, then close the old peer connection. The new connection takes over the call. If the old connection is still reachable, it also receives `{"type":"status","state":"replaced"}`.

If Retell's side of the connection stays disconnected for 5 seconds (as of October 2026), Retell ends the call, and a later reconnect fails. Create a new call instead.

## After the call

* [Register a webhook](https://docs.retellai.com/features/register-webhook.md) to receive `call_started`, `call_ended`, and `call_analyzed` on your server.
* Use the `call_id` with [Get Call](https://docs.retellai.com/api-references/get-call.md) for the transcript, recording, and analysis, or review the call in [session history](https://docs.retellai.com/features/session-history.md).
* For live captions in the app, have your backend connect to the [monitoring WebSocket](https://docs.retellai.com/api-references/monitor-call-websocket.md) with your API key and relay the transcript to the app.

## FAQ

> **Is there a Retell SDK for iOS or Android?**
>
> Not a dedicated one (as of October 2026). Native apps use a standard WebRTC library with the signaling described on this page. The browser [Web SDK](https://docs.retellai.com/deploy/web-call.md) uses the same protocol.

> **Can I use a public key from my app?**
>
> No. Public keys limit calls to your allowed domains through the `Origin` or `Referer` header. A browser sets those headers itself, but a native app can send any value, so the check doesn't protect calls from an app. Create the call on your backend with an API key and send the app only the `call_id`, `access_token`, and `ice_servers`.

> **Why does my Android app crash when the call starts?**
>
> Check `AndroidManifest.xml` for `ACCESS_NETWORK_STATE`, which WebRTC needs to monitor the network, and for `WAKE_LOCK` if you use `react-native-incall-manager`. Logcat shows a `SecurityException` naming the missing permission just before the crash.

> **Why can't the user hear the agent?**
>
> Check that the audio session is active in a voice-call mode (`playAndRecord` with `voiceChat` on iOS, `MODE_IN_COMMUNICATION` on Android) and that the output route is the speaker or headset you expect.

> **Why does the agent hear itself and keep interrupting?**
>
> Echo cancellation is off. Use the voice-call audio modes above, which turn on the device's echo cancellation.

> **Why does the connection fail on corporate or mobile networks?**
>
> Make sure you pass all of `ice_servers` to the peer connection, including the TURN entries and their `username` and `credential`. TURN over TLS on port 443 reaches networks that block UDP.

> **Do native web calls count toward my concurrency limit?**
>
> Yes. They are web calls and draw from the same [concurrency](https://docs.retellai.com/deploy/concurrency.md) pool as other calls.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.