Skip to content

Flutter

Markdown

concierge_sdk is a Flutter plugin that wraps the native iOS and Android SDKs, so the chat is the same native SwiftUI and Jetpack Compose screen on both platforms. Not a webview, and not a Flutter widget tree that imitates one.

  • iOS 16.4+ and Android 7 (API 24)+.
  • One static Dart API on both platforms. It matches the React Native package one to one, with streams where React Native has hooks.
  • Same features as the native SDKs: cached history, offline outbox, photos, cards, client tools, push.
  • configure makes no network calls. The device registers the first time the chat opens or a push token arrives.
  • History follows the user across devices once you call identify with a signed token.
  • iOS and Android only. For Flutter web, use the web widget.
Terminal window
flutter pub add concierge_sdk

The plugin is concierge_sdk on pub.dev, published by the verified publisher conciergesdk.com. You can also add concierge_sdk: ^0.1.0 under dependencies in pubspec.yaml and run flutter pub get.

Raise the minimum platform versions if your project is below them.

ios/Podfile:

platform :ios, '16.4'

android/app/build.gradle.kts (or build.gradle):

android {
compileSdk = 37
defaultConfig {
minSdk = 24
}
}

Then build as usual with flutter run. On iOS the plugin installs through CocoaPods and ships the prebuilt Concierge.xcframework inside the package. It does not support Swift Package Manager yet, so Flutter prints a note that concierge_sdk does not support it; the build is not affected. On Android it pulls com.conciergesdk:concierge from Maven Central, which new Flutter projects already list as a repository.

The plugin never asks for notification permission. Your app decides when to ask.

import 'package:concierge_sdk/concierge_sdk.dart';
import 'package:flutter/material.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
Concierge.configure(appKey: 'app_YOUR_APP_KEY');
runApp(const MyApp());
}
class SupportButton extends StatelessWidget {
const SupportButton({super.key});
@override
Widget build(BuildContext context) {
return StreamBuilder<int>(
stream: Concierge.unreadCountStream,
initialData: Concierge.getUnreadCount(),
builder: (context, snapshot) {
final unread = snapshot.data ?? 0;
return TextButton(
onPressed: () => Concierge.open(),
child: Text(unread > 0 ? 'Support ($unread)' : 'Support'),
);
},
);
}
}

configure(appKey:, baseUrl:, debug:, reviewPromptAfterSupport:, locale:) is called once at startup, after WidgetsFlutterBinding.ensureInitialized(). baseUrl defaults to https://api.conciergesdk.com and must be https; plain http works only for localhost and the emulator host addresses, or any host in a debug build. debug: true shows the native debug overlay in debug builds (connection state, ids, tools, the last 20 requests). Release builds ignore it. locale (a BCP 47 tag such as ja or pt-BR) overrides the device language; Concierge.setLocale(tag) changes it later and setLocale(null) follows the device again. See Localization.

