ebchat

Embeddable chat for Flutter apps, on the ebchat first-party livechat protocol. A drop-in chat screen with host-owned push and multi-company support.

v5 is a clean rewrite. It shares no API with the deprecated GetStream-based ebchat 3.x/4.x.

Wire protocol reference: docs/protocol-v1.md.

Install

dependencies:
  ebchat: ^5.2.0

Credentials

You need two values from your ebchat dashboard, under Settings → API keys:

Value What it is
apiKey An eb_… token scoped to the LIVECHAT permission
partnerId The partner id the key was issued under

A LIVECHAT-scoped key can only reach /livechat/v1 — it cannot read your inbox, your customers, or your integrations. That is what makes it safe to ship inside a mobile binary. Do not reuse a full-scope dashboard key here.

Quick start

final chat = await EbChat.init(
  baseUrl: Uri.parse('https://api.enable.tech'),
  apiKey: 'eb_...',                    // LIVECHAT-scoped token
  partnerId: 'your-app',
  user: EbChatUser.guest(),            // or EbChatUser.identified(...)
  language: 'en',                      // 'en' | 'ar'
);

Navigator.of(context).push(MaterialPageRoute(
  builder: (_) => Scaffold(body: EbChatScreen(chat: chat)),
));

EbChatScreen opens (or resumes) the conversation and renders it with the company's theme and language. When an operator (or the auto-close job) closes the conversation, the guest sees a closed notice with a one-tap Start new conversation — and anything they had typed but not sent carries over as a draft. Realtime is used automatically when the gateway advertises it (the bootstrap realtime block), otherwise the SDK polls — see Connectivity & resilience for what changes in polling mode.

Call chat.dispose() when you tear the instance down.

A light surface, by design

The chat always renders on its own light brand surface — wallpaper, bubbles, the catalog browser and the cart sheet pin their own colors and text styles. Embedding the screen in a dark-themed host does not flip the chat dark, deliberately: every ebchat surface (dashboard, web widget, this SDK) is meant to read as the same product. Don't fight it with theme overrides; frame the screen instead.

Session persistence

The default session store is in-memory: every app restart becomes a brand-new guest with an empty transcript. To your customer that reads as the business losing their conversation. Pass a persistent SessionStore to EbChat.init in anything beyond a throwaway demo.

The example app ships a ~20-line shared_preferences implementation — copy example/lib/prefs_session_store.dart:

final chat = await EbChat.init(
  baseUrl: base, apiKey: key, partnerId: partner,
  sessionStore: PrefsSessionStore(),
);

A persistent store is also what makes background push handling possible (see Push notifications).

Identified users

When the company enables identity verification, compute the HMAC on your backend — never ship the secret in the app:

// hmac = HMAC_SHA256(userId, companySecret)  — from your server
final chat = await EbChat.init(
  baseUrl: base, apiKey: key, partnerId: partner,
  user: EbChatUser.identified(id: 'crm-123', name: 'Sara', email: '…', hmac: hmac),
);

Identified users get a stable, company-scoped guest id, so the same person keeps one conversation history across devices and reinstalls. Token refreshes re-send the identity automatically — an expiring session never demotes a verified customer back to an anonymous guest.

Botflow form questions

When a flow asks the guest something — a name, a delivery location, a photo of a prescription — it arrives as a form question and EbChatScreen answers it for you: text questions focus the composer, and media/location questions upload and send whatever your picker returns.

Two hooks are all it needs. The SDK bundles no picker plugins, so the camera, file browser and GPS stack stay out of hosts whose flows never ask for them:

EbChatScreen(
  chat: chat,
  onPickFile: (request) async {
    // request.kind is EbChatFormKind.photo or .file
    final shot = await ImagePicker().pickImage(source: ImageSource.camera);
    if (shot == null) return null;            // cancelled — question stays open
    return EbChatPickedFile(
      bytes: await shot.readAsBytes(),
      filename: shot.name,
      mimeType: shot.mimeType ?? 'image/jpeg',
    );
  },
  onResolveLocation: () async {
    final fix = await Geolocator.getCurrentPosition();
    return GeoLocation(latitude: fix.latitude, longitude: fix.longitude);
  },
)

