ebchat 5.2.0
ebchat: ^5.2.0 copied to clipboard
Embeddable ebchat chat SDK for Flutter apps. Drop-in chat screen and widgets on the first-party livechat protocol, with host-owned push ("notification outsourcing"), botflow forms and multi-company support.
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
ebchat3.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
SessionStoretoEbChat.initin 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.
connectionStateon the controller.EbchatConversationControllerexposes aconnectionStatestream andcurrentConnectionState—connected/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_AUDIOexplicitly. 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.