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.
What You Will Build
- Create WKWebView
- Inject config via JavaScript
- Receive SDK events
- Load SDK from CDN
- Call initTalkdeskChat(config)
- Send events to native
The native app is responsible for:
- Creating and showing the
WKWebView - Passing SDK configuration to the bridge page
- Receiving SDK events from JavaScript
- Handling native permissions for attachments
- Opening external links outside the chat WebView
- Cleaning up the WebView when chat closes
The bridge page is responsible for:
- Loading the Talkdesk Chat Widget JS SDK script
- Calling
TalkdeskChatSDK(...) - Calling
webchat.init(...) - Sending events back to Swift
Requirements
- iOS 16 or later
- Swift 5 or later
- A Talkdesk
touchpointId - A Talkdesk region, for example
td-us-1,td-eu-1,td-ca-1,td-ap-1, ortd-uk-1 - A secure HTTPS URL where the bridge HTML page can be hosted
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:
viewport-fit=coverallows iOS safe-area variables to work correctly.padding-bottom: env(safe-area-inset-bottom, 0px)helps keep chat UI above the Home Indicator.enableResponsiveLayout: trueallows the widget to fill the WebView.- The SDK script callbacks are registered before the script is inserted into the document.
sdkReadyis sent only after the SDK has initialized successfully.
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.
Step 7: Open External Links Outside the Chat WebView
User-facing links should leave the chat WebView and open through iOS.
This includes:
- Normal
https://links clicked by the user target="_blank"links- JavaScript
window.open(...) tel:,mailto:,sms:, andfacetime:links
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:
- The WebView can be mounted before
sdkReady, but it stays invisible. - The customer only sees chat after
sdkReady. - The full-screen background prevents the underlying native screen from showing behind the keyboard.
- The WebView itself does not ignore the bottom safe area, so the chat input stays above the Home Indicator.
Step 12: Test the Integration
Test these cases before shipping:
- Chat opens after tapping the native chat button.
- Chat does not visibly appear until
sdkReadyis received. - Chat input is not hidden by the iPhone Home Indicator.
- Keyboard open and close does not reveal the native screen behind the WebView.
- Closing chat removes the WebView and returns to the native screen.
- Attachment upload asks for camera/photo permissions only when attachments are enabled.
- Denied permissions show a native message and a path to Settings.
https://links open in Safari or the user's default browser.tel:,mailto:,sms:, andfacetime:links open the correct iOS app.target="_blank"links andwindow.open(...)do not fail silently.- Poor network conditions show a friendly error or loading state.
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
| Issue | Likely Cause | Fix |
|---|---|---|
| Chat never appears | sdkReady was not received | Check bridge page URL, SDK script URL, touchpointId, and region |
| Native screen shows behind keyboard | Container background does not cover the full screen | Add a native container background with .ignoresSafeArea() |
| Chat input is hidden by Home Indicator | WebView ignores bottom safe area | Do not apply .ignoresSafeArea(edges: .bottom) to the WebView itself |
| External links do nothing | WKUIDelegate.createWebViewWith is missing | Implement createWebViewWith and open URLs through UIApplication.shared.open |
| Camera/photo upload fails | Missing Info.plist permissions | Add photo, camera, and microphone permission strings |
| App crashes when selecting attachment | Permission keys are missing | Add the required Info.plist keys before enabling attachments |
| Chat closes after a link/download navigation | Recoverable navigation error was treated as fatal | Ignore NSURLErrorCancelled and WKError code 102 |
Production Checklist
- Host the bridge page on HTTPS.
- Use the correct production
touchpointIdand region. - Keep the bridge page small and trusted.
- Do not expose secrets in the bridge page or SDK config.
- Register and remove the
sdkEventmessage handler correctly. - Show chat only after
sdkReady. - Disable transcript download for iOS app chat touchpoints in Talkdesk Admin.
- Clean up the WebView after
onCloseWebchator unrecoverable errors. - Test on a real iPhone with a Home Indicator.
- Test with keyboard, attachments, external links, and poor network conditions.