example/lib/form_pickers.dart is a complete implementation with permission handling — copy it. The example's Info.plist and AndroidManifest.xml show the entries image_picker and geolocator require.

Both hooks are optional. Omit onPickFile and media questions fall back to your manual onAttach override (if you provided one), then to the composer; omit onResolveLocation, or return null from it when permission is denied, and the guest is asked to type the address instead. Every question stays answerable by typing, so a missing hook slows the flow rather than stalling it.

While a direct-node form is waiting on the composer, its hint steers the guest ("Share your location or type the address…"), so the flow's expectation is visible without tapping anything.

Pass onForm to take over form handling entirely — you then own sending the answer through the controller.

Voice notes & attachments

The SDK owns uploading, sending and rendering; you supply the platform pieces it deliberately doesn't bundle — a picker and a recorder. example/lib/form_pickers.dart and example/lib/voice_notes.dart are the reference wiring — copy them and every hook below is filled in.

Attaching files

Three hooks, in order of preference:

Hook What it does
onPickAttachments Preferred — multi-select. Return every file the guest picked and each queues in the tray as its own attachment: five photos of a broken product are one gallery visit, not five. Takes precedence over onPickAttachment.
onPickAttachment Single-select. Return one EbChatPickedFile, or null on cancel.
onAttach Manual override: you own the whole flow (custom sheet, your own send through the controller) — the SDK gives the paperclip a callback and steps aside; the tray is not used. Takes precedence over both pickers.

The paperclip only appears when one of these is wired and the company has attachments enabled in its ebchat settings. Files over the company's size limit are rejected at pick time — before any upload starts.

Recording voice notes

Provide onRecordStart + onRecordStop and the composer shows a mic while the field is empty (unless the company disabled voice notes). Optional extras:

  • onRecordCancel — the guest discarded the recording; throw the audio away.
  • onRecordPause / onRecordResume — provide both and the recording bar gains a pause button; the timer excludes paused time.
  • onRecordAmplitude — a 0..1 level stream; provide it and the bar shows a live waveform instead of a static one.

onRecordStart returns false on a refused microphone — the composer stays in its text state and the guest is told to check app permissions instead of a mic that silently does nothing. example/lib/voice_notes.dart wires all six hooks over the record package, including the dBFS → 0..1 normalization the waveform expects.

The attachment tray: review before send

Nothing is uploaded or sent at pick or stop time. Picked files and finished recordings queue in a tray above the composer where the guest reviews them:

  • Voice notes are playable in place — hearing the clip back before an operator does is the whole point of not sending on stop.
  • Every item is removable; images show a thumbnail, files their name.
  • Send transmits the queued batch; composer text goes out as its own message right after (the protocol has no caption field).
  • While uploading, an item shows a spinner in place — a slow cellular upload doesn't read as a lost file.
  • A failed upload marks that one tile "Not sent — tap to retry", bytes intact (a voice note cannot be re-made). Retry is per item.

Connectivity & resilience

The SDK assumes the network will misbehave and heals without host code:

  • Automatic realtime reconnect. When the socket dies, fast polling covers immediately while reconnect attempts back off exponentially; a built-in "Reconnecting…" banner shows above the transcript. Even while connected, a slow safety poll bounds how stale a zombie socket can silently get.
  • connectionState on the controller. EbchatConversationController exposes a connectionState stream and currentConnectionStateconnected / reconnecting / pollingOnly — if your host UI wants its own indicator.
  • Failed-send retry. A send that fails (airplane mode, server error) keeps the bubble, marked "Not sent — tap to retry". Tapping the bubble retries with the same client idempotency key, so a duplicate is impossible even if the original actually landed. Attachment failures retry per tile in the tray. If the conversation was closed under the guest mid-send, the bubble is dropped (a retry can never succeed) and the text is preserved as a draft in the reopened conversation.
  • Silent token refresh. The guest token lives 24 hours; on expiry the SDK re-mints the session with the persisted guest id (identified users re-send their identity + HMAC) and retries the failed call once. A long-lived chat heals instead of bricking until app restart.
  • App-lifecycle resync. On resume, the screen refetches what arrived while the app was backgrounded and revives the socket (iOS freezes sockets in the background); on pause it clears the guest's typing state.

