Support API

WebView recipes: React Native, iOS and Android

Copy-paste screens that open the Gauge support chat in your app. Each one does the same five things as the Flutter package (gauge_support_webview):

  1. asks your backend for a fresh single-use link on every open (POST /v1/support/sessions, target: "chat"; any quick start has the endpoint), and adds embed=1;
  2. keeps navigation on your portal host and opens everything else in the system browser;
  3. grants the microphone (in-app calls) and camera only to your portal host;
  4. routes <input type="file"> to the native picker and keeps the composer above the keyboard;
  5. signs out by loading /logout?embed=1 in the same WebView and closing on /logout/done.

No Gauge key or secret is in any of them. Background and the reasons behind each rule: mobile.md.

Replace https://api.yourapp.com/support/chat-link with your own endpoint, and send your own auth.


React Native (react-native-webview ≥ 13)

// SupportChat.tsx
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { ActivityIndicator, Linking, PermissionsAndroid, Platform, Pressable, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import WebView, { type WebViewNavigation } from 'react-native-webview';
import type { ShouldStartLoadRequest } from 'react-native-webview/lib/WebViewTypes';

async function fetchChatLink(appToken: string): Promise<string> {
  const res = await fetch('https://api.yourapp.com/support/chat-link', {
    headers: { authorization: `Bearer ${appToken}` },
  });
  if (!res.ok) throw new Error(`chat link: ${res.status}`);
  const { url } = (await res.json()) as { url: string };
  const link = new URL(url);
  if (link.protocol !== 'https:') throw new Error('chat link must be https');
  return url + (link.search ? '&' : '?') + 'embed=1'; // appended as text: the token stays byte-exact
}

export function SupportChat({ appToken, onSignedOut, signOutRequested }: {
  appToken: string;
  onSignedOut: () => void;
  /** Flip to true when the user signs out of your app while this screen is open. */
  signOutRequested?: boolean;
}) {
  const [link, setLink] = useState<string | null>(null);
  const [error, setError] = useState(false);
  const web = useRef<WebView>(null);
  const portalHost = link ? new URL(link).host : null;

  const open = useCallback(async () => {
    setError(false);
    setLink(null);
    try {
      if (Platform.OS === 'android') {
        // In-app calls need the microphone; the page asks only when the customer presses Call.
        await PermissionsAndroid.request(PermissionsAndroid.PERMISSIONS.RECORD_AUDIO);
      }
      setLink(await fetchChatLink(appToken)); // fresh on every open: a link is single-use
    } catch {
      setError(true);
    }
  }, [appToken]);

  useEffect(() => { void open(); }, [open]);

  useEffect(() => {
    if (signOutRequested && portalHost) {
      web.current?.injectJavaScript(`location.assign('https://${portalHost}/logout?embed=1'); true;`);
    }
  }, [signOutRequested, portalHost]);

  const onShouldStart = (req: ShouldStartLoadRequest) => {
    const url = new URL(req.url);
    if (url.protocol === 'https:' && url.host === portalHost) return true;
    if (url.protocol === 'https:' || url.protocol === 'mailto:' || url.protocol === 'tel:') {
      if (req.isTopFrame) void Linking.openURL(req.url);
    }
    return false; // javascript:, intent:, file:, http: never load in the chat
  };

  const onNav = (nav: WebViewNavigation) => {
    if (portalHost && new URL(nav.url).host === portalHost && new URL(nav.url).pathname === '/logout/done') {
      onSignedOut();
    }
  };

  if (error) {
    return (
      <View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', gap: 12 }}>
        <Text>Support chat didn't open.</Text>
        <Pressable onPress={open}><Text style={{ fontWeight: '600' }}>Try again</Text></Pressable>
      </View>
    );
  }
  if (!link) return <ActivityIndicator style={{ flex: 1 }} />;

  return (
    <SafeAreaView style={{ flex: 1 }} edges={['bottom', 'left', 'right']}>
      <WebView
        ref={web}
        source={{ uri: link }}
        originWhitelist={['https://*']}
        onShouldStartLoadWithRequest={onShouldStart}
        onNavigationStateChange={onNav}
        javaScriptEnabled
        domStorageEnabled
        thirdPartyCookiesEnabled={false}
        sharedCookiesEnabled={false}
        setSupportMultipleWindows={false}
        allowsInlineMediaPlayback
        mediaPlaybackRequiresUserGesture={false}
        mediaCapturePermissionGrantType="grantIfSameHostElsePrompt"
        allowFileAccess={false}
        onError={() => setError(true)}
      />
    </SafeAreaView>
  );
}
  • Android manifest: RECORD_AUDIO, MODIFY_AUDIO_SETTINGS, CAMERA (if the picker offers it), and android:windowSoftInputMode="adjustResize". Don't wrap the WebView in a KeyboardAvoidingView.
  • iOS Info.plist: NSMicrophoneUsageDescription, NSCameraUsageDescription, NSPhotoLibraryUsageDescription.
  • react-native-webview grants the page's microphone request on Android only when your app holds RECORD_AUDIO; grantIfSameHostElsePrompt does the same on iOS 15+.
  • File inputs open the native picker on both platforms.

iOS (WKWebView, Swift, iOS 15+)

// SupportChatViewController.swift
import UIKit
import WebKit

final class SupportChatViewController: UIViewController, WKNavigationDelegate, WKUIDelegate {
    /// YOUR backend: returns { "url": "https://<portal>/auth/handoff?token=…" } for the signed-in user.
    var fetchChatLink: () async throws -> URL = { throw URLError(.userAuthenticationRequired) }
    var onSignedOut: () -> Void = {}

    private var webView: WKWebView!
    private var portalHost: String?

    override func viewDidLoad() {
        super.viewDidLoad()
        title = "Support"
        let config = WKWebViewConfiguration()
        config.websiteDataStore = .default()             // keeps the chat session across launches
        config.allowsInlineMediaPlayback = true          // the agent's call audio plays in place
        config.mediaTypesRequiringUserActionForPlayback = []
        webView = WKWebView(frame: .zero, configuration: config)
        webView.navigationDelegate = self
        webView.uiDelegate = self
        webView.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(webView)
        NSLayoutConstraint.activate([            // inside the safe area: nothing under the home indicator
            webView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
            webView.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor),
            webView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            webView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
        ])
        open()
    }

    func open() {
        Task { @MainActor in
            do {
                let minted = try await fetchChatLink()   // fresh on every open: a link is single-use
                guard minted.scheme == "https", let host = minted.host else { throw URLError(.badURL) }
                portalHost = host
                var text = minted.absoluteString       // appended as text: the token stays byte-exact
                text += (minted.query == nil ? "?" : "&") + "embed=1"
                webView.load(URLRequest(url: URL(string: text)!))
            } catch {
                showRetry()
            }
        }
    }

    /// Call when the user signs out of your app with this screen open.
    func signOut() {
        guard let host = portalHost else { return }
        webView.load(URLRequest(url: URL(string: "https://\(host)/logout?embed=1")!))
    }

    // Navigation: your portal host only; the rest goes to the system.
    func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction,
                 decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
        guard let url = action.request.url else { return decisionHandler(.cancel) }
        if url.scheme == "https", url.host == portalHost {
            if url.path == "/logout/done" { finishSignOut() }
            return decisionHandler(.allow)
        }
        if ["https", "mailto", "tel"].contains(url.scheme ?? ""), action.targetFrame?.isMainFrame != false {
            UIApplication.shared.open(url)
        }
        decisionHandler(.cancel)
    }

    // target=_blank links: never a second WebView.
    func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration,
                 for action: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? {
        if let url = action.request.url { UIApplication.shared.open(url) }
        return nil
    }

    // Microphone (in-app calls) and camera: your portal host only.
    func webView(_ webView: WKWebView, requestMediaCapturePermissionFor origin: WKSecurityOrigin,
                 initiatedByFrame frame: WKFrameInfo, type: WKMediaCaptureType,
                 decisionHandler: @escaping (WKPermissionDecision) -> Void) {
        decisionHandler(origin.protocol == "https" && origin.host == portalHost ? .grant : .deny)
    }

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

    private func finishSignOut() {
        WKWebsiteDataStore.default().removeData(ofTypes: WKWebsiteDataStore.allWebsiteDataTypes(),
                                                modifiedSince: .distantPast) { [weak self] in
            self?.onSignedOut()
        }
    }

    private func showRetry() {
        let alert = UIAlertController(title: "Support chat didn't open",
                                      message: "Check your connection and try again.", preferredStyle: .alert)
        alert.addAction(UIAlertAction(title: "Try again", style: .default) { [weak self] _ in self?.open() })
        present(alert, animated: true)
    }
}

