Native iOS WKWebView Integration Guide

This guide explains how to embed the Talkdesk Chat Widget JS SDK in a native iOS app using WKWebView.

The recommended integration uses a small HTML bridge page. The native app loads that bridge page in a WKWebView, injects the Talkdesk configuration, and receives chat lifecycle events through the iOS JavaScript bridge.

Download Sample Code

Download the native iOS example files and bridge HTML used by this guide.

Download ZIP

What You Will Build

Integration Architecture
Customer iOS App
Swift WKWebView Host
  • Create WKWebView
  • Inject config via JavaScript
  • Receive SDK events
HTML Bridge Page
  • Load SDK from CDN
  • Call initTalkdeskChat(config)
  • Send events to native

The native app is responsible for:

The bridge page is responsible for:

Requirements

Use HTTPS in production. Avoid loading the SDK or bridge page from arbitrary or user-controlled URLs.

Step 1: Create the Bridge HTML Page

Create a file named talkdesk-chat-bridge.html and host it on your own HTTPS domain.

Example:


https://your-company.example.com/talkdesk-chat-bridge.html

Use this bridge page as a starting point:


<!doctype html>

<html lang="en">

  <head>

    <meta charset="UTF-8" />

    <meta

      name="viewport"

      content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"

    />

    <style>

      * {

        margin: 0;

        padding: 0;

        box-sizing: border-box;

      }



      html,

      body {

        width: 100%;

        height: 100%;

        overflow: hidden;

        padding-bottom: env(safe-area-inset-bottom, 0px);

      }

    </style>

  </head>

  <body>

    <script>

      var containerId = 'tdWebchat'

      var webchat

      var sdkScript



      function sendToNative(type, payload) {

        var message = {

          type: type,

          payload: payload || null,

          timestamp: new Date().toISOString()

        }



        if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.sdkEvent) {

          window.webkit.messageHandlers.sdkEvent.postMessage(message)

        }

      }



      window.initTalkdeskChat = function (config) {

        var container = document.getElementById(containerId)



        if (!container) {

          container = document.createElement('div')

          container.id = containerId

          document.body.appendChild(container)

        }



        if (sdkScript) {

          return

        }



        sdkScript = document.createElement('script')

        sdkScript.type = 'text/javascript'

        sdkScript.charset = 'UTF-8'

        sdkScript.src = config.scriptURL || 'https://talkdeskchatsdk.talkdeskapp.com/v2/talkdeskchatsdk.js'

        sdkScript.async = true



        sdkScript.onload = function () {

          webchat = TalkdeskChatSDK(containerId, {

            touchpointId: config.touchpointId,

            accountId: '',

            region: config.region

          })



          webchat.onOpenWebchat = function () {

            sendToNative('onOpenWebchat')

          }



          webchat.onCloseWebchat = function () {

            sendToNative('onCloseWebchat')

          }



          webchat.onConversationStart = function () {

            sendToNative('onConversationStart')

          }



          webchat.onConversationEnded = function () {

            sendToNative('onConversationEnded')

          }



          webchat.onConversationClear = function () {

            sendToNative('onConversationClear')

          }



          webchat

            .init({

              enableEmoji: config.enableEmoji !== undefined ? config.enableEmoji : true,

              enableAttachments: config.enableAttachments !== undefined ? config.enableAttachments : true,

              enableValidation: config.enableValidation !== undefined ? config.enableValidation : true,

              enableResponsiveLayout: true,

              enableSoundNotification:

                config.enableSoundNotification !== undefined ? config.enableSoundNotification : true,

              enableUserInput: config.enableUserInput !== undefined ? config.enableUserInput : true,

              enableChatHeader: config.enableChatHeader !== undefined ? config.enableChatHeader : true,

              languageCode: config.languageCode !== undefined ? config.languageCode : ''

            })

            .then(function (result) {

              if (result) {

                sendToNative('sdkReady')

              } else {

                sendToNative('error', { message: 'Talkdesk Chat SDK initialization returned false' })

              }

            })

            .catch(function (error) {

              sendToNative('error', { message: error && error.message ? error.message : String(error) })

            })

        }



        sdkScript.onerror = function () {

          sendToNative('error', { message: 'Failed to load Talkdesk Chat SDK script' })

        }



        document.head.appendChild(sdkScript)

      }



      window.openTalkdeskChat = function () {

        if (webchat && webchat.open) {

          webchat.open()

        }

      }



      window.onerror = function (message, url, line) {

        sendToNative('error', { message: message, url: url, line: line })

      }

    </script>

  </body>

</html>

Important details:

Step 2: Add iOS Permissions

If chat attachments are enabled, your app must declare photo, camera, and microphone permissions.

Add these keys to Info.plist:


