Mobile: webview and Flutter
The secure webview that carries the session in a mobile app: bridge contract, session handover, native core and app key, deep links, security rules, the clickfix_webview Flutter package.
The path for mobile apps is not a separate native SDK but a secure webview that carries the session: the app opens https://getclickfix.com/widget-embed.html?widget=WIDGET_ID in a webview; the widget opens full screen and talks to the app over a bridge. Flutter, React Native, iOS, Android and every other platform connect the same way, with the same contract. The bridge contract (bridge_version 1: commands, messages, error codes, security rules) is in the Bridge contract section of this page; Flutter has the ready-made clickfix_webview package, and complete apps for the other platforms live in the clickfix-examples repository (react-native-webview, flutter-webview).
Session handoff
- The app fetches
{ user_id, user_hash, company_id }from your own server (identity verification);user_hashsigns the user id and the company id together, and the secret never reaches the app. - The webview loads
widget-embed.html?widget=WIDGET_ID&locale=en&theme=auto. When the widget is ready,{"type":"clickfix:ready", ...}arrives over the bridge. - The app sends
{"type":"clickfix:identify","id":"1","user":{"user_id":"1001","user_hash":"…","name":"…","company":{"id":"F-77"}}};company.idis the company id in the signature (bridge contract 1.2,bridge_version1). The answer is{"type":"clickfix:result","id":"1","ok":true}. When rejected,ok:falseanderror.code(such asWIDGET_IDENTITY_INVALID) come back and the user stays anonymous. - On logout send
{"type":"clickfix:reset"}; the visitor is forgotten and the next launch is a new visitor.
The webview's cookies and storage carry the session across reloads; never switch accounts without sending reset.
Session handover with the native core (CF-201). An app can also call the widget endpoints without a webview: create an app key with the platform and package name under Web widget → Advanced → Mobile apps; @getclickfix/visitor-core with mode: "app" sends it in the X-ClickFix-App-Key and X-ClickFix-App-Package headers, so no page origin is needed. Hand the native session to the webview with {"type":"clickfix:session","token":client.getToken()}; the widget answers with its own token in a clickfix:session message after ready, identify and reset (client.adoptToken). The key is shown only when created and can be revoked in the panel; user_hash verification is the same as in the browser.
The bridge: app to widget and widget to app
App → widget (commands): clickfix:identify, clickfix:track (adds an app event to the screen context), clickfix:open (view or conversation), clickfix:close, clickfix:update (language, theme, profile, attributes), clickfix:consent, clickfix:reset, clickfix:get-state. Every command may carry an id; when it does, the widget answers with clickfix:result. A command arrives through window.ClickFixBridge.receive(json) or a postMessage to the page.
Widget → app: clickfix:ready, clickfix:event (every SDK event: message, unread, close, conversation:ended…), clickfix:link (a link to open outside; use the system browser) and clickfix:result. The widget picks the transport by platform: ReactNativeWebView.postMessage, iOS webkit.messageHandlers.ClickFix, the Android ClickFixAndroid interface, the Flutter ClickFixFlutter channel.
The host-side samples below set up the bridge, hand over the session when the widget is ready, send an app event and close the support screen on the widget's close event. They are not part of the guide's automated test; build and verify them in your own project.
// Package: clickfix_webview (sdks/flutter/clickfix_webview; a git dependency until the first release). The package sets up
// the bridge, the origin lock and the ClickFixFlutter channel; you hand over the session.
import 'package:clickfix_webview/clickfix_webview.dart';
import 'package:url_launcher/url_launcher.dart';
final controller = ClickFixController(
widgetId: 'WIDGET_ID',
locale: 'en',
conversation: conversationUuid, // optional deep link
);
ClickFixWebView(
controller: controller,
onReady: (_) async {
final s = await api.supportSession(); // { user_id, user_hash } from your server
try {
await controller.identify(ClickFixIdentity(
userId: s.userId, userHash: s.userHash, company: ClickFixCompany(id: s.companyId)));
await controller.track('app_screen', properties: {'screen': 'orders'});
} on ClickFixBridgeError catch (e) {
debugPrint('identity rejected: ${e.code}'); // e.g. WIDGET_IDENTITY_INVALID; the visitor stays anonymous
}
},
onUnread: (count) => badge.value = count,
onLink: (link) => launchUrl(link.url, mode: LaunchMode.externalApplication), // https only
onClose: () => Navigator.of(context).pop(),
);
// On logout: controller.reset(); when the screen closes: controller.dispose().Deep links
- Open a specific conversation: load
…/widget-embed.html?widget=WIDGET_ID&conversation=CONVERSATION_UUID, or send{"type":"clickfix:open","conversation":"CONVERSATION_UUID"}to an open webview.viewpicks the tab (home,messages,help,new). - From a notification to the conversation: subscribe to the
message.sentwebhook event for your app's own push notification; the event carriesconversation_uuidandcontact_external_id. When the notification's deep link (myapp://support?conversation=…) opens, load the webview with the URL above. The widget'sunreadevent (clickfix:event) is enough for the badge. - Out of the widget: help articles and links in messages arrive as
clickfix:link; open them in the system browser. Do not allow navigation away fromgetclickfix.cominside the webview.
Security notes
- The identity secret never enters the app.
user_hashcomes from an authenticated endpoint on your server; it is neither computed nor hard-coded in the app. - Origin restriction: the webview loads
https://getclickfix.comonly (or the app URL given to you); navigation elsewhere is blocked, and aclickfix:linkURL is re-validated in the app (https:only) before it opens in the system browser; never hand it to an intent or deep-link handler. Disable third-party and shared cookie stores; disable file and content access (allowFileAccess,allowContentAccess). - JS bridge:
window.ClickFixBridge.receiveexists on this page only, and the page belongs to ClickFix. Messages arriving bypostMessageare accepted from the configured parent origin only when framed (boot({ bridge: { origin } })) and from the page itself only when not framed;"*"is refused. Rejected commands never write the message content (for exampleuser_hash) to the console or the result. - Cookies and storage: the session lives in the webview's storage; send
clickfix:reseton account switch and logout. On shared devices clear the webview storage too. - Consent: if your app collects consent, add
consent=1to the URL or sendclickfix:consent; nothing is stored without it.
Bridge contract
Every command is a JSON object; type is required, id optional (≤128 characters). When id is given the widget answers with clickfix:result. A message cannot exceed 64 KB. The contract only changes in backwards-compatible ways; the app must ignore fields and events it does not know.
App → widget (commands)
type | Fields | What the widget does |
|---|---|---|
clickfix:identify | user: { user_id, user_hash, name?, email?, phone?, company?: { id?, name?, domain? }, attributes? } | identify; user_hash is computed on your server, a signed company.id links the verified company |
clickfix:track | name (≤100), properties? | Adds the app event to the screen context |
clickfix:open | view? (home/messages/help/new), conversation? (UUID) | Opens the conversation or the tab |
clickfix:close | – | Closes the panel |
clickfix:update | update (locale, theme, name, email, phone, attributes) | Updates profile and appearance |
clickfix:consent | granted (boolean) | Passes consent; false deletes everything stored |
clickfix:reset | – | Forgets the visitor (logout, account switch) |
clickfix:get-state | – | Returns ClickFixState in result.data (open, view, unread, verified, visitor_id…) |
clickfix:session | token (≤2048) | Adopts the native core's session |
Widget → app (messages)
type | Fields | When |
|---|---|---|
clickfix:ready | bridge_version: 1, protocol_version, sdk_version, widget_id, visitor_id | When the widget is rendered; send identify after it (earlier commands are queued) |
clickfix:event | event, payload | On every SDK event (ready, open, close, message, unread, identify, csat, error…) |
clickfix:link | url | An external link; the app checks it is https: and opens the system browser |
clickfix:result | id, ok, data?, error?: { code, message } | After every command that carries an id |
clickfix:session | token, visitor_id | After ready, identify and reset; hand it to the native core with adoptToken |
Error codes (result.error.code): INVALID_JSON, INVALID_MESSAGE, UNKNOWN_COMMAND, TOO_LARGE, SDK errors raised while the command runs (WIDGET_IDENTITY_DISABLED, WIDGET_IDENTITY_INVALID, RATE_LIMITED, NETWORK_ERROR…) and COMMAND_FAILED for unknown errors. error.message only repeats the code; message content is never written to the result, the console or the error event.
The transport is picked by the widget per platform: React Native window.ReactNativeWebView.postMessage, iOS window.webkit.messageHandlers.ClickFix, Android window.ClickFixAndroid (@JavascriptInterface), Flutter window.ClickFixFlutter (JavaScriptChannel), inside an iframe window.parent.postMessage (only to the configured origin). App → widget commands go through window.ClickFixBridge.receive(json) (preferred) or postMessage to the page.
Flutter package: clickfix_webview
clickfix_webview (Flutter 3.38+, Dart 3.10+; on top of webview_flutter, Android minSdk 24, iOS 12+) implements the bridge contract as ClickFixController + ClickFixWebView: origin lock, a command queue before ready, deep links, clickfix:link validation. The Flutter tab above uses this package; the runnable app is in clickfix-examples/flutter-webview.
flutter pub add clickfix_webview| Method | Bridge command |
|---|---|
identify(ClickFixIdentity) | clickfix:identify |
track(name, properties:) | clickfix:track |
open(view:, conversation:) | clickfix:open |
close() | clickfix:close |
update({...}) | clickfix:update |
setConsent(bool) | clickfix:consent |
reset() | clickfix:reset |
getState() | clickfix:get-state → ClickFixState |
send(ClickFixCommand) | any command, raw |
Widget → app messages arrive as callbacks such as onUnread, onLink, onClose and as the controller.messages, events, links, results streams; errors are thrown as ClickFixBridgeError (ClickFixErrorCode). The native Dart client and the other native SDKs are separate projects; when they ship they will use this same bridge contract.
Last updated on