ConvoKit Flutter SDK
Flutter SDK for adding ConvoKit conversations, messages, realtime events, and media uploads to a Flutter app.
The SDK talks to your ConvoKit backend over REST and uses Supabase Realtime for live messaging events. Your ConvoKit client secret should stay on your server; mobile and web clients should only receive short-lived user tokens from your own token endpoint.
Features
In development: live inbox and send correlation
These additions require the upcoming coordinated backend/SDK/UI release; they are not included in published 0.3.x packages yet.
ConvoKit.realtime.onInboxChanged(appId) emits an empty signal on room,
membership, profile and cascade changes, and on every verified join/rejoin.
Use the connected app ID from ConvoKit.lastTokenClaims['app_id'] and refetch
authorized conversations in a custom UI. The matching UI package handles this
automatically. This replaces the unimplemented onConversationUpdate() stub.
Every send carries a UUID clientMessageId, returned on Message through REST,
history and Realtime. The SDK generates it when omitted. Custom optimistic UIs
can call createClientMessageId() before showing the pending message and pass
it to ConvoKit.sendMessage(clientMessageId: id, ...). Match by ID, room and
sender, never by similar text or attachments. Reuse the ID only to retry the
same send; identical retries return the existing message, conflicts return 409.
Published baseline
- Configure a ConvoKit app client and connect an app user.
- Create, fetch, archive, unarchive, and leave conversations.
- Send and paginate messages with text, image, and file media; fetch a single message by id.
- Look up app user profiles, including their last-seen snapshot.
- Subscribe to realtime message (insert/update), typing, read receipt, and presence events.
- Upload user avatars, conversation images, and message attachments through short-lived R2 upload URLs, then verify each object with the backend.
- Session auto-refresh: the SDK refreshes tokens proactively ahead of expiry and reactively on a 401, transparently to callers.
- Typed errors (
ConvoKitAuthException,ConvoKitNotFoundException, etc.) so callers can distinguish transient failures from permanent ones. - Use typed Dart models for
Conversation,Message,Participant, andAppUser.
Installation
Version 0.3 requires Flutter 3.19 or later (Dart 3.3+) and
supabase_flutter >=2.14.0 <3.0.0. Earlier pre-release versions are unsupported
after the coordinated backend cutover. The SDK discovers
the Supabase URL and publishable key automatically. Customers configure neither.
Add the package to your Flutter app:
flutter pub add convokit_flutter
Then import it:
import 'package:convokit_flutter/convokit_flutter.dart';
Setup
Configure ConvoKit once when your app starts. The tokenProvider must call your
own backend and return a ConvoKit JWT for the current app user. Do not embed
your ConvoKit client secret in a Flutter app.
import 'dart:convert';
import 'package:convokit_flutter/convokit_flutter.dart';
import 'package:http/http.dart' as http;
Future<void> bootstrapConvoKit() async {
ConvoKit.configure(
clientId: 'your-convokit-client-id',
tokenProvider: (appUserId) async {
final response = await http.post(
Uri.parse('https://your-api.example.com/convokit/token'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'appUserId': appUserId}),
);
if (response.statusCode != 200) {
throw Exception('Failed to fetch ConvoKit token');
}
final body = jsonDecode(response.body) as Map<String, dynamic>;
return body['token'] as String;
},
);
}
ConvoKit uses the managed https://api.convokit.app endpoint automatically.
For local development, testing, or a self-hosted deployment only, pass
backendUrl to ConvoKit.configure; ConvoKit.defaultBackendUrl exposes the
managed default.
Call connectUser after your app has identified the current user:
await ConvoKit.connectUser('app-user-123');
When the user signs out, disconnect realtime subscriptions:
await ConvoKit.disconnectUser();
ConvoKit owns a dedicated Supabase client; a host app may use its own
Supabase.instance independently. Switching users or reconfiguring retires the
previous session, closes its streams and rejects pending operations. Capture a
new ConvoKit.realtime after connecting the replacement user.
Renewal uses the earlier expiration of the REST and Realtime JWTs, starts before expiry, and is shared across concurrent requests. Temporary renewal failures use bounded backoff; an independent deadline closes an expired session even if your token provider hangs. The provider must issue fresh, extended credentials.
Conversations
final conversations = await ConvoKit.getConversations(limit: 20);
final conversation = await ConvoKit.createConversation(
participants: ['app-user-123', 'app-user-456'],
title: 'Product support',
);
await ConvoKit.updateConversationTitle(
conversationId: conversation.id,
title: 'Billing support',
);
await ConvoKit.archiveConversation(conversation.id);
await ConvoKit.unarchiveConversation(conversation.id);
await ConvoKit.leaveConversation(conversation.id);
Messages
final message = await ConvoKit.sendMessage(
conversationId: conversation.id,
text: 'Hey, can you take a look?',
);
final messages = await ConvoKit.getMessages(
conversationId: conversation.id,
limit: 50,
);
final single = await ConvoKit.getMessage(message.id);
For older-history paging in 0.3+, pass beforeCreatedAt: messages.last.createdAt
and beforeId: messages.last.id together (only when the page is nonempty).
Keep offset at zero. The backend orders by timestamp then message ID descending;
the cursor still works if its original row has been deleted. This requires the
coordinated 0.3 backend cursor release.
To send media, upload the bytes first and pass the returned URL in the message payload:
final url = await ConvoKit.uploadMessageMedia(
bytes: imageBytes,
fileName: 'screenshot.png',
conversationId: conversation.id,
);
await ConvoKit.sendMessage(
conversationId: conversation.id,
text: 'Screenshot attached',
media: [
{
'type': 'image',
'url': url,
'name': 'screenshot.png',
},
],
);
The SDK completes each successful R2 upload with the ConvoKit backend before returning its media URL. This lets the backend verify the stored object and record its authoritative size before it is attached to a message.
Users
final user = await ConvoKit.getUser('app-user-456');
// user.name, user.imageUrl, user.lastSeenAt
final users = await ConvoKit.getUsers(limit: 50);
getUser/getUsers return AppUser — named to avoid colliding with
supabase_flutter's own User type.
Realtime
Subscribe through ConvoKit.realtime after connectUser completes.
final messageSub = ConvoKit.realtime
.onMessage(conversation.id)
.listen((event) {
switch (event.type) {
case MessageChangeType.insert:
// Render the new message: event.message
break;
case MessageChangeType.update:
// Replace the edited message in place: event.message
break;
}
});
// Deletion events contain IDs only, not a full old Message.
final deletionSub = ConvoKit.realtime
.onMessageDeleted(conversation.id)
.listen((event) {
// Remove event.id from event.conversationId in your local message cache.
// Retain a deletion marker so an older HTTP/Realtime row cannot restore it.
});
final typingSub = ConvoKit.realtime
.onTyping(conversation.id)
.listen((event) {
// event.userId and event.isTyping
});
final readSub = ConvoKit.realtime
.onReadReceipt(conversation.id)
.listen((event) {
// event.userId and event.readAt
});
Send typing indicators and read receipts with the REST helpers:
await ConvoKit.sendTyping(
conversationId: conversation.id,
isTyping: true,
);
await ConvoKit.markConversationRead(conversation.id);
Presence events are scoped to the app ID in the issued JWT. Read the scope from the connected user's claims (the managed API currently also uses it as the client ID):
final presenceSub = ConvoKit.realtime
.onPresence(ConvoKit.lastTokenClaims['app_id'] as String)
.listen((event) {
// event.userId, event.isOnline, event.lastSeenAt
});
await ConvoKit.updatePresence(isOnline: true);
Cancel stream subscriptions from your widget or state manager when they are no longer needed.
Typing, read-receipt, and deletion listeners share one private room channel. Cancelling one does not replace or disconnect the other; cancelling the last listener releases that channel. All SDK channels are private and authenticated before subscription.
Observe ConvoKit.errors for background session failures and
ConvoKit.realtime.errors for sanitized Realtime failures. Attach an onError
handler to event subscriptions too. ConvoKitSessionException.code distinguishes
session changes, expiry and connection errors without exposing provider responses.
ConvoKit.realtime.connectionEvents emits a topic and
RealtimeConnectionStatus.subscribed, interrupted, or closed. A subsequent
subscribed event lets your UI refetch REST history after a reconnect; Realtime
does not replay missed messages. A join acknowledgement is not a delivery receipt
or proof that asynchronous replication setup succeeded—continue observing errors.
Postgres Changes does not apply SELECT RLS to removed rows. This release does not
subscribe to raw DELETE events or assume a full deleted Message is available.
The onMessageDeleted() stream decodes the backend's private
message_deleted notification as MessageDeletedEvent(id, conversationId).
It validates that the payload belongs to the subscribed room. Replace the old
MessageChangeType.delete branch with this separate stream; the enum now has
only insert and update. This requires the coordinated backend/SDK release.
Notifications are best-effort, do not replay, and currently cover explicit
administrator message deletion, not user/room/app cascades. Refetch REST history
on rejoin to reconcile missed deletions. Published 0.3.x requires refresh/reopen
to discover cascade changes and new rooms; the upcoming onInboxChanged()
contract described above removes that limitation.
Private room/app topics are discovered and rotated automatically on membership
loss. Remaining listeners rejoin without changing customer configuration; removed
users cannot receive subsequent activity on a retired topic. Events already sent
while access was valid may still be in flight.
See Postgres Changes limitations.
Media Helpers
final avatarUrl = await ConvoKit.uploadUserAvatar(
userId: ConvoKit.currentUserId,
imageBytes: avatarBytes,
fileName: 'avatar.png',
);
final conversationImageUrl = await ConvoKit.uploadConversationImage(
conversationId: conversation.id,
imageBytes: imageBytes,
fileName: 'group.png',
);
await ConvoKit.deleteUserAvatar(userId: ConvoKit.currentUserId);
await ConvoKit.deleteConversationImage(conversationId: conversation.id);
Error Handling
REST helpers throw ConvoKitException when the backend returns a non-success
response.
Only an explicit HTTP 401 can refresh and retry a REST request once. HTTP 403, 5xx and network failures do not automatically replay writes: a lost response may follow a successful mutation. Storage PUTs never receive ConvoKit credentials and are not retried automatically. An upload interrupted by logout may leave bytes at storage, but cannot complete or attach them under a replacement user.
try {
await ConvoKit.sendMessage(
conversationId: conversation.id,
text: 'Hello',
);
} on ConvoKitException catch (error) {
// Show a retry state or log error.message.
}
Testing
Offline SDK regression and acceptance-helper tests do not contact a backend:
flutter test --coverage test/convokit_flutter_test.dart test/live_security_checks_test.dart test/client_session_test.dart test/session_http_test.dart test/realtime_protocol_test.dart
flutter analyze
CI runs these checks on Flutter 3.19.6 at the Supabase Flutter 2.14.0 floor
(flutter pub upgrade, then flutter pub downgrade supabase_flutter supabase realtime_client) and
Flutter 3.38.5 with the newest compatible dependencies (flutter pub upgrade).
This checks the Supabase/Realtime provider floors too; other transitive packages
use compatible resolutions, not every historical minimum. The publishing
workflow requires the same checks. Neither CI job uses
live credentials or counts as staging acceptance.
The separate live smoke suite requires the matching security-cutover backend and Supabase configuration, an existing staging app user with active room membership, and an existing message that user may read. Store this JSON outside the repository, replacing the placeholders with staging-only values:
{
"BACKEND_URL": "https://staging-api.example.invalid",
"CLIENT_ID": "STAGING_CLIENT_ID",
"CLIENT_SECRET": "STAGING_CLIENT_SECRET",
"APP_USER_ID": "EXISTING_ACTIVE_MEMBER",
"MESSAGE_ID": "EXISTING_READABLE_MESSAGE"
}
flutter test test/integration_test.dart \
--dart-define-from-file=/absolute/private/convokit-staging.json
Missing values fail before connecting; there is no implicit production endpoint,
fallback user, or silent skip. A plain flutter test includes the live file and
therefore also requires this configuration. This runner simulates a trusted
server's token exchange: never embed its client secret or define file in a
customer Flutter app. It does not create/delete fixture records or broadcast
messages, but authentication and reads can produce backend usage records.
The live suite checks generation/app-bound tokens, an exact REST-verified Message row through PostgREST, explicit PostgreSQL permission denials for 11 sensitive tables (including Conversation), and private app/room/message channel joins. Supabase URL and publishable key are discovered by the SDK; they are not test configuration or customer setup parameters. Private channel authorization and row visibility are separate controls. See Realtime authorization.
A successful join is not evidence of event delivery. Release acceptance still requires the two-app/two-member/non-member/departed-member matrix, direct-write denial, messages/typing/receipts/presence/files, reconnects, token expiry and already-joined access revocation. Run actual event assertions against staging; offline tests and this read-only smoke suite do not replace that matrix. In particular, verify DELETE behavior separately under the deployed RLS configuration; see Postgres Changes limitations.
Publishing
Publishing to pub.flutter-io.cn is automated through GitHub Actions when a version tag is pushed, after both compatibility test jobs pass. A tag must match the package version; never reuse an already published version for changed source.
Before the workflow can publish, the package must already exist on pub.flutter-io.cn. If
this is the first release, publish it manually once with dart pub publish or
flutter pub publish.
Enable automated publishing from the pub.flutter-io.cn package admin page with:
- Repository:
ConvoKitApp/ConvoKit-Flutter-SDK - Tag pattern:
v{{version}} - GitHub Actions environment:
pub.flutter-io.cn
To publish a new version:
- Update
versioninpubspec.yaml. - Update
CHANGELOG.md. - Commit and push the changes.
- Push a matching version tag:
git tag v<new-version>
git push origin v<new-version>
License
ConvoKit Flutter SDK is licensed under the Apache License, Version 2.0. See
LICENSE for details.