<key>NSPhotoLibraryUsageDescription</key>

<string>Allow access to your photo library to upload attachments in chat.</string>

<key>NSCameraUsageDescription</key>

<string>Allow access to your camera to take photos for chat attachments.</string>

<key>NSMicrophoneUsageDescription</key>

<string>Allow access to your microphone for video attachments.</string>

<key>LSApplicationQueriesSchemes</key>

<array>

  <string>tel</string>

  <string>mailto</string>

  <string>sms</string>

  <string>facetime</string>

</array>

If you do not enable attachments, camera and photo permissions are not required.

Step 3: Create the Swift Configuration Model


struct TalkdeskChatConfig {

    let bridgeURL: URL

    let touchpointId: String

    let region: String

    let languageCode: String?

    let enableEmoji: Bool

    let enableAttachments: Bool

    let enableSoundNotification: Bool

    let enableUserInput: Bool

    let enableChatHeader: Bool

    let enableValidation: Bool



    func asDictionary() -> [String: Any] {

        var dictionary: [String: Any] = [

            "touchpointId": touchpointId,

            "region": region,

            "enableEmoji": enableEmoji,

            "enableAttachments": enableAttachments,

            "enableSoundNotification": enableSoundNotification,

            "enableUserInput": enableUserInput,

            "enableChatHeader": enableChatHeader,

            "enableValidation": enableValidation

        ]



        if let languageCode, !languageCode.isEmpty {

            dictionary["languageCode"] = languageCode

        }



        return dictionary

    }

}

Example configuration:


let config = TalkdeskChatConfig(

    bridgeURL: URL(string: "https://your-company.example.com/talkdesk-chat-bridge.html")!,

    touchpointId: "YOUR_TOUCHPOINT_ID",

    region: "td-us-1",

    languageCode: "en-US",

    enableEmoji: true,

    enableAttachments: true,

    enableSoundNotification: true,

    enableUserInput: true,

    enableChatHeader: true,

    enableValidation: true

)

Step 4: Create the WebView Manager

The manager owns the WKWebView, loads the bridge page, injects the config, receives JavaScript events, and cleans up when chat closes.


import AVFoundation

import Photos

import SwiftUI

import UIKit

import WebKit



struct TalkdeskChatEvent: Identifiable {

    let id = UUID()

    let type: String

    let payload: String?

    let timestamp = Date()

}



@MainActor

final class TalkdeskChatWebViewManager: NSObject, ObservableObject {

    @Published var webView: WKWebView?

    @Published var shouldShowWebView = false

    @Published var isLoading = false

    @Published var events: [TalkdeskChatEvent] = []

    @Published var showPermissionAlert = false



    private var pendingConfig: [String: Any]?

    private let nativeURLSchemes = ["tel", "mailto", "sms", "facetime"]

}

Add launch and cleanup methods:


extension TalkdeskChatWebViewManager {

    func launchChat(config: TalkdeskChatConfig) {

        isLoading = true

        shouldShowWebView = false



        if config.enableAttachments {

            requestAttachmentPermissions { [weak self] granted in

                guard let self else { return }



                if granted {

                    self.startWebView(config: config)

                } else {

                    self.isLoading = false

                    self.showPermissionAlert = true

                }

            }

        } else {

            startWebView(config: config)

        }

    }



    func destroyWebView() {

        webView?.configuration.userContentController.removeScriptMessageHandler(forName: "sdkEvent")

        webView?.navigationDelegate = nil

        webView?.uiDelegate = nil

        webView = nil

        isLoading = false

        shouldShowWebView = false

        pendingConfig = nil

    }



    func openAppSettings() {

        if let url = URL(string: UIApplication.openSettingsURLString) {

            UIApplication.shared.open(url)

        }

    }

}

Add WebView creation:


private extension TalkdeskChatWebViewManager {

    func startWebView(config: TalkdeskChatConfig) {

        pendingConfig = config.asDictionary()



        let webViewConfig = WKWebViewConfiguration()

        webViewConfig.websiteDataStore = .default()



        let contentController = WKUserContentController()

        contentController.add(self, name: "sdkEvent")

        webViewConfig.userContentController = contentController



        let webView = WKWebView(frame: .zero, configuration: webViewConfig)

        webView.navigationDelegate = self

        webView.uiDelegate = self

        webView.scrollView.bounces = false

        webView.scrollView.contentInsetAdjustmentBehavior = .automatic



        self.webView = webView

        webView.load(URLRequest(url: config.bridgeURL))

    }

}

Step 5: Inject SDK Config After the Bridge Page Loads


extension TalkdeskChatWebViewManager: WKNavigationDelegate {