To try a local API, pass baseUrl: 'http://10.0.2.2:4000' on the Android emulator (http://localhost:4000 on the iOS Simulator):

import 'dart:io';
Concierge.configure(
appKey: 'app_YOUR_APP_KEY',
baseUrl: Platform.isAndroid ? 'http://10.0.2.2:4000' : 'http://localhost:4000',
);

Android blocks plain HTTP by default, so allow the emulator host in debug builds only. Flutter projects already have android/app/src/debug/AndroidManifest.xml; keep its INTERNET permission and add the <application> line:

android/app/src/debug/AndroidManifest.xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<application android:networkSecurityConfig="@xml/network_security_config" />
</manifest>
<!-- android/app/src/debug/res/xml/network_security_config.xml -->
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">10.0.2.2</domain>
</domain-config>
</network-security-config>

Without it the chat stays offline (“You’re offline”).

  • Concierge.open() opens the chat full screen (a sheet on iOS, an activity on Android). Concierge.dismiss() closes it.
  • Concierge.isChatVisible() returns a Future<bool>, for example to skip your own banner while the chat is on screen.
  • Concierge.isEnabled() is false while the app is turned off in the dashboard. While off the SDK makes no network calls and open() does nothing, so hide your Support button. Concierge.enabledStream reports changes.
StreamBuilder<int>(
stream: Concierge.unreadCountStream,
initialData: Concierge.getUnreadCount(),
builder: (context, snapshot) => Badge(
isLabelVisible: (snapshot.data ?? 0) > 0,
label: Text('${snapshot.data ?? 0}'),
child: const Icon(Icons.support_agent),
),
)

The stream gives the live count of team and AI replies. Once the chat has been opened on the device, it updates live while the app is in the foreground, even with the chat closed, and refreshes when the app returns to the foreground. getUnreadCount() returns the last known value and refreshUnreadCount() fetches it now.

// After sign in, and on every launch while signed in
Concierge.identify(user.id, name: user.name, email: user.email, token: token);
// On sign out
Concierge.logout();

Cross-device history needs token: an HS256 JWT your server signs with the app’s identity secret, with exp at most 30 days out. See Identity verification. An identify without a token stays on this device only. A rejected token emits an identity rejected event; chat keeps working on this device.

logout() forgets the user on this device, and the next user never sees the previous chat. It does nothing for guests, so never call it at launch for signed-out users.

// "Erase all data": wipe Concierge on this device.
Concierge.reset();
// A decoy or guest session: Concierge goes invisible and silent.
Concierge.suspend();
Concierge.resume();
Concierge.isSuspended();
  • reset() wipes Concierge on this device back to a fresh install without a network request: the device token in secure storage, the identity, context, cached history and appearance, the outbox, seen cards and handled tool calls. It closes the chat. Tools and handlers stay, and the next open registers a new device.
  • While suspended the SDK sends no request and keeps no socket open, the chat closes and won’t open, the unread count reads 0 and no events fire. Consent prompts and tool calls wait: a pending call is picked up after resume() or expires on the server.
  • resume() brings the entry points back, refreshes the unread count and sends anything queued. Suspension lasts until the app process ends, so call suspend() again on the next launch if needed.
Concierge.setMetadata({'plan': 'Pro'}); // fields next to the conversation
Concierge.setContext({'plan': 'pro', 'screen': 'Paywall'}); // structured context, merged
Concierge.breadcrumb('tapped_restore'); // last 50 are kept
try {
await sync();
} catch (error, stackTrace) {
Concierge.captureError(error, stackTrace); // newest error, shown next to the conversation
}

Context, breadcrumbs and the last error are batched and ride along with the next message the user sends. Never put names or emails in metadata; use identify. clearContext() removes every context key the app set.

Register actions the AI agent or your team can ask the app to run. The user is asked first, then your handler runs and its result goes back to whoever asked.

final sub = Concierge.registerTool(
'resync_library',
'Resync the music library from the server',
{
'type': 'object',
'properties': {
'full': {'type': 'boolean'},
},
},
(args) async {
final tracks = await library.resync(full: args['full'] == true);
return {'tracks': tracks}; // any JSON, up to 8KB
},
title: 'Resync your library?',
);
// later: sub.remove(), or Concierge.unregisterTool('resync_library')
  • Up to 32 tools. schema is a JSON Schema object for the arguments.
  • The consent sheet text always comes from your registration, never from the server. Pass requiresConsent: false for harmless actions.
  • The handler has 30 seconds. Throw to report a failure.
  • The native SDK shows the consent sheet; your Dart handler only runs the action.

Register the screens support answers may link to, once at launch. See Knowledge autopilot.

Concierge.registerScreen('settings.backup', 'Backup settings', 'myapp://settings/backup');
Concierge.unregisterScreen('settings.backup'); // its buttons stop showing

Tapping the button closes the chat and the native SDK opens the deep link. To route in app instead (for example with go_router), set a handler:

Concierge.onOpenScreen((id, deepLink) {
router.push(Uri.parse(deepLink).path);
});

The deep link never leaves the device: Concierge learns only the id and title, and only while the knowledge autopilot is on. When an AI answer is about a registered screen, it can end with an open screen button; the SDK shows it only if that id is registered on this device. Ids are 1 to 64 letters, digits, ., _ or -, starting with a letter; titles are 1 to 120 characters; up to 100 screens. Registering an id again replaces it, and invalid input is logged and ignored.

Concierge sends APNs pushes on iOS and FCM pushes on Android. Forward the device token (the raw APNs token on iOS, the FCM token on Android) and route taps. With firebase_messaging:

import 'dart:io';
import 'package:concierge_sdk/concierge_sdk.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
Future<void> setUpPush() async {
final messaging = FirebaseMessaging.instance;
// After your app has asked for permission:
final token = Platform.isIOS ? await messaging.getAPNSToken() : await messaging.getToken();
if (token != null) Concierge.registerPushToken(token);
messaging.onTokenRefresh.listen((t) {
if (Platform.isAndroid) Concierge.registerPushToken(t);
});
// In the foreground: keep the badge current without opening the chat.
FirebaseMessaging.onMessage.listen((m) => Concierge.handleNotification(m.data, openChat: false));
// Tapped while the app was in the background or closed: open the chat.
FirebaseMessaging.onMessageOpenedApp.listen((m) => Concierge.handleNotification(m.data));
final initial = await messaging.getInitialMessage();
if (initial != null) Concierge.handleNotification(initial.data);
}

handleNotification returns true when the push came from Concierge, updates the badge and, by default, opens the chat. isConciergeNotification(data) checks a payload without side effects. Any other push plugin works the same way as long as it gives you the APNs token on iOS and the payload of a tapped notification. Server setup is in Push notifications.

Your team or the AI can attach a card to a message: review opens your store review page, paywall calls onPaywallCard, and offer is an Offer Engine offer that the SDK redeems on the server for this device.

Concierge.onPaywallCard((paywallId) {
router.push('/paywall/$paywallId');
});
// Optional: run offer purchases yourself (RevenueCat, Superwall, your own billing)
Concierge.onOfferCard((redemption) async {
await Purchases.setAttributes({'concierge_card': redemption.accountToken});
switch (redemption.mechanism) {
case ProductMechanism(:final productId):
await buy(productId);
case PromotionalMechanism(:final productId, :final offerId, :final signature):
await buyPromotional(productId, offerId, signature);
case WinBackMechanism(:final productId, :final offerId):
await buyWinBack(productId, offerId);
case AndroidOfferMechanism(:final productId, :final basePlanId, :final offerId):
await buyPlayOffer(productId, basePlanId, offerId);
case OfferCodeMechanism(:final redeemUrl):
await launchUrl(Uri.parse(redeemUrl));
case PaywallMechanism(:final paywallId):
router.push('/paywall/$paywallId');
case CompMechanism():
break; // granted on the server, a confirmation message follows in the chat
case UnknownMechanism():
break; // a kind this plugin version does not know
}
});

redemption.mechanism is a sealed ConciergeOfferMechanism, so Dart checks that the switch handles every kind. Bind the purchase to the card with redemption.accountToken so the sale is attributed to the conversation. Without onOfferCard, the SDK runs the default: StoreKit 2 for store offers on iOS, the redeem link for offer codes, onPaywallCard for paywalls, and “Unlocking…” for comps. Android has no billing dependency, so store offers there need onOfferCard.

final sub = Concierge.events.listen((event) {
switch (event) {
case ChatOpenedEvent():
analytics.log('support_opened');
case ChatClosedEvent():
analytics.log('support_closed');
default:
break;
}
});
// later: sub.cancel();

Concierge.events is a broadcast stream of sealed ConciergeEvent subclasses, one per event the native SDKs emit, for example ChatOpenedEvent, ChatClosedEvent, UnreadCountChangedEvent, OfferRedeemedEvent and AvailabilityChangedEvent. Concierge.addEventListener does the same with a callback and returns a subscription with remove().

The refund desk, receipts and renewal heads ups only know a purchase is this customer’s when the store says so. The simplest route is RevenueCat with the app user id set to the same id you sign into the identity token (Purchases.logIn(user.id)). Otherwise pass the customer’s account token on your own purchases (it is derived from the verified user id, see Offers). With the crypto package:

import 'dart:convert';
import 'package:crypto/crypto.dart';
final _uuid = RegExp(r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', caseSensitive: false);
String conciergeAccountToken(String userId) {
if (_uuid.hasMatch(userId)) return userId.toLowerCase();
final b = md5.convert(utf8.encode(userId)).bytes.toList(); // MD5 of the UTF-8 bytes
b[6] = (b[6] & 0x0f) | 0x30;
b[8] = (b[8] & 0x3f) | 0x80;
final h = b.map((x) => x.toRadixString(16).padLeft(2, '0')).join();
return '${h.substring(0, 8)}-${h.substring(8, 12)}-${h.substring(12, 16)}-${h.substring(16, 20)}-${h.substring(20)}';
}
// in_app_purchase sends applicationUserName as the app account token on iOS
// and as the obfuscated account id on Google Play.
final param = PurchaseParam(productDetails: product, applicationUserName: conciergeAccountToken(user.id));
await InAppPurchase.instance.buyNonConsumable(purchaseParam: param);

Offer card purchases keep using redemption.accountToken.

Your app already has a paywall, products and maybe offers and a review prompt. Concierge cards should open those, not a new screen. The setup prompt does this for you: it finds what the app has (RevenueCat, including RevenueCat Paywalls, Superwall, Adapty, Qonversion, in_app_purchase or a custom paywall), wires the handlers below and prefills your account. By hand:

  • Paywall cards call onPaywallCard(paywallId). Map the id to your existing paywall, placement or offering, and open the main paywall for any id you don’t recognize: the dashboard sends default unless you type another id.
  • Offer cards call onOfferCard(redemption) after the server redeems them. Run your existing purchase flow for redemption.mechanism and pass redemption.accountToken to the store so the sale is attributed to the card.
  • Review cards open the store review page by themselves, but only when the app’s store listing is set: the App Store id and Google Play package under Settings, General in the dashboard (or update_app_settings on the MCP server).
  • Review prompts: replace your own native review request with maybeRequestReview at the same moment, so the two don’t compete for the system’s yearly limit.
  • After a purchase in your own flow, set the plan in the context and ask for a review at that moment.
  • Purchase identity: see Link purchases to customers.
Concierge.onPaywallCard((paywallId) => router.push('/paywall', extra: paywallId)); // unknown ids open the main paywall
Concierge.onOfferCard((r) async {
switch (r.mechanism) {
case PaywallMechanism(:final paywallId):
router.push('/paywall', extra: paywallId);
case OfferCodeMechanism(:final redeemUrl):
await launchUrl(Uri.parse(redeemUrl));
case CompMechanism():
break; // granted on the server
case ProductMechanism() || PromotionalMechanism() || WinBackMechanism() || AndroidOfferMechanism():
await purchase(r.mechanism, r.accountToken); // your purchase code
case UnknownMechanism():
router.push('/paywall');
}
});
// After a purchase in your own flow
Concierge.setContext({'plan': productId});
await Concierge.maybeRequestReview('purchase_completed');

With RevenueCat, call Purchases.setAttributes({'concierge_card': r.accountToken}) before buying and present paywalls with RevenueCatUI.presentPaywall. With in_app_purchase, pass applicationUserName: r.accountToken in the PurchaseParam. Replace InAppReview.instance.requestReview() with Concierge.maybeRequestReview.

The SDK reports which handlers are set (SDK 0.1.1 or later), so the dashboard’s Connect page, the verify_install MCP tool and npx @conciergesdk/cli doctor show whether paywall and offer cards are wired and whether review cards have a link. A card with no handler logs a warning and emits PaywallUnhandledEvent` or `OfferUnhandledEvent.

Dashboard settings apply without an app update. Local overrides win:

Concierge.setAppearance(ConciergeAppearance(
accentLight: '#4F46E5',
accentDark: '#A5B4FC',
title: 'Help',
welcome: 'Hi! How can we help?',
colorScheme: ConciergeColorScheme.system,
));

showsCloseButton is iOS only and colorScheme is Android only. See Appearance.

final result = await Concierge.maybeRequestReview('milestone');
// result.shown, result.reason
Concierge.presentCancelFlow(productId: 'pro_yearly');
final session = await Concierge.paywallHelp(PaywallContext(/* the plans and prices your paywall shows */));
  • maybeRequestReview(moment) asks the system review API only when the native SDK’s local rules and the server agree, the same rules as on iOS and Android. Moments are milestone, purchase_completed, content_created or your own name. shown means the system API was called; reason says why not otherwise. reviewPromptAfterSupport: false in configure turns off the automatic ask after a chat with praise.
  • presentCancelFlow() shows the in-app cancel flow from your own “Cancel subscription” button: why are you leaving, then the best retention step for that reason. “Continue to cancel” is on every screen and goes to the store’s subscription page, so cancelling is never blocked.
  • paywallHelp() is for a “Questions?” button on your paywall. It sends what the paywall shows so answers fit it, then opens the chat, and returns a PaywallHelpSession (pass its account token to the purchase) or null when it could not start.
  • getAttestationStatus() reports the device attestation state the native SDK has.
  • getRefundConsentText() and setRefundDataConsent(granted, text) record the user’s choice about sharing purchase info with Apple for refund requests. Call it only from an explicit user action, such as a toggle in your settings.
  • Native on both platforms: the plugin links the prebuilt native SDKs (Concierge.xcframework on iOS, shipped inside the plugin, and the com.conciergesdk:concierge AAR from Maven Central on Android), so the chat, offline outbox, cached history and photo handling are exactly the native SDKs’.
  • Same protocol everywhere: one inbox covers iOS, Android, React Native, Flutter and the web widget, and history follows a verified user across all of them.
  • Realtime only while needed: the WebSocket opens while the chat is visible, and in the foreground once the chat has been opened (for live unread and tool calls). It closes in the background.