In-app chat (WebView)
Embed the full chat widget in any mobile or desktop app with a WebView, full features, auto-updating.
The fastest way to add customer support inside a native app is to load our hosted full-screen widget page in a WebView. You get the exact same widget as the website, Home, Messages, Help center, Search, emoji, attachments, timestamps, read receipts, resolve, and it updates automatically. Once integrated, the app never needs another release for widget changes.
Quick start
Point a WebView at this URL. For a logged-in user you must include externalId and email (and ideally name), otherwise the agent only ever sees an anonymous visitor. URL-encode every value:
https://<your-domain>/api/widget-embed?appId=YOUR_APP_ID&externalId=USER_ID&email=USER_EMAIL&name=USER_NAMEOnly when the user is not logged in do you drop to the anonymous form:
https://<your-domain>/api/widget-embed?appId=YOUR_APP_ID⚠️ Agent shows an "anonymous visitor" with no email? That is the #1 integration mistake: your WebView URL is missing email/externalId. The server can't invent them, the app must pass them. If the visitor's id looks like anon-…, nothing was passed.
JavaScript / any WebView (non-Flutter)
Electron, desktop shells, React Native, or a native WKWebView where you set the URL yourself, build the URL in JS. URLSearchParams encodes every value for you:
// Build the support URL for the signed-in user before opening the WebView.
function buildSupportUrl(domain, user) {
const params = new URLSearchParams({
appId: 'YOUR_APP_ID',
platform: 'windows', // Strongly recommended: decides which help articles this visitor sees
// ios / macos_appstore / macos / android / windows / web
// locale: userSelectedLanguage, // Optional: only if your app has its own language switch; also add forceLocale: '1'
// Omitted = follow the device language (covers the App Store's 50 localizations)
// — Always pass these for signed-in users, or agents only see an "anonymous visitor" —
externalId: user.id, // The user's unique ID in your system
email: user.email, // The user's email (important)
name: user.name || '', // The user's name (optional)
// hmac: user.hmac, // Optional: computed on your server to prevent impersonation (see HMAC below)
// attrs: JSON.stringify({ plan: user.plan, expiresAt: user.expireAt }), // Optional: custom attributes
});
return `https://${domain}/api/widget-embed?${params.toString()}`;
}
// Usage: load the returned url in your WebView (instead of the old ?appId=...-only URL).
const url = buildSupportUrl('askais.com', currentUser);
myWebView.loadURL(url); // Electron: win.loadURL(url); native: load this URLFlutter (webview_flutter)
Add webview_flutter: ^4.x to pubspec.yaml, then:
import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';
class SupportPage extends StatefulWidget {
const SupportPage({super.key});
@override
State<SupportPage> createState() => _SupportPageState();
}
class _SupportPageState extends State<SupportPage> {
late final WebViewController _controller;
@override
void initState() {
super.initState();
// For signed-in users always include externalId + email, or agents see an "anonymous visitor" (no name / email).
// Pass only appId + locale when the user is not signed in. Uri.https URL-encodes the values.
final uri = Uri.https('askais.com', '/api/widget-embed', {
'appId': 'YOUR_APP_ID',
'platform': Platform.isIOS ? 'ios' : 'android', // Strongly recommended: decides which help articles are visible
// No locale = follow the device language (covers the App Store's 50 localizations).
// Only pass it if your app has its own language switch, together with 'forceLocale': '1':
// 'locale': appSettings.selectedLanguage,
'externalId': user.id, // The user's unique ID in your system (important)
'email': user.email, // The user's email (important)
'name': user.name, // Optional
// 'hmac': hmacFromYourServer, // Optional: computed on your server to prevent impersonation
});
_controller = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setBackgroundColor(Colors.white)
..loadRequest(uri);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Support')),
// Full-screen: drop the appBar and use your own close button.
body: SafeArea(child: WebViewWidget(controller: _controller)),
);
}
}
// Open it from wherever your "Support" button lives:
// Navigator.push(context,
// MaterialPageRoute(builder: (_) => const SupportPage()));For iOS/Android WebViews in other stacks (Swift WKWebView, KotlinWebView, React Native react-native-webview) the idea is identical: load the URL, enable JavaScript.
Native iOS & Android
No SDK to install , you build the same URL and hand it to the WebView. Note the Mac case: an App Store build and a DMG build are the same operating system, so the value has to come from a build flag, not from a runtime check.
// ── iOS (Swift / WKWebView) ────────────────────────────────
// On Mac Catalyst / macOS, set platform to macos_appstore or macos.
#if targetEnvironment(macCatalyst)
let platform = "macos_appstore" // App Store build; a DMG build from your website passes "macos"
#else
let platform = "ios" // iPhone and iPad are both ios
#endif
var comps = URLComponents(string: "https://\(domain)/api/widget-embed")!
comps.queryItems = [
.init(name: "appId", value: "YOUR_APP_ID"),
.init(name: "platform", value: platform),
.init(name: "externalId", value: user.id), // Required for signed-in users
.init(name: "email", value: user.email), // Required for signed-in users
.init(name: "name", value: user.name),
// Only pass these two if your app has its own language switch:
// .init(name: "locale", value: settings.language),
// .init(name: "forceLocale", value: "1"),
]
webView.load(URLRequest(url: comps.url!)) // URLComponents URL-encodes the values
// ── Android (Kotlin / WebView) ─────────────────────────────
val url = Uri.parse("https://$domain/api/widget-embed")
.buildUpon()
.appendQueryParameter("appId", "YOUR_APP_ID")
.appendQueryParameter("platform", "android")
.appendQueryParameter("externalId", user.id)
.appendQueryParameter("email", user.email)
.appendQueryParameter("name", user.name)
// .appendQueryParameter("locale", settings.language)
// .appendQueryParameter("forceLocale", "1")
.build()
webView.settings.javaScriptEnabled = true
webView.settings.domStorageEnabled = true // The widget uses localStorage to remember the visitor
webView.loadUrl(url.toString())URL parameters
appId(required), your inbox App ID.locale, UI language. Omit it in almost every case , the widget then follows the device language reported by the WebView, which is what users expect. The chrome is translated into all 50 App Store Connect localizations; anything outside that list falls back to English. Pass it only when your app has its own in-app language switcher , then send the language the user picked, plusforceLocale=1so it wins over the device setting. Never hard-code a value: a hard-codedzh-Hansis why a Japanese app can end up showing a Chinese widget.platform(strongly recommended), which build the visitor is on. It decides which help articles they see and whether the AI answers under App Store rules. Without it, the platform is guessed from the WebView's user agent: that recognizes iPhone, Android and Windows, but not a Mac App Store build versus a DMG build, and not an iPad in desktop mode. Values:ios(iPhone and iPad),macos_appstore(Mac App Store build),macos(direct DMG build),android,windows,web.externalId, your app's unique user id. Passing it lets the agent recognize the user and merge their history.email,name,avatarUrl, profile shown to agents.hmac, identity signature (see below). Optional.
Hiding content per platform (App Store compliance)
App Store rule 3.1.1 does not allow an iOS app to show external purchase, subscription, or referral content. Every help article and FAQ therefore carries a per-platform visibility setting: open the Help Center or FAQ page in your dashboard and toggle the platform chips on that item. Visitors on a selected platform stop seeing it in the list and cannot open it by direct link.
The AI does not answer from help articles or FAQs; it answers from what you added under Training. Files and Q&A pairs there have the same platform chips, and an item hidden for a platform is not used in answers to visitors on that platform. Hide it in both places if the AI should not mention it either.
Two things decide whether this works reliably. First, your app should send platform; guessing from the user agent is only a fallback. Second, the value has to reflect the build, not just the operating system:
- iPad is not separate. It runs the same iOS app under the same rules, so
ioscovers iPhone and iPad together. - Mac has two builds. A Mac App Store build is bound by rule 3.1.1; a DMG you ship from your own site is not. They are the same operating system, so nothing at runtime can tell them apart , decide it at build time (an Xcode flag, for example) and send
macos_appstoreormacosaccordingly. Get it wrong and either the store build exposes subscription content during review, or the DMG build hides half your help center for no reason.
Changes take effect immediately , the setting lives in your dashboard, not in the app, so you never ship a release to change what is visible.
Verified identity (HMAC, optional)
To prove an externalId really is your logged-in user (and stop impersonation), pass an HMAC signature. Compute it on your server , never put the secret key in the app.
hmac = HMAC-SHA256(secretKey, externalId), output as lowercase hex.- Payload is the
externalId, so always pass it: without one the widget uses its own anonymous id, and a signature over theemailis rejected. secretKeyis your inbox secret (one per inbox, get it from your inbox settings). Withouthmacthe visitor is treated as unverified.
import { createHmac } from 'node:crypto';
// SECRET_KEY lives only on your server, never ship it inside the app.
const hmac = createHmac('sha256', SECRET_KEY)
.update(externalId) // exactly the externalId you pass to the widget
.digest('hex');
// Return { externalId, email, hmac } to the app, which appends them to the URL.Platform notes
- Android: keep the
INTERNETpermission; intercept the back button so it goes back inside the WebView first. Recentwebview_fluttersupports<input type="file">for attachments. - iOS: HTTPS works with no ATS exception. Image upload is a built-in widget feature:
WKWebViewopens the native photo picker on its own, so you build nothing. Choosing from the photo library needs no permission. Only addNSCameraUsageDescriptiontoInfo.plistif you want the "Take Photo" camera option, otherwise the app crashes when a user taps it.
Prefer a native UI?
If you need a hand-built native chat surface instead of a WebView, use the logic-only clients on the Mobile SDKs page. For most apps the WebView above is faster to ship and always full-featured.