UserHero Docs
Embedded Portal

Mobile and webviews

Embed the customer portal in iOS and Android apps built with native or hybrid frameworks.

Mobile and webviews

The Embedded Portal runs in any modern WebView. The supported way to ship it inside a mobile app — whether you build with Swift, Kotlin, React Native, Flutter, Capacitor, Cordova, or Ionic — is to host a tiny wrapper page on your domain and load it from your app's WebView.

This works identically for every framework, requires no special origin whitelisting, and keeps your HMAC secret on your server where it belongs.

How it works

[Mobile app] → WebView → https://app.example.com/support.html → loads UserHero portal
  1. You host a wrapper HTML page on your own domain (e.g. https://app.example.com/support.html).
  2. The wrapper page loads the UserHero portal script the same way a desktop page would.
  3. Your mobile app opens that URL inside a WebView.
  4. Your wrapper's HTTPS origin is on your project's Allowed origins list, so the portal session starts normally.

No native code change is needed when you tweak the portal — you only update the wrapper.

1. Host the wrapper page

Create a page on your domain at a URL like https://app.example.com/support.html. It needs three things:

  • The UserHero portal snippet.
  • The signed-in user's HMAC payload, generated server-side.
  • Mobile-friendly viewport and safe-area styling.
<!doctype html>
<html>
    <head>
        <meta charset="utf-8" />
        <meta
            name="viewport"
            content="width=device-width, initial-scale=1, viewport-fit=cover"
        />
        <title>Support</title>
        <style>
            html,
            body {
                margin: 0;
                height: 100%;
                background: #fff;
            }
            body {
                padding: env(safe-area-inset-top) env(safe-area-inset-right)
                    env(safe-area-inset-bottom) env(safe-area-inset-left);
            }
        </style>
    </head>
    <body>
        <script>
            window.UserHeroQueue = window.UserHeroQueue || [];
            window.UserHeroQueue.push([
                'init',
                {
                    publicKey: 'YOUR_PUBLIC_KEY',
                    user: {
                        externalUserId: '{{USER_ID}}',
                        email: '{{USER_EMAIL}}',
                        expiresAt: {{EXPIRES_AT}}
                    },
                    userHmac: '{{USER_HMAC}}',
                    launcher: 'none'
                }
            ]);
            window.UserHeroQueue.push(['open']);
        </script>
        <script src="https://userhero.co/portal.js" async></script>
    </body>
</html>

The {{...}} placeholders are rendered by your server on each request. Use launcher: 'none' and call open immediately so the portal fills the WebView instead of showing a floating button.

2. Sign the user on your server

Your wrapper endpoint should authenticate the request the same way the rest of your app does (session cookie, mobile bearer token, etc.), then render the page with a fresh HMAC payload.

See HMAC signing examples for Node, Python, Ruby, PHP, and Go.

Never bundle the HMAC secret into your mobile app — keep it on your server.

3. Whitelist the wrapper origin

In Settings → Embedded Portal → Allowed origins, add the HTTPS origin where your wrapper is hosted (e.g. https://app.example.com). You only need this one origin for every framework and every device.

See Allowed origins for details.

4. Open the wrapper from your app

Pick the snippet for your framework.

Native iOS (Swift, WKWebView)

import WebKit

let webView = WKWebView(frame: view.bounds)
view.addSubview(webView)

let url = URL(string: "https://app.example.com/support.html")!
webView.load(URLRequest(url: url))

Native Android (Kotlin, WebView)

val webView = findViewById<WebView>(R.id.webView)
webView.settings.javaScriptEnabled = true
webView.settings.domStorageEnabled = true
webView.webViewClient = WebViewClient()
webView.loadUrl("https://app.example.com/support.html")

Set domStorageEnabled = true so the SDK can persist its session token between page loads.

React Native

import { WebView } from 'react-native-webview';

export function SupportScreen() {
    return (
        <WebView
            source={{ uri: 'https://app.example.com/support.html' }}
            javaScriptEnabled
            domStorageEnabled
            originWhitelist={['https://*']}
        />
    );
}

Flutter

import 'package:webview_flutter/webview_flutter.dart';

final controller = WebViewController()
  ..setJavaScriptMode(JavaScriptMode.unrestricted)
  ..loadRequest(Uri.parse('https://app.example.com/support.html'));

Capacitor

import { Browser } from '@capacitor/browser';

await Browser.open({ url: 'https://app.example.com/support.html' });

For an in-app WebView instead of the system browser, use @capacitor/inappbrowser and call InAppBrowser.openInWebView(...).

Cordova / Ionic

cordova.InAppBrowser.open(
    'https://app.example.com/support.html',
    '_blank',
    'location=no,toolbar=yes,hidden=no'
);

Requires cordova-plugin-inappbrowser.

Passing user identity from the app to the wrapper

The wrapper needs to know which user is signed in. Three common patterns:

  • Session cookie — if your wrapper is on the same domain as your web app and the user is already signed in there, the WebView sends the cookie automatically.
  • Short-lived URL token — your app calls a backend endpoint to mint a single-use token, then loads https://app.example.com/support.html?token=.... The wrapper exchanges it for the user's session server-side.
  • Authorization header — set custom headers on the request (works in WKWebView, WebView and most hybrid frameworks via loadRequest / loadUrl overloads).

Pick whichever fits your existing mobile auth model.

Mobile gotchas

Safe area on notched devices

Always include viewport-fit=cover and env(safe-area-inset-*) padding in the wrapper, as shown above. Without it, the portal can be hidden under the notch or home indicator on iOS.

When customers tap a link in a ticket reply, you usually want it to open in the system browser, not inside the support WebView.

  • iOS (WKWebView) — implement WKNavigationDelegate.decidePolicyFor and call UIApplication.shared.open(url) for navigations to other domains.
  • Android (WebView) — set a WebViewClient and override shouldOverrideUrlLoading to open external URLs with an Intent.ACTION_VIEW.
  • React Native — handle onShouldStartLoadWithRequest and call Linking.openURL(url).
  • Flutter — set a NavigationDelegate with onNavigationRequest and use url_launcher.
  • Capacitor / Cordova InAppBrowser — links open in the in-app browser by default; pass _system to the open target if you want the system browser.

File uploads (attachments)

iOS WKWebView and modern Android WebView support file picking out of the box, but Android needs you to wire up the chooser intent:

webView.webChromeClient = object : WebChromeClient() {
    override fun onShowFileChooser(
        webView: WebView?,
        filePathCallback: ValueCallback<Array<Uri>>?,
        fileChooserParams: FileChooserParams?
    ): Boolean {
        val intent = fileChooserParams?.createIntent()
        // Launch intent via Activity Result API and pass the result to filePathCallback
        return true
    }
}

For React Native, file uploads work out of the box on iOS; on Android set allowFileAccess and request READ_MEDIA_IMAGES / READ_EXTERNAL_STORAGE. Capacitor and Cordova handle this through their respective camera / file plugins.

If you can't ship file picking, customers can still submit text-only tickets and you can collect attachments through another flow.

Microphone and camera

The portal supports two device-capture features:

  • Take photo from camera — a file input with capture="environment". Opens the system camera UI and returns a still image. This is a file-picker path, not a live stream.
  • Record a voice message — uses navigator.mediaDevices.getUserMedia({ audio: true }) and MediaRecorder to capture audio in the page.

Both require platform-level permission setup in your app, and both only work over HTTPS — your wrapper page already satisfies that.

Native iOS (WKWebView)

Add usage descriptions to Info.plist:

<key>NSCameraUsageDescription</key>
<string>Used to attach photos and screenshots to support tickets.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Used to record voice messages on support tickets.</string>

iOS 14.3 and later allow getUserMedia inside WKWebView automatically once these keys are set. Earlier iOS versions cannot record audio in a WebView — those users will only see the photo and file attachment buttons.

Native Android (WebView)

Declare the permissions in AndroidManifest.xml:

<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />

Request them at runtime before opening the support screen (Android 6+ requires runtime permission checks).

Forward WebView permission requests to the system:

webView.webChromeClient = object : WebChromeClient() {
    override fun onPermissionRequest(request: PermissionRequest) {
        request.grant(request.resources)
    }
    // also keep your onShowFileChooser override here
}

Without onPermissionRequest, Android WebView denies camera and microphone access silently.

React Native WebView

<WebView
    source={{ uri: 'https://app.example.com/support.html' }}
    javaScriptEnabled
    domStorageEnabled
    mediaPlaybackRequiresUserAction={false}
    allowsInlineMediaPlayback
    onPermissionRequest={(event) => {
        // Android only — auto-grant requested resources
        event.nativeEvent.grant(event.nativeEvent.resources);
    }}
/>

On iOS, also add the NSCameraUsageDescription / NSMicrophoneUsageDescription keys to your app's Info.plist. On Android, declare CAMERA and RECORD_AUDIO in AndroidManifest.xml and request them at runtime with PermissionsAndroid.

Flutter

The built-in webview_flutter package does not pass through getUserMedia permission requests. To support voice recording inside the WebView, use flutter_inappwebview instead and grant permissions in its callback:

InAppWebView(
    initialUrlRequest: URLRequest(url: WebUri('https://app.example.com/support.html')),
    initialSettings: InAppWebViewSettings(
        mediaPlaybackRequiresUserGesture: false,
        allowsInlineMediaPlayback: true,
    ),
    onPermissionRequest: (controller, request) async {
        return PermissionResponse(
            resources: request.resources,
            action: PermissionResponseAction.GRANT,
        );
    },
)

Also request the runtime permissions via permission_handler and add the Info.plist keys (iOS) and <uses-permission> entries (Android).

Capacitor

Capacitor's built-in iOS WebView and the Android WebView both support getUserMedia once permissions are configured.

  • iOS — add the NSCameraUsageDescription and NSMicrophoneUsageDescription keys to ios/App/App/Info.plist.
  • Android — add <uses-permission android:name="android.permission.CAMERA" /> and <uses-permission android:name="android.permission.RECORD_AUDIO" /> to android/app/src/main/AndroidManifest.xml. Capacitor's WebView auto-grants these to your origin.

If you'd prefer first-class native UX, use the @capacitor/camera and @capacitor/voice-recorder plugins from your native code and attach the resulting files to the ticket through your own flow — but getUserMedia inside the wrapper works for most apps.

Cordova / Ionic

Declare permissions in config.xml:

<edit-config target="NSCameraUsageDescription" file="*-Info.plist" mode="merge">
    <string>Used to attach photos to support tickets.</string>
</edit-config>
<edit-config target="NSMicrophoneUsageDescription" file="*-Info.plist" mode="merge">
    <string>Used to record voice messages on support tickets.</string>
</edit-config>

<config-file target="AndroidManifest.xml" parent="/manifest">
    <uses-permission android:name="android.permission.CAMERA" />
    <uses-permission android:name="android.permission.RECORD_AUDIO" />
</config-file>

If you load the portal in cordova-plugin-inappbrowser, note that older versions of the plugin do not propagate WebView permission requests. Use a recent version, or open the wrapper in the system browser via _system if you don't need an in-app WebView.

Quick reference

FrameworkPhoto attachment (capture="environment")Voice recording (getUserMedia)
Native iOS / WKWebViewInfo.plist NSCameraUsageDescriptioniOS 14.3+ with NSMicrophoneUsageDescription
Native Android / WebViewManifest CAMERA + onShowFileChooserManifest RECORD_AUDIO + onPermissionRequest
React Native WebViewPer-OS permissions + onPermissionRequest (Android)Per-OS permissions + onPermissionRequest (Android)
Flutterflutter_inappwebview + onPermissionRequestflutter_inappwebview + onPermissionRequest
CapacitorInfo.plist + AndroidManifest permissionsInfo.plist + AndroidManifest permissions
Cordova / Ionicconfig.xml permissions, modern InAppBrowserconfig.xml permissions, modern InAppBrowser

Cookies and local storage

The SDK uses local storage to cache the session token. Make sure your WebView allows it:

  • Android WebViewsettings.domStorageEnabled = true
  • React NativedomStorageEnabled prop
  • Cordova — enabled by default
  • iOS WKWebView, Capacitor, Flutter — enabled by default

Cookies are not required by the SDK, but if your wrapper authenticates via cookie make sure the WebView's cookie store is shared with your auth domain.

Push notifications

UserHero does not push notifications to the embedded portal. Use email notifications, your existing in-app push system, or the JavaScript widget for real-time signals.

Responsive behavior

On screens 540px wide or below, the portal panel expands to a full-screen sheet so customers have room to read and reply. Because you set launcher: 'none' and call open immediately, the portal fills the entire WebView from the moment your screen opens.

What you don't need

You don't need to whitelist capacitor://localhost, ionic://localhost, file://, or any other non-HTTPS scheme. As long as the WebView loads your HTTPS wrapper URL, the request origin is your normal HTTPS domain and the portal works the same as on the web.

Next

On this page