Skip to content

Flutter SDK

The official Flutter SDK (uchara_sdk) for embedding realtime chat into Flutter applications. It is headless — it provides no prebuilt UI — so you build the widget experience yourself and drive it with the SDK’s session, conversation, message, upload, and realtime APIs.

  • Dart >=3.0.0
  • Flutter >=3.10.0

Add to your pubspec.yaml:

dependencies:
uchara_sdk: ^1.0.1

Then run:

Terminal window
flutter pub get

The SDK exposes a single headless VisitorSDK class. It wraps the /v1/widget/* REST endpoints and the /ws/visitor WebSocket connection. Because it is headless, it works on any platform Flutter supports (iOS, Android, web, macOS, Linux, Windows) and never assumes a particular UI.

Create a VisitorConfig with the API base URL and the public widget token for your channel:

final config = VisitorConfig(
apiUrl: 'https://api.uchara.com',
widgetToken: 'wgt_your_token',
identity: VisitorIdentity(
externalId: 'user_123',
name: 'Jane Doe',
email: 'jane@example.com',
phone: '+6281234567890',
metadata: {'plan': 'pro'},
),
autoConnect: true, // open the realtime socket after init()
autoReconnect: true, // reconnect after an unexpected drop
maxReconnectAttempts: 10, // bounded exponential backoff
tokenStore: MyTokenStore(), // optional durable persistence
);
FieldTypeDefaultDescription
apiUrlStringBase URL of the Uchara API, e.g. https://api.uchara.com. Must use http/https.
widgetTokenStringPublic widget token for the channel.
identityVisitorIdentity?nullIdentity metadata sent during session initialisation.
autoConnectbooltrueOpen the realtime connection automatically after init().
timeoutDuration30sPer-request HTTP timeout.
tokenStoreTokenStore?InMemoryTokenStorePersistence for the visitor token.
autoReconnectbooltrueAuto-reconnect the WebSocket after an unexpected drop.
maxReconnectAttemptsint10Maximum WebSocket reconnect attempts.
initialReconnectDelayDuration500msDelay before the first reconnect attempt.
maxReconnectDelayDuration30sUpper bound for the reconnect delay.
autoPresenceHeartbeatbooltrueAutomatically refresh visitor online presence while connected.
presenceHeartbeatIntervalDuration20sPresence refresh interval; the backend presence TTL is 35 seconds.
autoRefreshSessionbooltrueAutomatically renew the visitor JWT before expiry.
sessionRefreshBeforeExpiryDuration5mHow early to renew the visitor JWT.
sessionRefreshIntervalDuration12hFallback refresh interval when token expiry cannot be decoded.
autoPresenceHeartbeatbooltrueAutomatically refresh visitor online presence while connected.
presenceHeartbeatIntervalDuration20sPresence refresh interval; the backend presence TTL is 35 seconds.

Optional identity metadata used during session initialisation: externalId, name, email, phone, and free-form metadata.

Exchanges the widget token for a visitor JWT and contact identity, persists the token, and (when autoConnect is enabled) opens the realtime connection.

final VisitorSDK visitor = VisitorSDK(config);
final VisitorSession session = await visitor.init();
print(session.visitorToken);
print(session.contactId);

Restores a previously persisted visitor session from the TokenStore so a returning visitor does not need to re-authenticate. Returns true when a token was restored, false when none was stored.

final bool restored = await visitor.restoreSession(
conversationId: 'conv_abc123', // optionally restore the active conversation
connect: true, // open the realtime connection (default)
);

The visitor token can be persisted across SDK instances. The default InMemoryTokenStore keeps the token only for the lifetime of the process. Provide your own TokenStore backed by e.g. shared_preferences or flutter_secure_storage for durable persistence. The SDK also automatically refreshes an initialized visitor session before the JWT expires; call refreshSession() when an explicit refresh is needed:

abstract class TokenStore {
Future<String?> read();
Future<void> write(String token);
Future<void> clear();
}
// Fetch the public widget configuration (channel name + config)
final WidgetConfigResult cfg = await visitor.getConfig();
// Get the active conversation, or null when none exists
final Conversation? active = await visitor.getActiveConversation();
// Start a new conversation (optionally with a first message)
final Conversation conv = await visitor.startConversation(
message: 'Hello!',
metadata: {'source': 'ios'},
);
// Close the conversation
await visitor.closeConversation(conv.id);

When an active conversation is found or a new one is started, the SDK automatically reconnects the WebSocket to that conversation’s room so its events are received.

getMessages uses offset/limit pagination and returns a MessagePage with messages, total, and perPage:

final MessagePage page = await visitor.getMessages(
conv.id,
limit: 50,
offset: 0,
);
for (final Message m in page.messages) {
print('${m.senderType}: ${m.content}');
}
final Message msg = await visitor.sendMessage(
conv.id,
content: 'Thanks for your help!',
);

Note: Sending attachments inline via sendMessage is not supported by the backend. The legacy attachmentIds / attachments parameters are deprecated and ignored. Upload files first with uploadFile — the backend creates the message carrying the attachment.

final Message uploaded = await visitor.uploadFile(
conv.id,
filename: 'receipt.pdf',
bytes: fileBytes, // List<int>
contentType: 'application/pdf',
);

downloadConversation returns the transcript as raw Uint8List rather than a dart:io File, keeping the API platform-agnostic (including web):

final Uint8List transcript = await visitor.downloadConversation(conv.id);

The SDK exposes a broadcast stream of typed events via visitor.events. The connection state is available via visitor.connectionState and visitor.connectionStateStream.

visitor.events?.listen((WSEvent event) {
switch (event) {
case MessageNewEvent(:final message):
// a new message arrived
case MessageDeltaEvent(:final delta, :final done):
// a streaming bot response chunk
case TypingEvent(:final senderType, :final stop):
// typing indicator started (stop == false) or stopped (stop == true)
case PresenceEvent(:final online):
// a member/contact presence update
case ConversationResolvedEvent(:final conversationId):
// the conversation was resolved
case ConversationEvent(:final type, :final payload):
// a generic conversation.* event
case UnknownEvent(:final type):
// a forward-compatible event the SDK does not recognise
}
});
visitor.sendTyping(conv.id); // start typing indicator
visitor.stopTyping(conv.id); // stop typing indicator
visitor.presenceHeartbeat(); // keep the visitor marked online

WsConnectionState is one of disconnected, connecting, connected, reconnecting, or closed. The client keeps the connection alive with pings and reconnects with bounded exponential backoff plus jitter.

The SDK avoids dart:io in its public API. downloadConversation returns Uint8List, and the WebSocket is abstracted behind a socket interface, so the same code runs on web, mobile, and desktop.

Security caveat: token in the WebSocket query

Section titled “Security caveat: token in the WebSocket query”

The realtime connection is opened to /ws/visitor?token=...&conv=.... The visitor token is sent as a query parameter, which the backend requires. Query strings can be logged by proxies and intermediaries, so treat the visitor token as sensitive and avoid exposing it in places where it could be captured.

The SDK throws typed errors so callers can react appropriately:

ErrorMeaning
ServerEnvelopeExceptionServer returned a structured error envelope ({"ok":false,"error":{...}}). Exposes statusCode, code, requestId.
ApiExceptionAn HTTP error that did not carry a structured envelope.
NetworkExceptionA transport-level failure (DNS, refused, reset).
UcharaTimeoutExceptionA request exceeded its configured timeout.
ProtocolExceptionA WebSocket protocol violation or malformed payload.

All errors extend UcharaException, which carries a message and optional details.

try {
await visitor.sendMessage(conv.id, content: 'Hello');
} on ServerEnvelopeException catch (e) {
print('${e.statusCode} ${e.code}: ${e.message}');
} on NetworkException catch (e) {
print('Network error: ${e.message}');
}
import 'package:flutter/material.dart';
import 'package:uchara_sdk/uchara_sdk.dart';
class ChatPage extends StatefulWidget {
const ChatPage({super.key});
@override
State<ChatPage> createState() => _ChatPageState();
}
class _ChatPageState extends State<ChatPage> {
late final VisitorSDK visitor;
final List<Message> messages = [];
final TextEditingController _input = TextEditingController();
@override
void initState() {
super.initState();
visitor = VisitorSDK(VisitorConfig(
apiUrl: 'https://api.uchara.com',
widgetToken: 'wgt_your_token',
));
_initChat();
}
Future<void> _initChat() async {
await visitor.init();
visitor.events?.listen((event) {
if (event is MessageNewEvent) {
setState(() => messages.add(event.message));
}
});
final conv = await visitor.getActiveConversation() ??
await visitor.startConversation(message: 'Hello!');
final page = await visitor.getMessages(conv.id, limit: 50);
setState(() {
messages.clear();
messages.addAll(page.messages);
});
}
Future<void> _send() async {
final convId = visitor.activeConversationId;
if (convId == null || _input.text.isEmpty) return;
final msg = await visitor.sendMessage(convId, content: _input.text);
setState(() => messages.add(msg));
_input.clear();
}
@override
void dispose() {
visitor.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Chat Support')),
body: Column(
children: [
Expanded(
child: ListView.builder(
itemCount: messages.length,
itemBuilder: (_, i) => ListTile(
title: Text(messages[i].content ?? ''),
subtitle: Text(messages[i].senderType.name),
),
),
),
Row(
children: [
Expanded(child: TextField(controller: _input)),
IconButton(icon: const Icon(Icons.send), onPressed: _send),
],
),
],
),
);
}
}