Info.plist: NSMicrophoneUsageDescription, NSCameraUsageDescription, NSPhotoLibraryUsageDescription. <input type="file"> presents the system picker by itself. No WKScriptMessageHandler is added: the page needs no bridge into your app.


Android (WebView, Kotlin)

// SupportChatActivity.kt
import android.Manifest
import android.annotation.SuppressLint
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.webkit.*
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch

class SupportChatActivity : AppCompatActivity() {
    private lateinit var web: WebView
    private var portalHost: String? = null
    private var fileCallback: ValueCallback<Array<Uri>>? = null

    private val pickFile = registerForActivityResult(ActivityResultContracts.GetContent()) { uri ->
        fileCallback?.onReceiveValue(if (uri == null) emptyArray() else arrayOf(uri))
        fileCallback = null
    }
    private val askMic = registerForActivityResult(ActivityResultContracts.RequestPermission()) { open() }

    @SuppressLint("SetJavaScriptEnabled")
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        web = WebView(this)
        setContentView(web) // manifest: android:windowSoftInputMode="adjustResize"
        with(web.settings) {
            javaScriptEnabled = true
            domStorageEnabled = true
            mediaPlaybackRequiresUserGesture = false
            allowFileAccess = false
            allowContentAccess = true          // the picker hands back content:// URIs
            setSupportMultipleWindows(false)
        }
        CookieManager.getInstance().setAcceptThirdPartyCookies(web, false)
        web.webViewClient = object : WebViewClient() {
            override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
                val url = request.url
                if (url.scheme == "https" && url.host == portalHost) {
                    if (url.path == "/logout/done") finishSignOut()
                    return false
                }
                if (request.isForMainFrame && url.scheme in setOf("https", "mailto", "tel")) {
                    startActivity(Intent(Intent.ACTION_VIEW, url))
                }
                return true // javascript:, intent:, file:, http: never load in the chat
            }