    func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {

        guard let pendingConfig,

              let jsonData = try? JSONSerialization.data(withJSONObject: pendingConfig),

              let jsonString = String(data: jsonData, encoding: .utf8) else {

            handleError("Failed to serialize Talkdesk Chat config")

            return

        }



        webView.evaluateJavaScript("window.initTalkdeskChat(\(jsonString))") { [weak self] _, error in

            if let error {

                Task { @MainActor in

                    self?.handleError("Failed to inject Talkdesk Chat config: \(error.localizedDescription)")

                }

            }

        }



        self.pendingConfig = nil

    }



    func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {

        if isRecoverableNavigationInterruption(error) { return }

        handleError("Failed to load Talkdesk Chat: \(error.localizedDescription)")

    }



    func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {

        if isRecoverableNavigationInterruption(error) { return }

        handleError("Failed to start Talkdesk Chat load: \(error.localizedDescription)")

    }

}

Some WebView navigation interruptions are expected, especially when links or downloads leave the WebView. Do not destroy chat for these recoverable cases:


private extension TalkdeskChatWebViewManager {

    func isRecoverableNavigationInterruption(_ error: Error) -> Bool {

        let nsError = error as NSError

        let frameLoadInterruptedByPolicyChange = 102



        return (nsError.domain == NSURLErrorDomain && nsError.code == NSURLErrorCancelled) ||

            (nsError.domain == WKError.errorDomain && nsError.code == frameLoadInterruptedByPolicyChange)

    }

}

Step 6: Receive SDK Events From JavaScript


extension TalkdeskChatWebViewManager: WKScriptMessageHandler {

    func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {

        guard let body = message.body as? [String: Any],

              let rawType = body["type"] as? String else {

            return

        }



        let type = rawType.trimmingCharacters(in: .whitespacesAndNewlines)

        guard !type.isEmpty else { return }



        let payload = (body["payload"] as? [String: Any]).flatMap { payload in

            try? String(data: JSONSerialization.data(withJSONObject: payload), encoding: .utf8)

        }



        events.append(TalkdeskChatEvent(type: type, payload: payload))



        switch type {

        case "sdkReady":

            isLoading = false

            shouldShowWebView = true

            webView?.evaluateJavaScript("window.openTalkdeskChat()", completionHandler: nil)



        case "onCloseWebchat", "error":

            destroyWebView()



        default:

            break

        }

    }

}

Do not show the visible chat container before sdkReady. It is fine to mount the WKWebView hidden so the bridge page can load, but the customer should only see the chat WebView after the SDK is initialized.

User-facing links should leave the chat WebView and open through iOS.

This includes:


extension TalkdeskChatWebViewManager: WKUIDelegate {

    func webView(

        _ webView: WKWebView,

        createWebViewWith configuration: WKWebViewConfiguration,

        for navigationAction: WKNavigationAction,

        windowFeatures: WKWindowFeatures

    ) -> WKWebView? {

        if let url = navigationAction.request.url {

            _ = openExternallyIfNeeded(url)

        }



        return nil

    }

}



extension TalkdeskChatWebViewManager {

    func webView(

        _ webView: WKWebView,

        decidePolicyFor navigationAction: WKNavigationAction,

        decisionHandler: @escaping (WKNavigationActionPolicy) -> Void

    ) {

        if let url = navigationAction.request.url,

           (nativeURLSchemes.contains(url.scheme?.lowercased() ?? "") ||

            navigationAction.navigationType == .linkActivated),

           openExternallyIfNeeded(url) {

            decisionHandler(.cancel)

            return

        }



        decisionHandler(.allow)

    }



    private func openExternallyIfNeeded(_ url: URL) -> Bool {

        guard let scheme = url.scheme?.lowercased() else { return false }



        if nativeURLSchemes.contains(scheme) || scheme == "http" || scheme == "https" {

            UIApplication.shared.open(url)

            return true

        }



        return false

    }

}

Step 8: Request Attachment Permissions

Only request these permissions if attachments are enabled.


private extension TalkdeskChatWebViewManager {

    func requestAttachmentPermissions(completion: @escaping (Bool) -> Void) {

        let photoStatus = PHPhotoLibrary.authorizationStatus(for: .readWrite)

        let cameraStatus = AVCaptureDevice.authorizationStatus(for: .video)



        if photoStatus == .denied || photoStatus == .restricted ||

            cameraStatus == .denied || cameraStatus == .restricted {

            completion(false)

            return

        }



        PHPhotoLibrary.requestAuthorization(for: .readWrite) { photoStatus in

            AVCaptureDevice.requestAccess(for: .video) { cameraGranted in

                let photoGranted = photoStatus == .authorized || photoStatus == .limited



                DispatchQueue.main.async {

                    completion(photoGranted && cameraGranted)

                }

            }

        }

    }

}