Polling-mode feature matrix

When the bootstrap advertises no realtime — or while a socket is down — the SDK polls. Everything works, with these differences:

Capability Realtime Polling
New messages instant next poll (default 5 s)
Typing indicators yes (auto-expire after 6 s without a stop) never fire
Delivered/read ticks live (message.updated deltas) via refetch — up to one poll interval late
Conversation closed instant (conversation.closed event) learned on the next poll or send (409)

Platform setup (permissions)

This package ships no camera, microphone, file-browser or GPS plugin. That is deliberate — a chat SDK that drags all four in costs every host four permission prompts and a heavier binary, including hosts whose flows never ask for any of them. You supply the pickers; the SDK uploads and sends what they return.

The consequence is that the permissions belong to your app, and your app must declare them. A missing usage description does not degrade gracefully: iOS terminates the process the moment the picker opens.

Declare only what you actually wire up.

iOS — ios/Runner/Info.plist

Key Needed when
NSCameraUsageDescription onPickFile / an attach picker offers the camera
NSPhotoLibraryUsageDescription either picker offers the photo library
NSMicrophoneUsageDescription you wire the voice-note recorder
NSLocationWhenInUseUsageDescription onResolveLocation is provided
<key>NSCameraUsageDescription</key>
<string>Take a photo to send in the chat.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Attach a photo to send in the chat.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Record a voice note to send in the chat.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>Share your location so we can deliver to the right address.</string>

Android — android/app/src/main/AndroidManifest.xml

<uses-permission android:name="android.permission.INTERNET"/>
<!-- Voice notes -->
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<!-- Location form questions -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>

Camera and file access go through the system pickers, which need no manifest entry of their own.

Declare RECORD_AUDIO explicitly. Recorder plugins ship it in their own manifest and it reaches your app through manifest merging, so voice notes appear to work without it being declared anywhere in your project. That is a trap: it leaves the permission invisible at review time and dependent on a transitive dependency's manifest.

Requesting at runtime

The pickers prompt on first use; you only need an explicit request if you want to ask ahead of time or show your own rationale. Return null from a picker on denial — every botflow question stays answerable by typing, so a refused permission slows the flow rather than stalling it.

example/ is a complete, working reference: form_pickers.dart (camera, photo library, files, GPS — single and multi-select), voice_notes.dart (recorder with pause/resume and a live level stream), plus the matching Info.plist and AndroidManifest.xml.

Push notifications

ebchat does not bundle Firebase. Pushes are delivered through your app's Firebase project ("notification outsourcing"): you own firebase_messaging, and ebchat sends through the service-account credentials you upload to your ebchat company settings. Nothing arrives until those credentials are in place.

What the gateway sends

Every bot or operator reply on the guest's conversation produces a notification + data message:

{
  "notification": { "title": "Sara",              // operator name, or the company for bot replies
                    "body":  "Your order is ready" },
  "data": { "type": "ebchat.message.new",
            "companyId": "...", "conversationId": "...",
            "messageId": "...", "sentAt": "2026-08-10T09:12:44.010Z" }
}

Because the payload carries notification, the OS displays it on its own when your app is backgrounded or terminated — you do not have to build a local notification to get the basic "you have a reply" behaviour. The data block is what you use to route a tap. On Android the messages are tagged per conversation, so a burst of bot replies collapses into one notification instead of stacking.

If the tenant has previews disabled (health, payments), body is a generic "New message" and the text stays off the lock screen. Internal operator notes never trigger a push at all.

1. Register the device

Do this after EbChat.init, and again whenever FCM rotates the token:

final token = await FirebaseMessaging.instance.getToken();
if (token != null) {
  await chat.registerPushToken(token,
      platform: Platform.isIOS ? 'ios' : 'android');
}

FirebaseMessaging.instance.onTokenRefresh.listen((t) =>
    chat.registerPushToken(t, platform: Platform.isIOS ? 'ios' : 'android'));

Call chat.unregisterPushToken(token) on logout so a shared device stops receiving the previous user's replies.

