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

  • 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, and AppUser.

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. onConversationUpdate() is not implemented yet; refresh/reopen to discover cascade changes and new rooms. 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:

  1. Update version in pubspec.yaml.
  2. Update CHANGELOG.md.
  3. Commit and push the changes.
  4. 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.

Libraries

convokit_flutter