Support API

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.

  1. Open the live support chat inside the app, signed in as the app's own user, with no login screen.
  2. 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 1

The 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.


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. chat opens the live chat. ticket:<ticketId> opens one request. portal opens the request list. chat is matched exactly: chat:x or chats is a 400.
  • The link is a one-time sign-in. It expires in at most 15 minutes (ttlSeconds ≤ 900, default 900), and use: "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=1 to 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 authenticated mode, even when your web widget uses hmac_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-content on 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" /> (and MODIFY_AUDIO_SETTINGS), ask for RECORD_AUDIO at runtime before opening the chat, and grant the page's request in WebChromeClient.onPermissionRequest. Grant only PermissionRequest.RESOURCE_AUDIO_CAPTURE, and only when request.origin is your portal host. Otherwise call request.deny().
  • iOS: add NSMicrophoneUsageDescription to Info.plist (WKWebView refuses the microphone without it), and set allowsInlineMediaPlayback = true on the WKWebViewConfiguration. Otherwise the agent's audio tries to open full screen. On iOS 15+, return .grant from webView(_:requestMediaCapturePermissionFor:initiatedByFrame:type:decisionHandler:) for your portal host and .microphone only.
  • Flutter: webview_flutter_android → setOnPlatformPermissionRequest and grant WebViewPermissionResourceType.microphone. With flutter_inappwebview, return PermissionResponseAction.GRANT from onPermissionRequest for PermissionResourceType.MICROPHONE, and set mediaPlaybackRequiresUserGesture: false and allowsInlineMediaPlayback: true.
  • React Native: react-native-webview needs allowsInlineMediaPlayback and mediaPlaybackRequiresUserGesture={false}, and on iOS 15+ mediaCapturePermissionGrantType="grantIfSameHostElsePrompt". Request RECORD_AUDIO with PermissionsAndroid before 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, plus CAMERA / READ_MEDIA_IMAGES if the picker should offer the camera.
  • iOS Info.plist: NSCameraUsageDescription and NSPhotoLibraryUsageDescription. WKWebView refuses to open the camera or library without them. For in-app calls, also NSMicrophoneUsageDescription (§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 to UIApplication.shared.open.
  • <input type="file"> presents the system picker. Add the two Info.plist keys above.
  • Leave contentInsetAdjustmentBehavior at its default. The page pads itself by env(safe-area-inset-*).

Native Android: WebView

  • Set settings.javaScriptEnabled = true and settings.domStorageEnabled = true. CookieManager.setAcceptCookie(true) is the default. Leave setAcceptThirdPartyCookies off.
  • Implement WebChromeClient.onShowFileChooser with an ActivityResultLauncher (ACTION_GET_CONTENT, the MIME types above) and pass the result to the callback.
  • Use WebViewClient.shouldOverrideUrlLoading to keep navigation on your portal host.
  • Activity: android:windowSoftInputMode="adjustResize".
  • For in-app calls: RECORD_AUDIO in the manifest and at runtime, and grant RESOURCE_AUDIO_CAPTURE for your portal host in WebChromeClient.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:

EventWhenPush it when
support.chat.message_createda 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.replieda reply is added to a requestpayload.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"
  }
}
  • externalRef is your own user id. It is the customer's external identity, or the hmac-verified id your web widget opened with. It is null for an anonymous web visitor, who has no phone to push to. customerId is Gauge's id for the same person.
  • support.ticket.replied now carries requesterCustomerId and externalRef too. This is a minor, backwards-compatible change: both are always sent, and either may be null.

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

ThingValue
Minted linksingle-use, ≤ 15 min, spent atomically: a second open of the same url is refused
Portal session cookiepulse_support_session, HttpOnly; Secure; SameSite=Lax; Path=/, no Domain, so it belongs to your portal host only
Idle expiry30 days, sliding (each request extends it)
Absolute expiry90 days from sign-in, whatever the activity
Ends early whenthe 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.

  1. Load https://<your-portal-host>/logout?embed=1 in the same WebView, so it carries the same cookie jar.
  2. The page revokes the session on the server, which also clears the cookie and drops any live connection, then navigates to /logout/done.
  3. 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 /logout directly 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 calls POST /v1/support/callbacks with the customer id and the number. The response carries promisedBy (the time to show), and support.callback.completed|failed|cancelled webhooks 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 /logout in the WebView before clearing its jar.

On this page