2. Route a tap into the conversation

EbChatPush.parse returns null for anything that is not an ebchat push, so it is safe to hand it every message you receive.

// Cold start: the notification that launched the app.
final initial = await FirebaseMessaging.instance.getInitialMessage();
_open(EbChatPush.parse(initial?.data ?? {}));

// Warm start: tapped while the app was in the background.
FirebaseMessaging.onMessageOpenedApp.listen((m) => _open(EbChatPush.parse(m.data)));

void _open(EbChatPush? push) {
  if (push == null) return;
  // push.companyId picks the EbChat instance; push.conversationId is the thread.
  navigatorKey.currentState?.push(MaterialPageRoute(
    builder: (_) => Scaffold(body: EbChatScreen(chat: chatFor(push.companyId))),
  ));
}

3. Foreground behaviour

While your app is in the foreground the OS does not display the banner by default, and it should not while the user is already looking at the chat.

// iOS: let the banner through when the user is elsewhere in the app.
await FirebaseMessaging.instance.setForegroundNotificationPresentationOptions(
  alert: true, badge: true, sound: true);

// Android foreground: raise it yourself only when the chat screen isn't open.
FirebaseMessaging.onMessage.listen((m) {
  final push = EbChatPush.parse(m.data);
  if (push == null || isChatScreenOpen(push.conversationId)) return;
  showLocalNotification(m.notification?.title, m.notification?.body);
});

EbChatScreen receives the message over realtime regardless of push, so an open chat updates whether or not the notification is shown.

4. Background handling with EbChatBackground.handleMessage (optional)

The OS already renders backgrounded pushes on its own. If you want to enrich or replace that — a custom local notification with the real message text, a badge count — EbChatBackground.handleMessage does the data work inside your FCM background isolate, where no EbChat instance exists:

@pragma('vm:entry-point')
Future<void> firebaseMessagingBackgroundHandler(RemoteMessage message) async {
  final result = await EbChatBackground.handleMessage(
    message.data,
    baseUrl: Uri.parse('https://api.enable.tech'),
    apiKey: 'eb_...',
    partnerId: 'your-app',
    sessionStore: PrefsSessionStore(), // the SAME persistent store you init with
  );
  if (result == null) return;          // not an ebchat push
  // result.push          → companyId / conversationId / messageId for routing
  // result.latestMessage → newest operator/bot message (null → generic body)
  // result.unreadCount   → for the badge
}

It re-sessions from the persisted guest id, fetches the pushed conversation's newest messages, and hands back what a local notification needs — rendering stays yours. It requires a persistent SessionStore (with the in-memory default there is no session to resume, and you get routing info only). Offline or a failed fetch degrade the same way: latestMessage comes back null and the push still routes.

5. Platform setup

  • Android — no extra code for the background case; the OS renders it. If you use a custom notification channel, name it ebchat_messages (or remap it in your own handler) so importance settings apply.
  • iOS — enable the Push Notifications capability and Background modes → Remote notifications, upload your APNs key to the same Firebase project, and request permission with FirebaseMessaging.instance.requestPermission(). Without an APNs key, iOS receives nothing.

Strings & RTL

EbChatScreen picks the bundled EbchatStrings.en / EbchatStrings.ar from the language you passed to EbChat.init. Two things to know when you customize strings (hosts composing the lower-level widgets directly):

  • Every string added since 5.1 has an English default, so an existing custom strings object keeps compiling across upgrades — override what you translate.
  • A custom right-to-left strings object must set rtl: true. Layout direction follows that field, not object identity with .ar — a copied or customized Arabic strings object without it renders left-to-right.

Multi-company hosts

Keep one EbChat per API key. A push carries companyId, so EbChatPush.parse(...) tells you which instance to open.

Local development

Point baseUrl at your local gateway and use the dev key that make livechat-up provisions (eb_dev_livechat / partner livechat-dev). make livechat-info reprints it. On the Android emulator the gateway is http://10.0.2.2:3000, not localhost.

License

MIT — see LICENSE.

Libraries

ebchat
Embeddable ebchat chat SDK for Flutter, on the first-party livechat protocol.