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- You host a wrapper HTML page on your own domain (e.g.
https://app.example.com/support.html). - The wrapper page loads the UserHero portal script the same way a desktop page would.
- Your mobile app opens that URL inside a WebView.
- 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,WebViewand most hybrid frameworks vialoadRequest/loadUrloverloads).
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.
External links
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.decidePolicyForand callUIApplication.shared.open(url)for navigations to other domains. - Android (WebView) — set a
WebViewClientand overrideshouldOverrideUrlLoadingto open external URLs with anIntent.ACTION_VIEW. - React Native — handle
onShouldStartLoadWithRequestand callLinking.openURL(url). - Flutter — set a
NavigationDelegatewithonNavigationRequestand useurl_launcher. - Capacitor / Cordova InAppBrowser — links open in the in-app browser by default; pass
_systemto theopentarget 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 })andMediaRecorderto 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
NSCameraUsageDescriptionandNSMicrophoneUsageDescriptionkeys toios/App/App/Info.plist. - Android — add
<uses-permission android:name="android.permission.CAMERA" />and<uses-permission android:name="android.permission.RECORD_AUDIO" />toandroid/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
| Framework | Photo attachment (capture="environment") | Voice recording (getUserMedia) |
|---|---|---|
| Native iOS / WKWebView | Info.plist NSCameraUsageDescription | iOS 14.3+ with NSMicrophoneUsageDescription |
| Native Android / WebView | Manifest CAMERA + onShowFileChooser | Manifest RECORD_AUDIO + onPermissionRequest |
| React Native WebView | Per-OS permissions + onPermissionRequest (Android) | Per-OS permissions + onPermissionRequest (Android) |
| Flutter | flutter_inappwebview + onPermissionRequest | flutter_inappwebview + onPermissionRequest |
| Capacitor | Info.plist + AndroidManifest permissions | Info.plist + AndroidManifest permissions |
| Cordova / Ionic | config.xml permissions, modern InAppBrowser | config.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 WebView —
settings.domStorageEnabled = true - React Native —
domStorageEnabledprop - 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.