            override fun onReceivedError(view: WebView, request: WebResourceRequest, error: WebResourceError) {
                if (request.isForMainFrame) showRetry()
            }
        }
        web.webChromeClient = object : WebChromeClient() {
            // Microphone (in-app calls) and camera: your portal host only, nothing else.
            override fun onPermissionRequest(request: PermissionRequest) {
                val allowed = setOf(PermissionRequest.RESOURCE_AUDIO_CAPTURE, PermissionRequest.RESOURCE_VIDEO_CAPTURE)
                val fromPortal = request.origin.scheme == "https" && request.origin.host == portalHost
                if (fromPortal && request.resources.all { it in allowed }) request.grant(request.resources)
                else request.deny()
            }

            override fun onShowFileChooser(view: WebView, callback: ValueCallback<Array<Uri>>,
                                           params: FileChooserParams): Boolean {
                fileCallback?.onReceiveValue(null)
                fileCallback = callback
                pickFile.launch(params.acceptTypes.firstOrNull { it.isNotBlank() } ?: "*/*")
                return true
            }
        }
        askMic.launch(Manifest.permission.RECORD_AUDIO) // the page asks only when the customer presses Call
    }

    private fun open() = lifecycleScope.launch {
        try {
            val minted = Uri.parse(fetchChatLink())  // YOUR backend; fresh on every open
            require(minted.scheme == "https" && minted.host != null)
            portalHost = minted.host
            val text = minted.toString()            // appended as text: the token stays byte-exact
            web.loadUrl(text + (if (minted.query == null) "?" else "&") + "embed=1")
        } catch (e: Exception) {
            showRetry()
        }
    }

    /** Call when the user signs out of your app with this screen open. */
    fun signOut() {
        portalHost?.let { web.loadUrl("https://$it/logout?embed=1") }
    }

    private fun finishSignOut() {
        CookieManager.getInstance().removeAllCookies(null)
        WebStorage.getInstance().deleteAllData()
        finish()
    }

    private fun showRetry() {
        AlertDialog.Builder(this)
            .setTitle("Support chat didn't open")
            .setMessage("Check your connection and try again.")
            .setPositiveButton("Try again") { _, _ -> open() }
            .show()
    }

    /** YOUR backend call (OkHttp, Ktor, Retrofit…): GET /support/chat-link with your app's auth → "url". */
    private suspend fun fetchChatLink(): String = TODO("call your backend")
}

Manifest: INTERNET, RECORD_AUDIO, MODIFY_AUDIO_SETTINGS, CAMERA (if the picker should offer it), and android:windowSoftInputMode="adjustResize" on the activity. No addJavascriptInterface: the page needs no bridge into your app. Edge-to-edge apps: pad the WebView by the system-bar insets (ViewCompat.setOnApplyWindowInsetsListener) so the composer never sits under the navigation bar.


Check it

Open the screen as a test user, send a message, and confirm in the console that the conversation shows your user's name. Then sign out: the screen should close on /logout/done, and opening it again should mint a new link (one link never opens twice).

On this page