Support in a native app: live chat in a WebView, and push on reply
This page is for a tenant whose product is a native app: Salonvira's two Flutter apps are the first. It covers two jobs.
- Open the live support chat inside the app, signed in as the app's own user, with no login screen.
- Know when the support team replies, so the app can send a push notification.
Machine truth lives in GET /openapi.json: the /v1/support/sessions operation, and the webhooks block for support.chat.message_created and support.ticket.replied. The zod sources are packages/types/src/support-session-links.ts, support-portal-chat.ts and events.ts. The S1 API core is in api-s1.md.
1. How it fits together
App (user taps "Chat")
│ 1. "open support chat for me" (your app ↔ YOUR backend, your own auth)
▼
Your backend
│ 2. POST /v1/support/sessions target: "chat" (Gauge API key, server-side ONLY)
▼
Gauge → { url, expiresAt } a single-use link, valid ≤ 15 minutes
│ 3. your backend returns the url to the app; the app appends &embed=1
▼
WebView opens https://<your-portal-host>/auth/handoff?token=…&embed=1
│ 4. the portal spends the token once, sets its session cookie, lands on /chat?embed=1
▼
Live chat, as that customer, with no login. Replies are read over HTTPS, files are sent from the
native picker, and it works in Arabic/RTL.
Agent replies ──► webhook support.chat.message_created (ids only) ──► your backend ──► FCM / APNs
user taps the push ──► back to step 1The API key never leaves your backend. The app asks your backend for a link, authenticated however your app already authenticates its users, and your backend mints one. A key embedded in an app binary is a key anyone can extract, and with it they can mint a sign-in for any of your customers.
2. Mint a chat link (your backend)
POST /v1/support/sessions
Authorization: Bearer pulse_k_live_… # needs support:rw + support-sessions:rw
Content-Type: application/json
{
"projectId": "<your support project id>",
"externalRef": "user_8812", # YOUR id for this person
"email": "layla@example.com", # needed only the first time we see this ref
"name": "Layla Haddad",
"target": "chat"
}201 { "url": "https://help.salonvira.com/auth/handoff?token=…", "expiresAt": "2026-10-14T16:45:00.000Z" }- Targets.
chatopens the live chat.ticket:<ticketId>opens one request.portalopens the request list.chatis matched exactly:chat:xorchatsis a 400. - The link is a one-time sign-in. It expires in at most 15 minutes (
ttlSeconds≤ 900, default 900), anduse: "multi"is a 400. Mint a fresh link every time the user opens the chat. Never cache one, never put one in a push payload, and never log it: it is a bearer credential until it is spent. - Embed mode. Append
&embed=1to the url before loading it. The portal then renders the chat without its own header and footer, edge to edge, padded by the device's safe-area insets. Your app draws its own title bar and back button. - Not idempotent, by design. Each call mints a new single-use link, so don't retry a mint with an
Idempotency-Key. - Identity. A customer opened this way is in
authenticatedmode, even when your web widget useshmac_verified. The portal session is the proof. - Blocked and deactivated customers. A blocked customer's link signs nobody in: every session check refuses a blocked customer, so the WebView shows "Your sign-in has ended". A deactivated customer is signed in read-only, and the chat page tells them "Chat isn't available on this account". Don't rely on the mint itself to refuse a blocked customer; check your own user's state before you ask for a link.
Node (your server):
async function chatLinkFor(user) {
const res = await fetch(`${GAUGE_API}/v1/support/sessions`, {
method: 'POST',
headers: { authorization: `Bearer ${process.env.GAUGE_SUPPORT_KEY}`, 'content-type': 'application/json' },
body: JSON.stringify({ projectId: SUPPORT_PROJECT_ID, externalRef: user.id, email: user.email, name: user.name, target: 'chat' }),
});
if (!res.ok) throw new Error(`support link: ${res.status}`);
const { url } = await res.json();
return `${url}&embed=1`;
}3. The WebView
Shortcut:
gauge_support_webview(Flutter) and the copy-paste screens in webview-recipes.md (React Native, iOS, Android) apply every rule below.
The chat is an ordinary HTTPS page on your portal host. Its session cookie is first-party (the page is the top-level document) and host-only (no Domain). So:
- JavaScript must be on.
- Third-party cookies are not needed. Leave them off. The page never relies on a cookie from another site.
- Keep navigation on your portal host. Open any other link in the system browser.
- File upload uses the native picker. The page asks for
image/jpeg, image/png, image/webp, image/gif, image/heic, image/heif, application/pdf, one file at a time, at most 7 MB. On a phone that opens the camera and photo-library sheet. - Keyboard. The page keeps its composer on the keyboard's top edge by itself. It uses
interactive-widget=resizes-contenton Android, and sizes itself to the visual viewport on iOS. Let your WebView resize with the keyboard (below) and don't add your own keyboard padding.
3.1 In-app support calls (microphone)
When the project has voice on, the chat page shows a Call support key. The call runs in the WebView over WebRTC (LiveKit), so the WebView must be allowed to use the microphone. Without it the page says "Microphone access is blocked" and the customer can still message. You need nothing from Twilio.
- Android: declare
<uses-permission android:name="android.permission.RECORD_AUDIO" />(andMODIFY_AUDIO_SETTINGS), ask forRECORD_AUDIOat runtime before opening the chat, and grant the page's request inWebChromeClient.onPermissionRequest. Grant onlyPermissionRequest.RESOURCE_AUDIO_CAPTURE, and only whenrequest.originis your portal host. Otherwise callrequest.deny(). - iOS: add
NSMicrophoneUsageDescriptiontoInfo.plist(WKWebView refuses the microphone without it), and setallowsInlineMediaPlayback = trueon theWKWebViewConfiguration. Otherwise the agent's audio tries to open full screen. On iOS 15+, return.grantfromwebView(_:requestMediaCapturePermissionFor:initiatedByFrame:type:decisionHandler:)for your portal host and.microphoneonly. - Flutter:
webview_flutter_android→setOnPlatformPermissionRequestand grantWebViewPermissionResourceType.microphone. Withflutter_inappwebview, returnPermissionResponseAction.GRANTfromonPermissionRequestforPermissionResourceType.MICROPHONE, and setmediaPlaybackRequiresUserGesture: falseandallowsInlineMediaPlayback: true. - React Native:
react-native-webviewneedsallowsInlineMediaPlaybackandmediaPlaybackRequiresUserGesture={false}, and on iOS 15+mediaCapturePermissionGrantType="grantIfSameHostElsePrompt". RequestRECORD_AUDIOwithPermissionsAndroidbefore opening the chat on Android.
The page asks for the microphone only when the customer presses Call, never on load.
Flutter: webview_flutter (4.x)
import 'package:webview_flutter/webview_flutter.dart';
import 'package:webview_flutter_android/webview_flutter_android.dart';
import 'package:file_picker/file_picker.dart';
import 'package:url_launcher/url_launcher.dart';
const portalHost = 'help.salonvira.com';
WebViewController supportChat(String link, {required VoidCallback onSignedOut}) {
final controller = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setNavigationDelegate(NavigationDelegate(
onNavigationRequest: (request) {
final uri = Uri.parse(request.url);
if (uri.host != portalHost) {
launchUrl(uri, mode: LaunchMode.externalApplication); // never inside the chat
return NavigationDecision.prevent;
}
if (uri.path == '/logout/done') onSignedOut(); // see §5
return NavigationDecision.navigate;
},
))
..loadRequest(Uri.parse(link));
// Android only: route <input type="file"> to the native picker (iOS does this by itself).
final platform = controller.platform;
if (platform is AndroidWebViewController) {
platform.setOnShowFileSelector((params) async {
final picked = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['jpg', 'jpeg', 'png', 'webp', 'gif', 'heic', 'heif', 'pdf'],
);
final path = picked?.files.single.path;
return path == null ? <String>[] : [Uri.file(path).toString()];
});
}
return controller;
}
// The screen: let the WebView resize with the keyboard.
Scaffold(resizeToAvoidBottomInset: true, appBar: AppBar(title: const Text('Support')),
body: WebViewWidget(controller: controller));Flutter: flutter_inappwebview (6.x)
InAppWebView(
initialUrlRequest: URLRequest(url: WebUri(link)),
initialSettings: InAppWebViewSettings(
javaScriptEnabled: true,
thirdPartyCookiesEnabled: false, // first-party cookie only — see above
supportMultipleWindows: false,
javaScriptCanOpenWindowsAutomatically: false,
allowsInlineMediaPlayback: true,
useHybridComposition: true, // Android: correct keyboard + input behaviour
isFraudulentWebsiteWarningEnabled: true,
),
shouldOverrideUrlLoading: (controller, action) async {
final uri = action.request.url!;
if (uri.host != portalHost) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
return NavigationActionPolicy.CANCEL;
}
if (uri.path == '/logout/done') onSignedOut();
return NavigationActionPolicy.ALLOW;
},
)flutter_inappwebview handles <input type="file"> on both platforms.
Platform settings (both Flutter packages)
- Android
AndroidManifest.xml:android:windowSoftInputMode="adjustResize"on the activity, plusCAMERA/READ_MEDIA_IMAGESif the picker should offer the camera. - iOS
Info.plist:NSCameraUsageDescriptionandNSPhotoLibraryUsageDescription. WKWebView refuses to open the camera or library without them. For in-app calls, alsoNSMicrophoneUsageDescription(§3.1).
React Native: react-native-webview
<WebView
source={{ uri: link }}
javaScriptEnabled
thirdPartyCookiesEnabled={false}
setSupportMultipleWindows={false}
allowsInlineMediaPlayback
originWhitelist={[`https://${portalHost}`]}
onShouldStartLoadWithRequest={(req) => {
const { host, pathname } = new URL(req.url);
if (host !== portalHost) { Linking.openURL(req.url); return false; }
if (pathname === '/logout/done') onSignedOut();
return true;
}}
/>File inputs work on both platforms. On Android, request camera and media permissions first if you want the camera offered. Use android:windowSoftInputMode="adjustResize", and don't wrap the WebView in a KeyboardAvoidingView: the page already handles the keyboard.
Native iOS: WKWebView
- Use
WKWebsiteDataStore.default()to keep the session across launches, or.nonPersistent()to sign in again (with a fresh link) on every open. - In
webView(_:decidePolicyFor:), allow only your portal host and send everything else toUIApplication.shared.open. <input type="file">presents the system picker. Add the twoInfo.plistkeys above.- Leave
contentInsetAdjustmentBehaviorat its default. The page pads itself byenv(safe-area-inset-*).
Native Android: WebView
- Set
settings.javaScriptEnabled = trueandsettings.domStorageEnabled = true.CookieManager.setAcceptCookie(true)is the default. LeavesetAcceptThirdPartyCookiesoff. - Implement
WebChromeClient.onShowFileChooserwith anActivityResultLauncher(ACTION_GET_CONTENT, the MIME types above) and pass the result to the callback. - Use
WebViewClient.shouldOverrideUrlLoadingto keep navigation on your portal host. - Activity:
android:windowSoftInputMode="adjustResize". - For in-app calls:
RECORD_AUDIOin the manifest and at runtime, and grantRESOURCE_AUDIO_CAPTUREfor your portal host inWebChromeClient.onPermissionRequest(§3.1).
4. Push notifications on reply
Gauge never sends a push to your users. Your backend gets a signed webhook and sends the push through FCM or APNs. Every payload carries ids only: no message text, name or email ever leaves Gauge on this path.
Subscribe a webhook endpoint (console → Developers → Webhooks) to:
| Event | When | Push it when |
|---|---|---|
support.chat.message_created | a message lands in a live chat: from a team member (member), the AI agent (ai), an automation or API send (system), or the customer (customer) | payload.authorType !== "customer" |
support.ticket.replied | a reply is added to a request | payload.replyVisibility === "public" and payload.authorType === "member" |
A delivery body (support.chat.message_created):
{
"id": "4b1f6c1e-…",
"event": "support.chat.message_created",
"workspaceId": "…",
"entity": "message",
"entityId": "<messageId>",
"occurredAt": "2026-10-14T16:24:00.000Z",
"payload": {
"version": 1,
"sessionId": "…",
"projectId": "…",
"conversationId": "…",
"messageId": "…",
"authorType": "member",
"customerId": "…",
"externalRef": "user_8812"
}
}externalRefis your own user id. It is the customer'sexternalidentity, or the hmac-verified id your web widget opened with. It isnullfor an anonymous web visitor, who has no phone to push to.customerIdis Gauge's id for the same person.support.ticket.repliednow carriesrequesterCustomerIdandexternalReftoo. This is a minor, backwards-compatible change: both are always sent, and either may benull.
Verify, then dedupe. Each delivery has x-pulse-signature: sha256=<hex>, an HMAC-SHA256 of <x-pulse-timestamp>.<raw body> keyed with the endpoint secret. The timestamp is epoch milliseconds; reject anything more than 5 minutes from your clock. During a secret rotation there are two signatures, separated by a comma, and either may match. Retries and manual resends carry the same envelope id. Store it and drop a delivery whose id you have already handled, so a user is never pushed twice for one reply.
Send the push. Look up the devices for externalRef and send a data message, with a body you write yourself. The webhook carries no message text, by design.
FCM (HTTP v1):
{
"message": {
"token": "<device token>",
"notification": { "title": "Salonvira Support", "body": "You have a new reply" },
"data": { "kind": "support_chat", "sessionId": "…", "messageId": "…" },
"android": { "collapse_key": "support_chat", "priority": "HIGH" }
}
}APNs:
{
"aps": { "alert": { "title": "Salonvira Support", "body": "You have a new reply" }, "thread-id": "support_chat", "sound": "default" },
"kind": "support_chat",
"sessionId": "…"
}For support.ticket.replied, use "kind": "support_ticket", "ticketId": "…".
On tap, deep-link into the app (for example salonvira://support/chat). The app then asks your backend for a fresh link (§2): target: "chat" for a chat push, target: "ticket:<ticketId>" for a ticket push. Never put a session link in the push itself. It would expire before most taps, and it is a sign-in credential sitting in a notification store.
5. Sessions, cookies and sign-out
| Thing | Value |
|---|---|
| Minted link | single-use, ≤ 15 min, spent atomically: a second open of the same url is refused |
| Portal session cookie | pulse_support_session, HttpOnly; Secure; SameSite=Lax; Path=/, no Domain, so it belongs to your portal host only |
| Idle expiry | 30 days, sliding (each request extends it) |
| Absolute expiry | 90 days from sign-in, whatever the activity |
| Ends early when | the customer is blocked, their project access is revoked, or POST /v1/support/customers/:id/sessions/revoke is called. The next request or poll sees it. |
When your user signs out of the app, also end the support session. Clearing the WebView's cookie jar alone leaves the server-side session valid until it expires.
- Load
https://<your-portal-host>/logout?embed=1in the same WebView, so it carries the same cookie jar. - The page revokes the session on the server, which also clears the cookie and drops any live connection, then navigates to
/logout/done. - When your navigation callback sees
/logout/done, close the WebView, and clear its cookie jar too.
If the revoke fails (the device is offline, say), the page stays on /logout, says so, and offers a retry. It never shows "signed out" while the session is still live.
Loading the page doesn't sign anyone out by itself. The page's own script sends the sign-out as a same-origin POST, and it does that on load only when the browser reports the navigation as the app's own (Sec-Fetch-Site: none, which is what loadUrl / load(URLRequest) sends) or as coming from the portal itself (same-origin). If another site sends the browser to /logout, the page shows a Sign out button and revokes nothing until the user presses it. The API also refuses a sign-out POST from any other origin. In practice:
- Load
/logoutdirectly in the WebView. Don't reach it through a redirect from your own domain, because the browser then reports the navigation as cross-site and the user has to press the button. - WebViews too old to send
Sec-Fetch-Site(Android System WebView before 76, iOS before 16.4) also get the button, so the flow still works with one tap.
6. What the chat page does on its own
- Replies arrive over HTTPS, not a socket. A WebView is suspended whenever the app goes to the background, and a socket dies with it. The page re-reads the conversation when it opens, whenever it returns to the foreground or comes back online, and every 4 s while it is visible. It never polls in the background or while offline.
- Messages typed offline wait in the page and go out when the network returns. Each keeps its id and timestamp, so a retry lands exactly once.
- What it says. "You're next." / "N people are ahead of you." come from the server's own count. "We're closed right now. Leave a message and we'll reply by Thu 1:00 PM." comes from your project's hours and SLA. "This chat is now request #1042." appears when an agent turns the chat into a request.
- What it does not show: an agent-typing indicator. That signal only exists on the web widget's socket, and the page will not invent one.
- Arabic. When the customer's saved locale (or the project default) is Arabic, the whole page is in Arabic and right-to-left, including plurals and times.
- Rate limits (per customer): 60 chat opens an hour, 120 transcript reads a minute, and 10 file uploads an hour (shared with request attachments).
- "Request a callback" is not drawn inside the embedded page (
embed=1). Inside the web widget that key belongs to the Slip, and two keys for one request is a duplicate. In a native app, offer it from your own UI: your backend callsPOST /v1/support/callbackswith the customer id and the number. The response carriespromisedBy(the time to show), andsupport.callback.completed|failed|cancelledwebhooks tell you how it ended. See callbacks.md.
7. Security checklist
- The Gauge API key lives only on your backend, scoped to
support:rw+support-sessions:rw, and pinned to your support project (projectIds). - Your backend mints a link only for the user your app has authenticated. A link signs that person in.
- A link is minted per open, never cached, never logged, never sent in a push.
- The WebView allows only your portal host and has no JavaScript bridge (
addJavascriptInterface/JavascriptChannel) exposed to it. - Third-party cookies are off. HTTPS only.
- Webhooks are signature-checked, held to a 5-minute window, and deduped on envelope
id. - Sign-out loads
/logoutin the WebView before clearing its jar.
Native Help & Support screens
How a native app (iOS, Android, Flutter, React Native) builds its Help & Support screens on Gauge. The rule for every section: Gauge is the source of truth. Hours, staffing and who answers are…
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):