Step 9: Add Error Handling


private extension TalkdeskChatWebViewManager {

    func handleError(_ message: String) {

        events.append(TalkdeskChatEvent(type: "error", payload: "{\"message\":\"\(message)\"}"))

        destroyWebView()

    }

}

For production apps, show a friendly native error message, for example:


Chat is currently unavailable. Please try again later.

Avoid showing raw technical errors directly to end users.

Step 10: Create the SwiftUI WebView Wrapper


struct TalkdeskChatWebView: UIViewRepresentable {

    let webView: WKWebView



    func makeUIView(context: Context) -> WKWebView {

        webView.scrollView.bounces = false

        webView.scrollView.contentInsetAdjustmentBehavior = .automatic

        return webView

    }



    func updateUIView(_ uiView: WKWebView, context: Context) {

        uiView.scrollView.verticalScrollIndicatorInsets = uiView.safeAreaInsets

    }

}

Step 11: Display the Chat WebView Safely

Use a native container around the WebView.

The container background may cover the full screen, including the bottom safe-area area. The WebView itself should still respect the safe area so the chat input is not hidden by the Home Indicator.


struct ContentView: View {

    @StateObject private var manager = TalkdeskChatWebViewManager()



    private let chatConfig = TalkdeskChatConfig(

        bridgeURL: URL(string: "https://your-company.example.com/talkdesk-chat-bridge.html")!,

        touchpointId: "YOUR_TOUCHPOINT_ID",

        region: "td-us-1",

        languageCode: "en-US",

        enableEmoji: true,

        enableAttachments: true,

        enableSoundNotification: true,

        enableUserInput: true,

        enableChatHeader: true,

        enableValidation: true

    )



    var body: some View {

        ZStack {

            mainScreen

                .zIndex(manager.shouldShowWebView ? 0 : 1)



            if let webView = manager.webView {

                ZStack {

                    Color.white

                        .ignoresSafeArea()

                        .opacity(manager.shouldShowWebView ? 1 : 0)



                    TalkdeskChatWebView(webView: webView)

                        .frame(maxWidth: .infinity, maxHeight: .infinity)

                        .background(Color.white)

                        .opacity(manager.shouldShowWebView ? 1 : 0)

                }

                .zIndex(manager.shouldShowWebView ? 1 : 0)

                .allowsHitTesting(manager.shouldShowWebView)

            }

        }

        .alert("Permissions Required", isPresented: $manager.showPermissionAlert) {

            Button("Open Settings") {

                manager.openAppSettings()

            }

            Button("Cancel", role: .cancel) {}

        } message: {

            Text("Camera and Photo Library access are required for chat attachments. Please enable them in Settings.")

        }

    }



    private var mainScreen: some View {

        VStack(spacing: 16) {

            Text("Need help?")

                .font(.title)



            Button {

                manager.launchChat(config: chatConfig)

            } label: {

                if manager.isLoading {

                    ProgressView()

                } else {

                    Text("Start Chat")

                }

            }

            .disabled(manager.isLoading)

        }

        .padding()

    }

}

Why this layout matters:

Step 12: Test the Integration

Test these cases before shipping:

Known Limitation: Transcript Download

If the web SDK generates transcript downloads using a temporary blob: URL and an <a download> click, native iOS WKWebView cannot reliably save that blob-backed file.

Transcript download is currently supported for web browser integrations, but not for native iOS WKWebView integrations.

For iOS app integrations, disable transcript download for the chat touchpoint in Talkdesk Admin:


Admin > Channels > Chat > Touchpoints > [your touchpoint] > disable transcript download

Treat transcript download as unsupported in native iOS unless you implement and validate a separate native download flow.

The app should ignore recoverable WebView navigation interruptions so chat stays open, but it should not tell users that transcript download is supported unless the native app handles it explicitly.

Common Issues

IssueLikely CauseFix
Chat never appearssdkReady was not receivedCheck bridge page URL, SDK script URL, touchpointId, and region
Native screen shows behind keyboardContainer background does not cover the full screenAdd a native container background with .ignoresSafeArea()
Chat input is hidden by Home IndicatorWebView ignores bottom safe areaDo not apply .ignoresSafeArea(edges: .bottom) to the WebView itself
External links do nothingWKUIDelegate.createWebViewWith is missingImplement createWebViewWith and open URLs through UIApplication.shared.open
Camera/photo upload failsMissing Info.plist permissionsAdd photo, camera, and microphone permission strings
App crashes when selecting attachmentPermission keys are missingAdd the required Info.plist keys before enabling attachments
Chat closes after a link/download navigationRecoverable navigation error was treated as fatalIgnore NSURLErrorCancelled and WKError code 102

Production Checklist