Chat Widget ## Sections • [Website](https://developer.hiverhq.com/chat-widget-installation/website.md): Retrieve customizable installation scripts to seamlessly embed the Chat Widget on your website. This section enables you to tailor the integration code to match your site's requirements and desired functionality. • [Load widget for authenticated users only](https://developer.hiverhq.com/chat-widget-installation/website/authenticated-users.md): Use this script to load the chat widget only for logged-in users. You can add custom logic to detect logged-in users. Installation Paste this script just before the </body> tag on every page where the widget should appear. Before you begin Replace YOUR_WEBSITE_TOKEN with your Website Token from Admin Panel → Chat Inbox → Widget Script. 2. Customize the isUserLoggedIn() function to detect signed-in users. 3. Update the values in window.$hiverChatWidget.setUser() with your authenticated user data. 4. Set widgetPosition to "left" to display the widget on left side of the page. Javascript var widgetPosition = "left"; Script: HTML <script> (function(d, t) { var BASE_URL = ''; var CDN_URL = 'https://chat-widget.hiverhq.com/chat-widget'; var WEBSITE_TOKEN = 'YOUR_WEBSITE_TOKEN'; var widgetPosition = "right"; function isUserLoggedIn() { return true; // replace with real auth check } function loadWidget() { if (!isUserLoggedIn()) return; var g = d.createElement(t), s = d.getElementsByTagName(t)[0]; g.src = CDN_URL + '/js/sdk.js'; g.defer = true; g.async = true; s.parentNode.insertBefore(g, s); g.onload = function() { window.hiverChatWidgetSettings = window.hiverChatWidgetSettings || {}; window.hiverChatWidgetSettings.position = widgetPosition; window.chatwootSDK.run({ websiteToken: WEBSITE_TOKEN, baseUrl: BASE_URL }); if (window.$hiverChatWidget) { window.$hiverChatWidget.setUser('user0@example.com', { email: 'user0@example.com', name: 'John Doe 0', phone_number: '+919876543210' }); } }; } loadWidget(); })(document, 'script'); </script> <script> (function(d, t) { var BASE_URL = ''; var CDN_URL = 'https://chat-widget.hiverhq.com/chat-widget'; var WEBSITE_TOKEN = 'YOUR_WEBSITE_TOKEN'; var widgetPosition = "right"; function isUserLoggedIn() { return true; // replace with real auth check } function loadWidget() { if (!isUserLoggedIn()) return; var g = d.createElement(t), s = d.getElementsByTagName(t)[0]; g.src = CDN_URL + '/js/sdk.js'; g.defer = true; g.async = true; s.parentNode.insertBefore(g, s); g.onload = function() { window.hiverChatWidgetSettings = window.hiverChatWidgetSettings || {}; window.hiverChatWidgetSettings.position = widgetPosition; window.chatwootSDK.run({ websiteToken: WEBSITE_TOKEN, baseUrl: BASE_URL }); if (window.$hiverChatWidget) { window.$hiverChatWidget.setUser('user0@example.com', { email: 'user0@example.com', name: 'John Doe 0', phone_number: '+919876543210' }); } }; } loadWidget(); })(document, 'script'); </script> Verifying the installation Refresh the page to confirm if the widget appears. Next If you want the widget to load in response to a specific user action, learn more about loading the widget on trigger . • [Trigger the widget programmatically](https://developer.hiverhq.com/chat-widget-installation/website/programmatic-triggers.md): By default, the widget loads automatically on page load. Use this script to load it in response to a button click. Installation Use this script to load it in response to a button click. Paste this script just before the </body> tag on every page where the widget should appear. Before you begin Replace YOUR_WEBSITE_TOKEN with your Website Token from Admin Panel → Chat Inbox → Widget Script . 2. Replace your-button-id with the ID of your custom chat button. 3. Update your button element. This ensures the button is only active after the widget is ready and opens the chat when clicked. HTML <button id="your-button-id" disabled onclick="window?.$hiverChatWidget?.toggle?.()"> Chat with us </button> <button id="your-button-id" disabled onclick="window?.$hiverChatWidget?.toggle?.()"> Chat with us </button> 4. Please update widgetPosition to "left" to configure widget on left side of the website. Javascript var widgetPosition = "left"; Script: HTML <script> window.addEventListener('hiver:widget-ready', function() { // Enable custom chat button only after the widget is ready const btn = document.getElementById('your-button-id'); btn.disabled = false; }); (function(d,t) { var BASE_URL = ""; var CDN_URL = "https://chat-widget.hiverhq.com/chat-widget"; var WEBSITE_TOKEN = 'YOUR_WEBSITE_TOKEN'; var widgetPosition = "right"; var g = d.createElement(t), s = d.getElementsByTagName(t)[0]; g.src = CDN_URL + "/js/sdk.js"; g.defer = true; g.async = true; s.parentNode.insertBefore(g,s); g.onload = function() { window.hiverChatWidgetSettings = window.hiverChatWidgetSettings || {}; window.hiverChatWidgetSettings.position = widgetPosition; window.$hiverChatWidget.hideMessageBubble = true; window.chatwootSDK.run({ websiteToken: WEBSITE_TOKEN, baseUrl: BASE_URL }); } })(document,"script"); </script> <script> window.addEventListener('hiver:widget-ready', function() { // Enable custom chat button only after the widget is ready const btn = document.getElementById('your-button-id'); btn.disabled = false; }); (function(d,t) { var BASE_URL = ""; var CDN_URL = "https://chat-widget.hiverhq.com/chat-widget"; var WEBSITE_TOKEN = 'YOUR_WEBSITE_TOKEN'; var widgetPosition = "right"; var g = d.createElement(t), s = d.getElementsByTagName(t)[0]; g.src = CDN_URL + "/js/sdk.js"; g.defer = true; g.async = true; s.parentNode.insertBefore(g,s); g.onload = function() { window.hiverChatWidgetSettings = window.hiverChatWidgetSettings || {}; window.hiverChatWidgetSettings.position = widgetPosition; window.$hiverChatWidget.hideMessageBubble = true; window.chatwootSDK.run({ websiteToken: WEBSITE_TOKEN, baseUrl: BASE_URL }); } })(document,"script"); </script> Verifying the installation Refresh the page and click your custom button to confirm the widget appears. • [WebView installation](https://developer.hiverhq.com/chat-widget-installation/webview-installation-1.md): Retrieve customizable installation scripts to seamlessly embed the Chat Widget on your mobile app. This section enables you to tailor the integration code to match your app requirements and desired functionality. • [Embed the Hiver chat widget in a mobile app (WebView)](https://developer.hiverhq.com/chat-widget-installation/webview-installation-1/embed-the-hiver-chat-widget-in-a-mobile-app-webview.md): Add live chat to your mobile app with the Hiver chat widget. This guide shows you how to embed it in a WebView on Android (Kotlin), iOS (Swift), Flutter, and React Native. The widget is a self-contained web app. Your app loads it in a WebView and acts as its host: it controls when the chat is visible, identifies the user, and handles the events the widget emits. Conversations land in your Hiver inbox. 1. Before you start Title Description Thing you need Where it comes from Website token Admin Panel → Chat Inbox → Widget Script . The same token as your web embed. Widget base URL https://chat-widget.hiverhq.com/chat-widget . The page you load is <base>/widget.html . Network access Your app must reach the widget host and the Hiver chat API ( https://chat-api.hiverhq.com , wss://chat-api.hiverhq.com for live updates) Native prerequisites, per platform: Title Description Platform Requirements Android INTERNET permission; JavaScript and DOM storage enabled; a WebChromeClient that implements onShowFileChooser if you want attachment uploads. Recommended: androidx.webkit:webkit for reliable document-start script injection. iOS WKWebView with the default (persistent) websiteDataStore ; NSPhotoLibraryUsageDescription and NSCameraUsageDescription in Info.plist if users will attach photos. Flutter flutter_inappwebview (recommended - it supports document-start user scripts and a JS handler bridge) plus url_launcher . React Native react-native-webview . Use the ready-made component in mobile/react-native/HiverChat.js . No push-notification or background setup is required. Live agent replies arrive over the widget's own WebSocket connection while the WebView is alive. 2. How the integration works Plain text ┌──────────────────────── Your mobile app ────────────────────────┐ │ │ │ Launcher button ──► show WebView │ │ │ │ ┌──────────────── WebView ────────────────┐ │ │ │ <WIDGET_BASE_URL>/widget.html │ │ │ │ ?website_token=… │ │ │ │ │ │ │ │ widget → host: │ │ │ │ window.ReactNativeWebView │ │ │ │ .postMessage("chatwoot-widget:{…}")│──► native handler │ │ │ │ │ │ │ host → widget: │ │ │ │ window.dispatchEvent( │◄── evaluateJavascript│ │ │ new MessageEvent('message', …)) │ │ │ └──────────────────────────────────────────┘ │ └──────────────────────────────────────────────────────────────────┘ │ ▼ Hiver chat API / inbox ┌──────────────────────── Your mobile app ────────────────────────┐ │ │ │ Launcher button ──► show WebView │ │ │ │ ┌──────────────── WebView ────────────────┐ │ │ │ <WIDGET_BASE_URL>/widget.html │ │ │ │ ?website_token=… │ │ │ │ │ │ │ │ widget → host: │ │ │ │ window.ReactNativeWebView │ │ │ │ .postMessage("chatwoot-widget:{…}")│──► native handler │ │ │ │ │ │ │ host → widget: │ │ │ │ window.dispatchEvent( │◄── evaluateJavascript│ │ │ new MessageEvent('message', …)) │ │ │ └──────────────────────────────────────────┘ │ └──────────────────────────────────────────────────────────────────┘ │ ▼ Hiver chat API / inbox Two things are worth internalizing before you write any code: You do not use the web SDK on mobile. The web embed loads sdk.js , which creates an iframe plus a floating launcher bubble and exposes window.$chatwoot / window.$hiverChatWidget . On mobile you skip all of that and load widget.html directly. Your app provides the launcher and the chrome. The widget only opens its bridge if it detects a host. The widget enables its host-message listener and announces itself only when it is either inside an iframe ( window.self !== window.top ) or when window.ReactNativeWebView exists ( src/widget/App.vue:71-116 , src/widget/helpers/utils.js:81-88 ). Inside a plain Android WebView, WKWebView , or Flutter WebView neither is true, so: the widget never emits the loaded event, and every message you send it is silently ignored (no listener is registered), and documentWidth stays 0 , which keeps the widget's own header actions (close, "End chat") hidden ( src/widget/components/HeaderActions.vue:129 ). The fix is one small script, injected at document start , that shims window.ReactNativeWebView.postMessage onto your platform's native bridge. The name is historical, it is the widget's generic "I am inside an app WebView" contract, not something React Native specific. React Native is the exception: it needs no shim. react-native-webview already injects a real window.ReactNativeWebView into every page it loads, so the widget detects the host on its own — which is why mobile/example-app/HiverChat.js contains no such snippet. Its injectedJavaScript only pins the viewport. The Android, iOS, and Flutter snippets install the shim because those WebViews provide no such object. 3. The bridge protocol Both directions use the same envelope: a string consisting of the prefix chatwoot-widget: followed by JSON. The JSON always carries an event key; other keys are the payload. Plain text chatwoot-widget:{"event":"set-user","identifier":"user_123","user":{"name":"Jane"}} chatwoot-widget:{"event":"set-user","identifier":"user_123","user":{"name":"Jane"}} Widget → your app The widget calls: JavaScript window.ReactNativeWebView.postMessage('chatwoot-widget:{"event":"loaded", ...}'); Your shim forwards that string to native code. Always check the prefix and ignore anything else — other libraries in the page may use the same channel. Your app → widget The widget listens for message events on window . Deliver a message by dispatching a synthetic MessageEvent whose data is the prefixed string: JavaScript window.dispatchEvent(new MessageEvent('message', { data: "chatwoot-widget:{…}" })); Build the JS string literal with your platform's JSON encoder rather than string concatenation. Message payloads contain user names and email addresses that will otherwise break the injected script. Timing The widget registers its listener after its config request ( /api/v1/widget/config ) resolves, and emits loaded at that moment. So: Inject the shim at document start (before the page's scripts run) on Android, iOS, and Flutter. React Native needs no shim. Wait for loaded before sending anything else. Messages sent earlier are dropped, not queued. 4. Platform snippets All four snippets implement the same contract: Make sure window.ReactNativeWebView exists — inject the shim at document start on Android, iOS, and Flutter. React Native already provides it, so its snippet has no step 1. Load <base>/widget.html?website_token=… . On loaded : report the document width, choose the close button, identify the user, and push the webwidget.triggered event. On close-widget : dismiss the chat. Keep external URLs (attachments, links) out of the WebView. 4.1 Android (Kotlin) Three things to know before you copy this: It extends ComponentActivity , not AppCompatActivity . AppCompatActivity requires a Theme.AppCompat descendant and throws "You need to use a Theme.AppCompat theme (or descendant) with this activity" at runtime against the themes new projects ship with (e.g. the Compose template's android:Theme.Material.Light.NoActionBar ). ComponentActivity needs no theme change and no appcompat dependency, and still provides registerForActivityResult for the attachment picker. Use AppCompatActivity only if the rest of your app already depends on AppCompat. Window insets are not optional. Edge-to-edge is enforced from targetSdk 35, so the snippet pads a container view for the system bars and the IME. See the comment in onCreate for why the padding cannot go on the WebView itself. Register the activity in AndroidManifest.xml with the INTERNET permission, android:windowSoftInputMode="adjustResize" , and android:configChanges="orientation|screenSize|keyboardHidden|screenLayout" so a rotation doesn't reload the WebView and drop the conversation. Kotlin // build.gradle(.kts): implementation("androidx.webkit:webkit:1.12.1") package com.example.app.chat import android.annotation.SuppressLint import android.content.Intent import android.graphics.Bitmap import android.graphics.Color import android.net.Uri import android.os.Bundle import android.webkit.JavascriptInterface import android.webkit.ValueCallback import android.webkit.WebChromeClient import android.webkit.WebResourceRequest import android.webkit.WebView import android.webkit.WebViewClient import android.widget.FrameLayout import androidx.activity.ComponentActivity import androidx.activity.result.contract.ActivityResultContracts import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat import androidx.webkit.WebViewCompat import androidx.webkit.WebViewFeature import org.json.JSONObject /** * Full-screen Hiver chat. Launch it from your own "Chat with support" button: * startActivity(Intent(this, HiverChatActivity::class.java)) */ class HiverChatActivity : ComponentActivity() { private companion object { const val WIDGET_BASE_URL = "https://chat-widget.hiverhq.com/chat-widget" const val WEBSITE_TOKEN = "YOUR_WEBSITE_TOKEN" const val MESSAGE_PREFIX = "chatwoot-widget:" /** * The widget renders its own close button only while the reported * document width is under 667. Reporting exactly 667 suppresses it so * your native toolbar owns closing the chat. */ const val WIDGET_CLOSE_BUTTON_WIDTH_LIMIT = 667 /** * Tells the widget it is running inside an app WebView and routes its * outgoing messages to the `HiverChatNative` JS interface below. */ const val BRIDGE_SHIM = """ (function () { if (window.ReactNativeWebView) return; window.ReactNativeWebView = { postMessage: function (message) { window.HiverChatNative.postMessage(String(message)); } }; })(); """ } private lateinit var webView: WebView /** Native file picker for chat attachments. */ private var filePathCallback: ValueCallback<Array<Uri>>? = null private val filePicker = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> val uris = WebChromeClient.FileChooserParams.parseResult(result.resultCode, result.data) filePathCallback?.onReceiveValue(uris) filePathCallback = null } @SuppressLint("SetJavaScriptEnabled") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) webView = WebView(this).apply { settings.javaScriptEnabled = true // The widget keeps its session token in localStorage. settings.domStorageEnabled = true settings.mediaPlaybackRequiresUserGesture = false settings.setSupportMultipleWindows(false) } // Edge-to-edge is mandatory from SDK 35 on, so without this the widget // header renders under the status bar and the composer sits under the // navigation bar. Note the insets pad a *container*, not the WebView: // the widget sizes itself with viewport units, which ignore a WebView's // own padding, so only shrinking the WebView actually moves the content. // The IME inset is in the set too, keeping the composer above the // keyboard — `adjustResize` no longer resizes the window under // edge-to-edge. val container = FrameLayout(this).apply { setBackgroundColor(Color.WHITE) } container.addView( webView, FrameLayout.LayoutParams( FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT ) ) setContentView(container) ViewCompat.setOnApplyWindowInsetsListener(container) { view, windowInsets -> val insets = windowInsets.getInsets( WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.ime() ) view.setPadding(insets.left, insets.top, insets.right, insets.bottom) WindowInsetsCompat.CONSUMED } // The widget header is white, so the status bar needs dark icons to stay // legible — the default light icons are invisible against it. WindowInsetsControllerCompat(window, window.decorView).isAppearanceLightStatusBars = true webView.addJavascriptInterface(Bridge(), "HiverChatNative") installBridgeShim() webView.webViewClient = object : WebViewClient() { override fun onPageStarted(view: WebView, url: String, favicon: Bitmap?) { // Fallback shim injection for WebView builds without // DOCUMENT_START_SCRIPT. Harmless when the document-start // script already ran — the shim is idempotent. if (!WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)) { view.evaluateJavascript(BRIDGE_SHIM, null) } } override fun shouldOverrideUrlLoading( view: WebView, request: WebResourceRequest ): Boolean { val url = request.url.toString() // Keep the chat on the widget origin; attachments and links // open in the browser so the conversation is never navigated away. if (url.startsWith(WIDGET_BASE_URL)) return false startActivity(Intent(Intent.ACTION_VIEW, request.url)) return true } } webView.webChromeClient = object : WebChromeClient() { override fun onShowFileChooser( view: WebView, callback: ValueCallback<Array<Uri>>, params: FileChooserParams ): Boolean { filePathCallback?.onReceiveValue(null) filePathCallback = callback filePicker.launch(params.createIntent()) return true } } webView.loadUrl( "$WIDGET_BASE_URL/widget.html?website_token=" + Uri.encode(WEBSITE_TOKEN) ) } /** * The shim must run before the widget's own scripts. `addDocumentStartJavaScript` * guarantees that; the `onPageStarted` fallback above covers older WebView builds. */ private fun installBridgeShim() { if (!WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)) return val origin = Uri.parse(WIDGET_BASE_URL).let { "${it.scheme}://${it.host}" } WebViewCompat.addDocumentStartJavaScript(webView, BRIDGE_SHIM, setOf(origin)) } /** Receives `chatwoot-widget:` messages from the widget. */ private inner class Bridge { @JavascriptInterface fun postMessage(raw: String) { if (!raw.startsWith(MESSAGE_PREFIX)) return val message = JSONObject(raw.removePrefix(MESSAGE_PREFIX)) runOnUiThread { when (message.optString("event")) { "loaded" -> onWidgetLoaded() "close-widget" -> finish() } } } } private fun onWidgetLoaded() { // Mobile layout, and suppress the widget's own close button in favour // of your native toolbar / back button. sendToWidget( JSONObject() .put("event", "inner-document-width") .put("documentWidth", WIDGET_CLOSE_BUTTON_WIDTH_LIMIT) ) sendToWidget( JSONObject().put("event", "toggle-close-button").put("showClose", false) ) // Identify the signed-in user so the conversation is attributed correctly. sendToWidget( JSONObject() .put("event", "set-user") .put("identifier", "user_123") .put( "user", JSONObject() .put("name", "Jane Doe") .put("email", "jane@acme.com") ) ) // Tell the widget the user opened the chat. sendToWidget( JSONObject().put("event", "push-event").put("eventName", "webwidget.triggered") ) } private fun sendToWidget(payload: JSONObject) { val message = JSONObject.quote(MESSAGE_PREFIX + payload.toString()) webView.evaluateJavascript( "window.dispatchEvent(new MessageEvent('message', { data: $message }));", null ) } /** Clear the chat session — call this when the user signs out. */ fun resetChatSession() { webView.evaluateJavascript( """try { localStorage.removeItem('cw_conversation'); } catch (e) {} window.location.reload();""", null ) } override fun onDestroy() { webView.destroy() super.onDestroy() } } 4.2 iOS (Swift) Swift import UIKit import WebKit /// Full-screen Hiver chat. Present it from your own launcher: /// present(HiverChatViewController(), animated: true) final class HiverChatViewController: UIViewController { private enum Config { static let widgetBaseURL = "https://chat-widget.hiverhq.com/chat-widget" static let websiteToken = "YOUR_WEBSITE_TOKEN" static let messagePrefix = "chatwoot-widget:" /// The widget renders its own close button only below 667pt. Reporting /// exactly 667 suppresses it so the native nav bar owns closing. static let closeButtonWidthLimit = 667 /// Tells the widget it is inside an app WebView and routes its outgoing /// messages to the `hiverChat` script message handler. static let bridgeShim = """ (function () { if (window.ReactNativeWebView) return; window.ReactNativeWebView = { postMessage: function (message) { window.webkit.messageHandlers.hiverChat.postMessage(String(message)); } }; })(); """ /// Pins the zoom level so focusing a text field doesn't zoom the chat. static let viewportShim = """ (function () { var meta = document.querySelector('meta[name="viewport"]') || document.head.appendChild(Object.assign( document.createElement('meta'), { name: 'viewport' })); meta.setAttribute('content', 'width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0'); })(); """ } private var webView: WKWebView! private var user: [String: String] = ["name": "Jane Doe", "email": "jane@acme.com"] private var userIdentifier = "user_123" override func viewDidLoad() { super.viewDidLoad() let controller = WKUserContentController() controller.add(self, name: "hiverChat") // Document-start injection: the shim must exist before the widget's // scripts decide whether a host is present. controller.addUserScript(WKUserScript(source: Config.bridgeShim, injectionTime: .atDocumentStart, forMainFrameOnly: true)) controller.addUserScript(WKUserScript(source: Config.viewportShim, injectionTime: .atDocumentEnd, forMainFrameOnly: true)) let configuration = WKWebViewConfiguration() configuration.userContentController = controller // Default (persistent) store keeps localStorage — and therefore the // conversation — across app launches. configuration.websiteDataStore = .default() configuration.allowsInlineMediaPlayback = true webView = WKWebView(frame: .zero, configuration: configuration) webView.navigationDelegate = self webView.uiDelegate = self webView.scrollView.bounces = false webView.translatesAutoresizingMaskIntoConstraints = false // The widget header is white; match it so the safe-area strips blend in. view.backgroundColor = .white view.addSubview(webView) // Pin to the safe area, not to `view.bounds`. The widget lays itself out // with viewport units, so a WebView that spans the full screen puts its // header under the notch / status bar and its composer under the home // indicator. Constraining the WebView is what moves the content — the // page ignores any inset you try to apply inside it. NSLayoutConstraint.activate([ webView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), webView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), webView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), webView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), ]) navigationItem.rightBarButtonItem = UIBarButtonItem( barButtonSystemItem: .close, target: self, action: #selector(closeChat)) var components = URLComponents(string: "\(Config.widgetBaseURL)/widget.html")! components.queryItems = [URLQueryItem(name: "website_token", value: Config.websiteToken)] webView.load(URLRequest(url: components.url!)) } @objc private func closeChat() { dismiss(animated: true) } // MARK: - Host → widget private func send(_ payload: [String: Any]) { guard let data = try? JSONSerialization.data(withJSONObject: payload), let json = String(data: data, encoding: .utf8), let literal = jsStringLiteral(Config.messagePrefix + json) else { return } webView.evaluateJavaScript( "window.dispatchEvent(new MessageEvent('message', { data: \(literal) }));") } /// Escapes a Swift string into a JavaScript string literal. private func jsStringLiteral(_ value: String) -> String? { guard let data = try? JSONSerialization.data(withJSONObject: [value]), let wrapped = String(data: data, encoding: .utf8) else { return nil } return String(wrapped.dropFirst().dropLast()) // strip the [ ] of the array } private func onWidgetLoaded() { send(["event": "inner-document-width", "documentWidth": Config.closeButtonWidthLimit]) send(["event": "toggle-close-button", "showClose": false]) send(["event": "set-user", "identifier": userIdentifier, "user": user]) send(["event": "push-event", "eventName": "webwidget.triggered"]) } /// Clear the chat session — call this when the user signs out. func resetChatSession() { webView.evaluateJavaScript(""" try { localStorage.removeItem('cw_conversation'); } catch (e) {} window.location.reload(); """) } } // MARK: - Widget → host extension HiverChatViewController: WKScriptMessageHandler { func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) { guard let raw = message.body as? String, raw.hasPrefix(Config.messagePrefix), let data = raw.dropFirst(Config.messagePrefix.count).data(using: .utf8), let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return } switch parsed["event"] as? String { case "loaded": onWidgetLoaded() case "close-widget": closeChat() default: break } } } // MARK: - Navigation extension HiverChatViewController: WKNavigationDelegate, WKUIDelegate { /// Keep the chat on the widget origin; attachments and links open in Safari. func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { guard let url = navigationAction.request.url else { decisionHandler(.allow); return } if url.absoluteString.hasPrefix(Config.widgetBaseURL) || url.scheme == "about" { decisionHandler(.allow) } else { UIApplication.shared.open(url) decisionHandler(.cancel) } } /// `target="_blank"` links also go to Safari. func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? { if let url = navigationAction.request.url { UIApplication.shared.open(url) } return nil } } Add to Info.plist if users will attach files: XML <key>NSPhotoLibraryUsageDescription</key> <string>Attach photos to your support conversation.</string> <key>NSCameraUsageDescription</key> <string>Take a photo to attach to your support conversation.</string> <key>NSPhotoLibraryUsageDescription</key> <string>Attach photos to your support conversation.</string> <key>NSCameraUsageDescription</key> <string>Take a photo to attach to your support conversation.</string> 4.3 Flutter Uses flutter_inappwebview (document-start user scripts and a JS handler bridge, both of which the integration needs) and url_launcher . YAML # pubspec.yaml dependencies: flutter_inappwebview: ^6.1.5 url_launcher: ^6.3.0 Dart import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter_inappwebview/flutter_inappwebview.dart'; import 'package:url_launcher/url_launcher.dart'; const widgetBaseUrl = 'https://chat-widget.hiverhq.com/chat-widget'; const websiteToken = 'YOUR_WEBSITE_TOKEN'; const messagePrefix = 'chatwoot-widget:'; /// The widget renders its own close button only below 667 logical px. /// Reporting exactly 667 suppresses it so the Flutter AppBar owns closing. const closeButtonWidthLimit = 667; /// Tells the widget it is inside an app WebView and routes its outgoing /// messages to the `hiverChat` JS handler registered below. const bridgeShim = ''' (function () { if (window.ReactNativeWebView) return; window.ReactNativeWebView = { postMessage: function (message) { window.flutter_inappwebview.callHandler('hiverChat', String(message)); } }; })(); '''; /// Full-screen Hiver chat. Push it from your own launcher: /// Navigator.push(context, MaterialPageRoute(builder: (_) => const HiverChatPage())); class HiverChatPage extends StatefulWidget { const HiverChatPage({ super.key, this.userIdentifier = 'user_123', this.user = const {'name': 'Jane Doe', 'email': 'jane@acme.com'}, }); final String userIdentifier; final Map<String, String> user; @override State<HiverChatPage> createState() => _HiverChatPageState(); } class _HiverChatPageState extends State<HiverChatPage> { InAppWebViewController? _controller; @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('Support'), actions: [ IconButton( icon: const Icon(Icons.close), onPressed: () => Navigator.of(context).maybePop(), ), ], ), // SafeArea matters on the bottom edge: the AppBar already covers the top, // but without this the composer sits under the home indicator / gesture // bar. The widget sizes itself with viewport units, so the WebView has to // be the thing that shrinks — padding inside the page won't do it. body: SafeArea( child: InAppWebView( initialUrlRequest: URLRequest( url: WebUri('$widgetBaseUrl/widget.html' '?website_token=${Uri.encodeComponent(websiteToken)}'), ), initialSettings: InAppWebViewSettings( javaScriptEnabled: true, // The widget keeps its session token in localStorage. domStorageEnabled: true, useShouldOverrideUrlLoading: true, supportMultipleWindows: false, javaScriptCanOpenWindowsAutomatically: false, mediaPlaybackRequiresUserGesture: false, allowsInlineMediaPlayback: true, ), // Document-start injection: the shim must exist before the widget's // scripts decide whether a host is present. initialUserScripts: UnmodifiableListView([ UserScript( source: bridgeShim, injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START, ), ]), onWebViewCreated: (controller) { _controller = controller; controller.addJavaScriptHandler( handlerName: 'hiverChat', callback: (args) => _onWidgetMessage(args.first as String), ); }, // Keep the chat on the widget origin; attachments and links open in // the device browser so the conversation is never navigated away. shouldOverrideUrlLoading: (controller, action) async { final url = action.request.url?.toString() ?? ''; if (url.startsWith(widgetBaseUrl) || url == 'about:blank') { return NavigationActionPolicy.ALLOW; } await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication); return NavigationActionPolicy.CANCEL; }, ), ), ); } void _onWidgetMessage(String raw) { if (!raw.startsWith(messagePrefix)) return; final message = jsonDecode(raw.substring(messagePrefix.length)) as Map<String, dynamic>; switch (message['event']) { case 'loaded': _onWidgetLoaded(); break; case 'close-widget': if (mounted) Navigator.of(context).maybePop(); break; } } void _onWidgetLoaded() { _send({ 'event': 'inner-document-width', 'documentWidth': closeButtonWidthLimit, }); _send({'event': 'toggle-close-button', 'showClose': false}); _send({ 'event': 'set-user', 'identifier': widget.userIdentifier, 'user': widget.user, }); _send({'event': 'push-event', 'eventName': 'webwidget.triggered'}); } void _send(Map<String, dynamic> payload) { // jsonEncode produces a correctly escaped JS string literal. final literal = jsonEncode(messagePrefix + jsonEncode(payload)); _controller?.evaluateJavascript( source: "window.dispatchEvent(new MessageEvent('message', " "{ data: $literal }));", ); } /// Clear the chat session — call this when the user signs out. Future<void> resetChatSession() async { await _controller?.evaluateJavascript(source: ''' try { localStorage.removeItem('cw_conversation'); } catch (e) {} window.location.reload(); '''); } } Using webview_flutter instead? It has no portable document-start script injection, so the shim can only be injected from onPageStarted , which races the widget's own bootstrap on slow devices. If you must use it, register a JavaScriptChannel named hiverChat (channels are installed at document start) and inject the shim from onPageStarted as a best effort. 4.4 React Native A production-ready component ships with this repo: mobile/react-native/HiverChat.js . Copy it into your project, it already handles the bridge, the native header, attachment and link handling, viewport pinning, and session reset. react-native-webview injects window.ReactNativeWebView itself, so no shim is needed. Bash # Expo npx expo install react-native-webview # Bare React Native yarn add react-native-webview && (cd ios && pod install) JSX import React, { useEffect, useRef, useState } from 'react'; import { BackHandler, Button, View } from 'react-native'; import HiverChat from './HiverChat'; const WIDGET_BASE_URL = 'https://chat-widget.hiverhq.com/chat-widget'; const WEBSITE_TOKEN = 'YOUR_WEBSITE_TOKEN'; export default function App() { const chatRef = useRef(null); const [chatVisible, setChatVisible] = useState(false); // Android hardware back: close the chat instead of leaving the app. useEffect(() => { const sub = BackHandler.addEventListener('hardwareBackPress', () => { if (!chatVisible) return false; setChatVisible(false); return true; }); return () => sub.remove(); }, [chatVisible]); return ( <View style={{ flex: 1 }}> {/* your app */} <Button title="Chat with support" onPress={() => setChatVisible(true)} /> <HiverChat ref={chatRef} visible={chatVisible} websiteToken={WEBSITE_TOKEN} widgetBaseUrl={WIDGET_BASE_URL} user={{ identifier: 'user_123', name: 'Jane Doe', email: 'jane@acme.com', }} headerTitle="Support" onClose={() => setChatVisible(false)} onLoaded={() => console.log('[HiverChat] loaded')} /> </View> ); } // On logout: // chatRef.current?.reset(); If you would rather wire the WebView yourself, the essential parts are below. Note that HiverChat.js wraps its WebView in a SafeAreaView . Do the same, or the widget header lands under the status bar / notch and the composer under the home indicator. The WebView itself has to shrink; the widget sizes itself with viewport units and ignores any inset applied inside the page. JSX const MESSAGE_PREFIX = 'chatwoot-widget:'; /** The widget renders its own close button only below this reported width. */ const WIDGET_CLOSE_BUTTON_WIDTH_LIMIT = 667; // host → widget const postToWidget = payload => { const message = `${MESSAGE_PREFIX}${JSON.stringify(payload)}`; webViewRef.current?.injectJavaScript( `window.dispatchEvent(new MessageEvent('message', { data: ${JSON.stringify( message )} })); true;` ); }; // widget → host const handleMessage = event => { const { data } = event.nativeEvent; if (typeof data !== 'string' || !data.startsWith(MESSAGE_PREFIX)) return; const message = JSON.parse(data.slice(MESSAGE_PREFIX.length)); if (message.event === 'loaded') handleWidgetLoaded(); if (message.event === 'close-widget') onClose(); }; /** * The init sequence. Same four messages as the Android, iOS, and Flutter * snippets: mobile layout, close-button ownership, user identity, open event. */ const handleWidgetLoaded = () => { // Reporting exactly 667 suppresses the widget's own close button so the // native header owns closing. Pass the real width + showClose: true to use // the widget's button instead. postToWidget({ event: 'inner-document-width', documentWidth: WIDGET_CLOSE_BUTTON_WIDTH_LIMIT, }); postToWidget({ event: 'toggle-close-button', showClose: false }); const { identifier, ...userFields } = user; postToWidget({ event: 'set-user', identifier, user: userFields }); postToWidget({ event: 'push-event', eventName: 'webwidget.triggered' }); }; <WebView ref={webViewRef} source={{ uri: `${widgetBaseUrl}/widget.html?website_token=${token}` }} onMessage={handleMessage} javaScriptEnabled domStorageEnabled /* the widget's session lives in localStorage */ sharedCookiesEnabled setSupportMultipleWindows={false} onShouldStartLoadWithRequest={keepOnWidgetOrigin} />; One difference from the shipped component. HiverChat.js 's handleWidgetLoaded sends three of these four messages: it omits push-event: webwidget.triggered . Nothing breaks without it (first-open setup also runs from the widget's own mounted hook), but the widget then skips refreshing admin "Widget design" changes on open and records no webwidget.triggered event for reporting. If you want parity with the native snippets, add it to handleWidgetLoaded and send it again on each reopen. Component props, ref methods, and behaviour notes: see the HiverChat reference and the React Native installation guide. 5. Options reference 5.1 URL parameters Everything about which inbox and which session the WebView loads is set on the widget.html URL. Title Description Title Description Parameter Required Value What it does website_token yes Your inbox's website token Selects the chat inbox. Persisted to localStorage.websiteToken and attached to every API request. Without it the widget cannot configure itself. cw_conversation no A previously issued auth token Resumes an existing contact/conversation instead of creating a new one. Read at mount, stored in localStorage.cw_conversation , and sent as the X-Auth-Token header. Use it when you persist the session natively (e.g. to carry a conversation across a WebView data wipe). locale no ISO 639-1 code, e.g. fr Sets the widget language. Applied only if the language is enabled for the inbox ( enabled_languages ); otherwise ignored silently. Also settable at runtime with the set-locale message. is_test_widget no true Marks this as the admin-panel test widget: it suppresses the "script is live on your website" callback to the server. Do not set it in a shipping app — your inbox will keep reporting the widget as not installed. automation no true Internal flag for Hiver's automation suites; relaxes postMessage origin checks. Never use in production. Example: Plain text https://chat-widget.hiverhq.com/chat-widget/widget.html?website_token=abc123&locale=fr https://chat-widget.hiverhq.com/chat-widget/widget.html?website_token=abc123&locale=fr 5.2 Messages you send to the widget These are the runtime "options" — the mobile equivalent of the web SDK's window.$chatwoot API. Send them only after loaded . Title Description Title Description Event Payload What it does Use on mobile set-user identifier , user: { name, email, phone_number, avatar_url, identifier_hash } Identifies the contact and updates it in Hiver. If identifier_hash is present, the widget also clears and refetches conversations so the verified identity's history loads. Yes — send on loaded and again whenever the signed-in user changes. inner-document-width documentWidth (number) Sets the widget's notion of viewport width. Gates the widget's own header actions (close button, "End chat" menu), which render only when 0 < documentWidth < 667 . Yes — this is how you choose between the widget's close button and your own. toggle-close-button showClose (boolean) Switches the widget into its mobile layout and shows/hides its close affordance. Yes — pair it with inner-document-width . push-event eventName (string) Records a widget event. webwidget.triggered is special: it refreshes the widget's appearance and Hiver config, runs first-open setup, and marks the widget as user-triggered (which suppresses proactive campaign views). Yes — send webwidget.triggered each time the user opens the chat. widget-visible — Scrolls the conversation to the newest message. Yes — send when you re-show a WebView that was hidden. on-bubble-close — Marks the user's "last seen" timestamp, which clears the unread count. Yes — send when the user closes the chat. set-locale locale Changes the language at runtime. Applied only if enabled for the inbox. Yes, if your app has a language switcher. set-custom-attributes customAttributes: { … } Sets contact custom attributes in Hiver (plan, account ID, app version…). Must contain at least one key. Yes — useful for routing and agent context. delete-custom-attribute customAttribute (string) Clears one custom attribute (sets it to null ). Yes. change-url referrerURL , referrerHost , disableCampaigns (boolean) Records where the user is and initialises proactive campaigns (nudges) for that URL. With disableCampaigns: true the campaign fetch is skipped and only referrer tracking happens. Optional. Send a logical screen URL (e.g. myapp://checkout ) if you use campaigns; otherwise send it with disableCampaigns: true , or skip it entirely. config-set locale , position , hideMessageBubble , showPopoutButton , widgetMargin , documentWidth , widgetLocationUrl The aggregate init message the web SDK sends. On mobile, position , hideMessageBubble , showPopoutButton , and widgetMargin describe host-page chrome you don't have. Not needed — send the individual messages above instead. If you do send it, only locale , documentWidth , and widgetLocationUrl have any effect. set-unread-view / unset-unread-view — Switches the widget to/from the compact "unread messages" peek used above the web launcher bubble. No — this is a web-launcher pattern with no equivalent in a full-screen mobile chat. Payload shape reminder — the event key sits at the top level alongside the payload, not nested: JSON { "event": "set-user", "identifier": "user_123", "user": { "name": "Jane" } 5.3 User identification Sent with set-user . identifier is a top-level key; everything else lives under user . Title Description Title Field Required Notes identifier yes Stable unique ID for the user in your system. Anything that doesn't change between sessions. name no Display name your agents see. email no The user's email address. phone_number no E.164 recommended. avatar_url no Publicly reachable image URL. identifier_hash no Server-side HMAC of the identifier. Required if identity validation is enabled on your inbox. Compute it on your backend — never ship the signing key in the app. Supplying it also triggers a conversation refetch so the verified user's history loads. At least one of name , email , or avatar_url should be present for the contact to be meaningful to agents. 5.4 Web-embed options (for reference) You don't need these for a WebView integration, but they're the same widget, so they're worth knowing — and they do apply if you choose to load your own HTML page in the WebView and embed the widget with the SDK script. The web embed configures itself through window.hiverChatWidgetSettings before calling chatwootSDK.run({ websiteToken, baseUrl }) : Title Description Title Description Setting Type Default Effect position 'left' | 'right' 'right' Which edge the launcher and panel sit on. Overridden by the inbox's widget_position config if set. type 'standard' | 'expanded_bubble' 'standard' Launcher style; the expanded bubble shows launcherTitle next to the icon. launcherTitle string '' Text on the expanded launcher bubble. locale ISO 639-1 inbox default Widget language. hideMessageBubble boolean false Hides the launcher bubble entirely — you open the widget yourself with window.$chatwoot.toggle() . showPopoutButton boolean false Shows the "open in new window" button in the widget header. closeOnOutsideClick boolean false Closes the widget when the user clicks the host page outside it. disableCampaigns boolean false Skips proactive campaign (nudge) fetching — useful for support-only launchers in SPAs where URL changes would otherwise re-fetch campaigns repeatedly. widgetMargin { left, right, top, bottom } in px null Offsets the launcher and panel from the viewport edges. Horizontal values are absolute; vertical values are added to the SDK's defaults. Runtime JS API on window.$chatwoot (aliased as window.$hiverChatWidget ): toggle() , setUser(identifier, user) , setCustomAttributes(attrs) , deleteCustomAttribute(key) , setLabel(label) , removeLabel(label) , setLocale(locale) , setWidgetMargin(margin) , setCloseOnOutsideClick(enabled) , reset() . Two of these have no message-bridge equivalent, so they are unavailable in a raw WebView integration: setLabel and removeLabel (the widget has no handler for set-label / remove-label ). Use custom attributes instead. 6. Events reference 6.1 Events the widget sends in WebView mode When the widget detects an app WebView it emits exactly two events through window.ReactNativeWebView.postMessage . Both arrive as chatwoot-widget:{…} strings. Title Description Title Description Event Payload Meaning Your responsibility loaded config: { authToken, channelConfig } The widget fetched its config, opened its live connection, and registered its host-message listener. This is the earliest moment it will accept messages. Send your init sequence ( inner-document-width , toggle-close-button , set-user , push-event: webwidget.triggered ); reveal the WebView / dismiss your loading state; optionally persist config.authToken natively so you can resume the session later via the cw_conversation URL parameter. close-widget also carries type: 'close-widget' for backward compatibility The user tapped the widget's own close button in the chat header. Only reachable if you enabled it by reporting a document width below 667. Hide or dismiss your chat container. The widget does not hide itself — nothing happens visually unless you act. Also send on-bubble-close so unread state is cleared. config.channelConfig is the inbox's widget configuration — brand name, widget colour, avatar, pre-chat form settings, enabled features, enabled languages, CSAT settings. Read it if you want to theme your native chrome (header colour, title) to match what admins configured in Hiver. Note: in WebView mode the loaded payload contains authToken and channelConfig only. The iframe embed additionally receives hiverConfig (widget colour and position used to style the launcher bubble) — that is launcher chrome the mobile host doesn't render. 6.2 Recommended host responsibilities A minimal but complete lifecycle: Title Description Moment in your app What to send / do User taps your launcher Show the WebView. If it is already loaded, send widget-visible (scroll to newest) and push-event: webwidget.triggered . loaded received inner-document-width → toggle-close-button → set-user → optional set-custom-attributes → push-event: webwidget.triggered . Signed-in user changes set-user again. No reload needed. App language changes set-locale . User closes the chat (your button, back gesture, or close-widget ) Hide the WebView (don't destroy it) and send on-bubble-close . User signs out Clear localStorage.cw_conversation and reload the WebView. User navigates to a different screen (only if you use campaigns) change-url with a logical URL for that screen. 6.3 Events that only fire in the web (iframe) embed For completeness — these are guarded by an iframe check or sent to window.parent , so they will not reach a WebView host. Knowing them helps when you read the widget source or compare mobile behaviour with web. Title Description Event (widget → host) Purpose / host responsibility on web setBubbleLabel Supplies the localised launcher label; the SDK writes it into the expanded bubble. toggleBubble The user pressed close inside the widget; the SDK collapses the panel. (In WebView mode this is close-widget instead.) setUnreadMode An agent replied while the widget was closed; the SDK opens the compact unread peek above the launcher. resetUnreadMode Unread state consumed; the SDK returns to the normal launcher. unreadMessageShown Reports the unread peek's height and count so the SDK can size the holder. unreadMessageClicked The user tapped the unread peek; the SDK expands the panel to full height. setCampaignMode A proactive campaign (nudge) is ready; the SDK opens the campaign peek. nudgeClicked The user tapped the nudge; the SDK expands the widget and echoes onNudgeClick back. testWidgetConversationStarted Fired for the admin-panel test widget when the first conversation is created; the host page shows the "Your message arrived in Hiver" confirmation. The web SDK also sends three fire-and-forget notifications into the widget — onChatWidgetLoaded , onBubbleClick , onNudgeClick . They have no functional handler; the widget only maps them to product-analytics events. You don't need to send them from a mobile host. 6.4 Host-page window events (web embed) On a web page the SDK dispatches CustomEvent s on window for your site's code. They are not part of the mobile bridge, but they're the same widget's public event surface: Title Description Title Event name detail When hiver:widget-ready — The widget finished loading and was configured. Safe point to call window.$chatwoot.setUser(...) . hiver:chat-widget-toggle { isOpen: boolean } The widget was opened or closed, for any reason. Useful for analytics or pausing your own UI. hiverChatTestConversationStarted — Admin-panel test widget only: the first test conversation was create 7. Behaviour you need to handle Session persistence. The widget stores its auth token in localStorage.cw_conversation . Keep DOM storage enabled and use a persistent data store (Android domStorageEnabled , iOS WKWebsiteDataStore.default() ) and the conversation survives open/close cycles and app restarts. Capture config.authToken from loaded if you also want to restore a session after clearing WebView data, by passing it back as ?cw_conversation= . Logout. Clear the token and reload: JavaScript try { localStorage.removeItem('cw_conversation'); } catch (e) {} window.location.reload(); Otherwise the next person to sign in on that device inherits the previous user's conversation. Keep the WebView mounted, just hidden. Destroying it on close drops the websocket and forces a full reconfigure on reopen. Hide it (opacity/visibility, or keep the activity/controller alive) and the conversation reopens exactly where the user left it. Closing the chat — pick one owner. The widget's own close button renders only when the reported document width is > 0 and < 667 , and it does nothing by itself: it emits close-widget and waits for you. Two workable setups: Native chrome owns it (recommended). Report documentWidth: 667 and showClose: false . The widget's button never appears, and your toolbar / back gesture is the single way out — which also survives the widget failing to load, and works on tablets. The widget owns it. Report the real width and showClose: true , render no native header, and dismiss on close-widget . Clamp the value you report to 666 ( min(realWidth, 666) ) so tablets and large foldables stay under the bound — without the clamp the button disappears above 667pt and, with no native header to fall back on, the user is trapped. Don't do both, or users see two close buttons. Android hardware back. Intercept it while the chat is visible and close the chat instead of leaving the screen. Links and attachments. Everything the user taps in a conversation — images, files, links in agent replies — should open in the device browser, not navigate the WebView. If the WebView navigates away, the chat is gone. All four snippets above enforce this by allowing only the widget origin and handing everything else to the system browser. Uploads. The composer has a file picker. On Android you must implement WebChromeClient.onShowFileChooser or nothing happens when the user taps the paperclip. iOS and Flutter handle it natively, but iOS needs the photo/camera Info.plist entries. Accepted types are images, documents, archives, audio and video ( .png , .jpg , .pdf , .docx , .xlsx , .zip , .mp4 , …; see src/shared/helpers/FileHelper.js ), up to the widget's 40 MB per-file limit. Keyboard and viewport. iOS zooms the page when a text input is focused, which pushes the chat out of view. Pin the viewport at document end (the viewportShim in the iOS snippet; INJECTED_JS in the React Native component). Also respect safe areas so the composer isn't under the home indicator. Offline. The widget refetches conversations automatically when the WebView regains connectivity — no action needed. 8. Verification checklist The chat loads and your loaded handler fires. (Log it — if it never fires, your shim isn't reaching the page at document start.) Send a message. It appears in your Hiver chat inbox, attributed to the user you passed in set-user — not as an anonymous visitor. Reply from Hiver. The reply appears in the app within a second or two (websocket is connected). Close and reopen the chat. History is intact and scrolled to the newest message. Kill the app and relaunch. History is still there. Tap an attachment and a link in an agent reply. Both open in the device browser; the chat is still loaded when you come back. Attach a photo from the composer. On Android confirm the picker opens. Check the safe area on a device with a notch or gesture navigation: the widget header sits below the status bar and the composer above the navigation bar / home indicator. Then focus the composer and confirm the keyboard doesn't cover it. The WebView is what must shrink — the widget uses viewport units and ignores insets applied inside the page. (Android: insets on a container view; iOS: safeAreaLayoutGuide ; Flutter: SafeArea ; React Native: SafeAreaView .) Rotate the device mid-conversation. The conversation survives — on Android that needs configChanges , or the activity recreates and reloads the WebView. Test on a tablet. There is still exactly one working way to close the chat. Sign out, then sign in as a different user. The new user sees an empty conversation. Switch languages (if you support that) and confirm the widget follows. 9. Troubleshooting Title Description Title Symptom Cause Fix loaded never fires; messages you send do nothing The widget didn't detect a host, so it never registered its listener. Android / iOS / Flutter: inject the ReactNativeWebView shim at document start — not onPageFinished . React Native: confirm you're on react-native-webview (it provides the object itself). Verify either way with typeof window.ReactNativeWebView in a debug console. Widget's close button never appears documentWidth is still 0 (its default) or ≥ 667. Send inner-document-width with a value under 667, plus toggle-close-button: { showClose: true } . Messages sent right after page load are ignored They were sent before loaded . Messages are dropped, not queued. Send everything from your loaded handler. Conversation resets on every open The WebView is being destroyed, or DOM storage is off / non-persistent. Keep the WebView mounted; enable domStorageEnabled (Android) and use WKWebsiteDataStore.default() (iOS). Conversations arrive as anonymous visitors set-user wasn't delivered, or identifier was missing. Send set-user from the loaded handler with a non-empty identifier . Tapping an attachment blanks the chat The WebView navigated to the file URL. Intercept navigation and open non-widget-origin URLs externally. Attachment button does nothing on Android No onShowFileChooser implementation. Add a WebChromeClient that launches the system picker (see the Kotlin snippet). Chat zooms and overflows when typing (iOS) Auto-zoom on input focus. Inject the viewport shim pinning maximum-scale=1 . Hiver reports the widget as "not installed" The app is loading the widget with is_test_widget=true . Remove that parameter from production builds. Injected script throws a syntax error with certain user names Payload interpolated into JS by hand. Build the JS string literal with your JSON encoder ( JSONObject.quote , JSONSerialization , jsonEncode , JSON.stringify ). Duplicate close buttons Native header and the widget's button are both enabled. Pick one owner (see section 7)