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

Quoted replies and jump-to-message (0.9.0)

ConvoKit.sendMessage(conversationId: id, text: ..., replyToMessageId: parent.id) quotes another message in the same room, and Message.replyToMessageId carries the reference on every surface (REST rows, inbox previews and Realtime row images). The reference is write-once and survives the quoted message being edited or deleted. Resolve the quoted text in one batched call per rendered page with ConvoKit.getReplyPreviews(id, messageIds: [...]), which returns bounded ReplyPreview rows and simply omits ids that are gone. ConvoKit.getMessageContext(id, messageId: parent.id) loads a window centred on a message that is not in the loaded page, with olderCursor/newerCursor for paging either way, so a room can open at a quoted message instead of paging back from the newest page. Message.copyWith(...) keeps the new field alive through row rebuilds. See Quoted replies and message context. This requires the coordinated 0.9 backend release: an older backend ignores replyToMessageId on a send and answers both new routes with an unmatched-route 404 without a code, which must not be read as "message gone".

Edit and delete your own messages (0.8.0)

ConvoKit.editMessage(id, text: newText, revision: message.revision) edits the text of the caller's own message and ConvoKit.deleteMessage(id) removes it, through the author-tier PATCH/DELETE /api/v1/messages/:id/own routes (separate from the administrative /api/v1/messages/:id routes your server calls with the client secret). Message gains revision (0 at creation, incremented by one on every content edit, author or administrative) and the derived isEdited (revision > 0); updatedAt is never the edited signal. An edit sends the revision the user was shown and a stale one is rejected with 409 REVISION_CONFLICT; a null text clears the caption of a message with attachments. See Editing and deleting messages. This requires the coordinated 0.8 backend release: an older backend answers the /own routes with an unmatched-route 404 without a code and sends no revision (parsed as 0, never edited).

Mark unread (0.7.0)

ConvoKit.markConversationUnread(id) sets a private "mark unread" marker on the caller's own membership and clearConversationUnread(id, ifVersion: v) removes it, both returning the caller's private state (unreadMarkedAt, privateStateVersion). Inbox entries gain isUnread, unreadMarkedAt and privateStateVersion; a marked room is isUnread with its real unreadCount (render a numberless dot when the count is 0). getConversation exposes the caller's own row as Conversation.membership; capture its privateStateVersion when a room opens and pass it to markConversationRead(id, throughMessageId:, privateStateVersion:) so only acknowledgements of that open clear the marker. See Mark unread. Other members, participant DTOs, webhooks and read events never carry the marker; the caller's other devices refetch on onInboxActivity. This requires the coordinated 0.7 backend release: an older backend answers the /unread routes with 404, sends no membership sibling (the field is null) and no new inbox fields (isUnread is derived, privateStateVersion is 0).

Inbox previews and unread counts (0.6.0)

ConvoKit.listInbox() returns the caller's rooms ordered by activity with the latest message preview, an accurate unread count and the caller's own read state per entry, paged by an opaque cursor (default 30, at most 100 per page). ConvoKit.realtime.onInboxActivity(appId) signals message sends, edits and read-position advances anywhere in the app so an inbox screen can refetch previews and counts; onInboxChanged keeps its structural semantics. See Inbox. This requires the coordinated 0.6 backend release: an older backend answers GET /api/v1/inbox with 404 (ConvoKitNotFoundException) and never broadcasts inbox_activity. getConversations is unchanged.

Precise read positions (0.5.0)

ConvoKit.markConversationRead(id, throughMessageId: messageId) acknowledges a concrete message instead of "everything the server has now", so a delayed request can no longer mark a message that arrived after the screen rendered as read. The backend stores the message's (createdAt, id) cursor as a ReadPosition that only advances; Participant.readPosition and ReadEvent.readPosition expose it beside the acknowledgement time, and the top-level readThrough(...) helper applies the same rule as every other ConvoKit client. See Read receipts. This requires the coordinated backend release: an older backend ignores the target and returns no positions, and both fields stay null. ConvoKitException.code carries the backend's machine-readable error code when its body includes one.

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; edit and delete your own messages; quote a message as a reply and load a window of context around any message.
  • 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);

getConversations lists rooms in creation order with offset paging and is unchanged. Inbox screens should use listInbox below.

Inbox

listInbox returns one InboxEntry per living membership, newest activity first, with the latest message preview and the caller's unread count:

final page = await ConvoKit.listInbox(limit: 30);

for (final entry in page.entries) {
  final Conversation conversation = entry.conversation;
  final Message? preview = entry.latestMessage; // null for an empty room
  final int unread = entry.unreadCount;
  final bool capped = entry.unreadCountCapped; // true: render "99+"
  final bool isUnread = entry.isUnread; // count, cap or a private marker
  final DateTime? marked = entry.unreadMarkedAt; // the caller's own marker
  final int version = entry.privateStateVersion; // see "Mark unread"
  final ReadPosition? mine = entry.readPosition; // the caller's own position
  final DateTime activityAt = entry.activityAt; // the ordering key
}

// Next page: pass the cursor unchanged; null means this was the last page.
if (page.nextCursor != null) {
  final more = await ConvoKit.listInbox(cursor: page.nextCursor);
}

// Archived memberships instead of active ones.
final archived = await ConvoKit.listInbox(archived: true);

Each entry is an InboxEntry with conversation and an InboxSummary (entry.summary: latestMessage, unreadCount, unreadCountCapped, readPosition, lastReadAt, isUnread, unreadMarkedAt, privateStateVersion, activityAt); the summary fields are also available directly on the entry. conversation has the getConversations shape, except that participants is bounded to at most ten members (the caller, the latest sender when still a member, then active members by id); fetch getConversation(id) for the full roster. latestMessage has the getMessages row shape with at most the first four media items.

Ordering: activityAt is the newest surviving message's creation time, or the room's creation time for an empty room, with ties broken by conversation id descending. Editing a message changes the preview but never moves the room; deleting the newest message recalculates both. Server order is authoritative within a page; when merging pages after live updates, dedupe by conversation id and let the later entry win.

Unread counts follow the same readThrough rule as read receipts: messages from other participants after the caller's read position (or after lastReadAt for a membership without a position; everything from others for a fresh membership). Own messages never count. The backend counts over a bounded window: when more than 1,000 messages follow the position, unreadCount is a lower bound over the first 1,000 and unreadCountCapped is true.

isUnread is unreadCount > 0 || unreadCountCapped || unreadMarkedAt != null: the count plus the caller's own mark unread marker. Render the numeric badge (or "99+") when the count is positive or capped, and a numberless dot (accessible name "Unread") when isUnread is true with a zero count; never invent a number for a marker. A 0.6 backend sends none of the three fields: isUnread is then derived from the count, unreadMarkedAt is null and privateStateVersion is 0. An InboxSummary built locally derives isUnread the same way unless it is passed explicitly.

Paging: limit must be an integer in 1..100 (default 30); any other value throws ArgumentError before a request is sent. cursor is opaque; pass a previous page's nextCursor unchanged. It stays valid after its room moves or is deleted. A malformed cursor fails with ConvoKitValidationException carrying code == 'INVALID_CURSOR'; other rejected parameters carry INVALID_ARGUMENT. Restart from the head (no cursor) on INVALID_CURSOR, do not evict the session. getConversations and its offset paging are unchanged.

Live updates: subscribe to both app-hub streams. onInboxChanged still fires for room, membership, profile, deletion and cascade changes and on every verified join/rejoin; onInboxActivity fires after a message insert or edit, after any member's read-position advance and after the caller's own unread marker changes (a mark, a clear, or an acknowledgement that clears it), and is never synthesised on a join. Refetch listInbox from the head on either signal, throttling activity signals in busy apps:

final appId = ConvoKit.lastTokenClaims['app_id'] as String;
final structuralSub =
    ConvoKit.realtime.onInboxChanged(appId).listen((_) => reload());
final activitySub =
    ConvoKit.realtime.onInboxActivity(appId).listen((_) => reload());

This requires the coordinated 0.6 backend release.

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.

Editing and deleting messages

The author of a message may edit its text or delete it while still an active member with a writing role. These are the author-tier routes PATCH /api/v1/messages/:id/own and DELETE /api/v1/messages/:id/own; the administrative PATCH/DELETE /api/v1/messages/:id routes stay on your server behind the client secret and are not reachable from this SDK.

// Edit: send the revision of the row the user was shown.
final edited = await ConvoKit.editMessage(
  message.id,
  text: 'Hey, can you take a look today?',
  revision: message.revision,
);
// edited.revision == message.revision + 1, edited.isEdited == true

// Clear the caption of a message that has attachments.
await ConvoKit.editMessage(photo.id, text: null, revision: photo.revision);

// Delete: unconditional, no revision.
await ConvoKit.deleteMessage(message.id);

Revisions: every Message carries revision, 0 as created and incremented by exactly one on every content edit, whether by the author or by an administrative edit (media-only administrative edits included). isEdited is revision > 0 on every surface (REST rows, inbox previews and Realtime UPDATE row images); never derive "edited" from updatedAt, which the backend always sends. The backend applies an edit only when the sent revision is still current and otherwise answers 409:

try {
  await ConvoKit.editMessage(message.id, text: draft, revision: message.revision);
} on ConvoKitValidationException catch (error) {
  if (error.statusCode == 409 && error.code == 'REVISION_CONFLICT') {
    // Someone else changed the message: reload it, show the new content and
    // retry with current.revision. The draft is untouched.
    final current = await ConvoKit.getMessage(message.id);
  }
}

Precedence for consumers that merge rows for one id (a REST response beside a live UPDATE row image): the higher revision wins and a lower one never overwrites it; equal revisions, and rows without a usable revision (pending sends, a 0.7 backend where every row is 0), fall back to updatedAt ?? createdAt.

Text: the backend trims it; an empty string is stored as null. A null text clears the caption of a message that has attachments and is rejected with a plain 400 (ConvoKitValidationException whose code is null) for a text-only message, because a message must keep text or at least one media item; only a malformed text or revision body is a 400 with code INVALID_ARGUMENT. Attachments are never changed by an edit; the content alias is not accepted. Both keys are always sent, so text: null travels as JSON null rather than being omitted.

Errors: a message that does not exist, was deleted, or belongs to a room the caller is not an active member of (also a departed member) is a single 404 with code MESSAGE_NOT_FOUND (ConvoKitNotFoundException) for both calls; another member's message is a 403 (ConvoKitAuthException); a stale revision is the 409 above. A revision outside 0..2147483647 throws ArgumentError before any request. Against a 0.7 backend the /own routes answer an unmatched-route 404 without a code: do not treat that as the message being gone.

Signals: an edit reaches the room as a Realtime UPDATE row image carrying the new revision and fires onInboxActivity on the app hub so inbox screens refetch previews; it never moves the room's activityAt.

Deleting cannot be undone. The message and its attachment records leave the conversation for every member: the caller's other devices and every other member receive onMessageDeleted for the room and onInboxChanged on the app hub, and the inbox preview moves to the previous surviving message. Files already received or downloaded cannot be retracted; stored files are reclaimed by the existing user or app deletion cleanup, not by this call. Keep a deletion marker so a late REST response or Realtime row for the id cannot restore it.

Quoted replies and message context

A message can quote another message in the same conversation. Pass the target's id when sending; the key is omitted entirely when you do not, so a plain send is byte-identical to one from a 0.8 SDK.

final reply = await ConvoKit.sendMessage(
  conversationId: conversation.id,
  text: 'Agreed.',
  replyToMessageId: parent.id,
);
// reply.replyToMessageId == parent.id

The reference is write-once: the backend sets it at send and neither editMessage nor an administrative edit can change it. It also survives the quoted message being edited or deleted, so a dangling id means the original is gone, not that the row is broken. A missing, deleted or foreign target on the send is a 404 with code MESSAGE_NOT_FOUND; a blank id or one over 64 characters is a 400 INVALID_ARGUMENT. Retrying a send with the same clientMessageId returns the stored row unchanged, even if the quoted message was deleted in between; retrying it with a different reply target is a 409.

Message.replyToMessageId is null when the row is not a reply. A missing key (a 0.8 backend) and an explicit null (a 0.9 backend, which sends the key on every row) are the same single state — there is no third "unknown".

Resolving the quoted text is a separate, batched read. Row images and REST rows never carry a preview, so collect the distinct replyToMessageId values of the rows you are about to render and ask for them in one call:

final parentIds = [
  for (final m in messages)
    if (m.replyToMessageId != null) m.replyToMessageId!,
];
final previews = await ConvoKit.getReplyPreviews(
  conversation.id,
  messageIds: parentIds,
);
final byId = {for (final p in previews) p.id: p};

The SDK trims each id, de-duplicates preserving first-seen order and splits the distinct list into requests of at most 50, merging them in chunk order — callers never chunk. Chunking is all-or-nothing: if any request fails the call throws and returns nothing, so an unsent chunk's ids can never be mistaken for deleted messages. Ids that do not exist, were deleted, or belong to another room are simply absent from a returned result; that absence is the only deletion signal and is never an error. Render an absent id as "original message unavailable" and keep the reference and the jump affordance. An empty list, a blank id or an id over 64 characters throws ArgumentError before any request is issued.

Each ReplyPreview carries id, conversationId, senderId (the same value Message.senderId carries, named for the wire key so the model is identical on every ConvoKit SDK), the first 500 characters of text with textTruncated, createdAt, the parent's current revision and mediaCount. It has value equality, so a cached entry can be compared with a refreshed one. A parent that is already in the loaded window needs no request at all — derive the preview locally.

Jumping to a message that is not in the loaded window needs a window around it. getMessages can only walk older from the newest page; getMessageContext is the random-access companion:

final window = await ConvoKit.getMessageContext(
  conversation.id,
  messageId: parent.id,
  limit: 50,
);
// window.messages is newest-first and centred on parent.id
final older = await ConvoKit.getMessageContext(
  conversation.id,
  olderCursor: window.olderCursor,
);

Exactly one of messageId, olderCursor and newerCursor must be given, and limit must be 1..100 (default 30); anything else throws ArgumentError before any request. With messageId the window is always min(limit, messages in the room) long — never short merely because the target sits near an end — and always contains the target. The cursors are opaque; pass them back unchanged.

newerCursor == null means the window touched the room's newest message at query time. It does not stay true: messages sent during the round trip are not in the window. Return to the live tail with a normal newest-page getMessages load rather than treating a jumped window as live.

Both new routes are conversation-scoped and check active membership before any message id is read, so an outsider, a departed member or a room in another app is a 404 Conversation not found (ConvoKitNotFoundException with a null code) and never leaks which ids exist. A read-only role may read both; they never answer 403. An unknown, deleted or foreign messageId on getMessageContext is a 404 with code MESSAGE_NOT_FOUND.

Version skew: against a 0.8 backend neither route exists, so Express answers the unmatched route with a 404 that carries no code. That is a missing backend, not a missing message. Distinguish the two by the code:

try {
  await ConvoKit.getMessageContext(conversation.id, messageId: parent.id);
} on ConvoKitNotFoundException catch (error) {
  if (error.code == 'MESSAGE_NOT_FOUND') {
    // The parent really is gone: show "original message unavailable".
  } else {
    // No 0.9 backend: hide the jump affordance instead of failing per row.
  }
}

Rebuilding rows: use Message.copyWith(...) rather than a hand-written Message( literal. Every nullable field is sentinel-guarded, so omitting it keeps the current value and passing null clears it:

// Keep hydrated attachments when a bare Realtime row image wins a merge.
final merged = incoming.copyWith(media: known.media);
// Stamp the composer's reply target on an optimistic row.
final pending = row.copyWith(replyToMessageId: replyTarget?.id);

A field-by-field literal silently drops whatever it forgets, which is how a reply loses its reference — and with it the quoted block and the jump affordance — the moment the row is re-wrapped for a pending send, a media hydration or a row image.

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 carries the new
      // revision (event.message.isEdited); keep the higher revision.
      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, event.readAt and event.readPosition (null from a 0.4 backend)
});

Send typing indicators and read receipts with the REST helpers:

await ConvoKit.sendTyping(
  conversationId: conversation.id,
  isTyping: true,
);

await ConvoKit.markConversationRead(
  conversation.id,
  throughMessageId: newestRenderedMessage.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. Presence, onInboxChanged and onInboxActivity share the private app hub the same way. 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.

Read receipts

Acknowledge the newest message the user has actually seen. Order candidates by (createdAt, id), not by list index, and never target a pending (unsent) row:

await ConvoKit.markConversationRead(
  conversation.id,
  throughMessageId: newestRenderedMessage.id,
);

The backend resolves that message's (createdAt, id) position and stores it only when it is newer than the stored one, so an older target after a newer one is a harmless no-op and positions never move backwards for a living membership (leaving or being removed discards it). Calling without throughMessageId keeps the legacy behaviour: the backend targets its newest message at request time, which can race with messages that arrived after the screen rendered. lastReadAt still records the acknowledgement time on every accepted call. Pass the privateStateVersion captured when the room opened so the acknowledgement also clears the caller's own unread marker; see Mark unread.

A targeted call whose message no longer exists, or belongs to another room or app, throws ConvoKitNotFoundException with code == 'MESSAGE_NOT_FOUND'; re-resolve the newest rendered message excluding that id and retry once. A membership failure is also a 404 but carries no code. A blank target throws ConvoKitValidationException before any request is sent.

Compute receipts with the shared rule instead of comparing timestamps:

final participant = conversation.participants
    .firstWhere((p) => p.appUserId == readerId);
final seen = readThrough(
  message,
  readPosition: participant.readPosition,
  lastReadAt: participant.lastReadAt,
);

// Live updates: a `read` event is broadcast only when the position advanced.
ConvoKit.realtime.onReadReceipt(conversation.id).listen((event) {
  final seenNow = readThrough(
    message,
    readPosition: event.readPosition,
    lastReadAt: event.readAt,
  );
});

readThrough prefers the position (ReadPosition.covers(message): a later createdAt, or an equal createdAt and an id that compares greater or equal by code units) and falls back to lastReadAt >= message.createdAt only when no position exists yet. Positions can be null while lastReadAt is set: an acknowledgement in an empty room, a membership recorded before the backend migration, or a 0.4 backend. Never fabricate the local user's own position from the device clock; take it from participant responses and read events.

Mixed fleets: precise receipts need both the sender and the reader on 0.5. Readers on 0.4 keep timestamp semantics and still parse the additive payloads.

Mark unread

A user can mark a room unread for themselves only. The marker lives on the caller's own membership row: other members, participant DTOs, the conversation.updated webhook and read events never carry it.

// Mark: sets the marker and bumps the private state version.
final ConversationPrivateState state =
    await ConvoKit.markConversationUnread(conversation.id);
// state.unreadMarkedAt (non-null), state.privateStateVersion (>= 1)

// Clear without acknowledging a message, only while the version still matches.
final ClearUnreadResult result = await ConvoKit.clearConversationUnread(
  conversation.id,
  ifVersion: state.privateStateVersion,
);
// result.cleared is false when nothing was marked or the version moved on;
// result.unreadMarkedAt and result.privateStateVersion are the current state.

markConversationUnread bumps privateStateVersion on every call, also on a repeat mark, so an acknowledgement that captured the older version can no longer clear the marker: the user's latest intention wins. unreadCount is not inflated; the room's inbox entry becomes isUnread and a zero-count room shows a dot rather than a number. Any living membership may mark, including a read-only role; a membership failure is a 404 without a code.

Capture at open: getConversation(id) returns the caller's own row as conversation.membership (role, lastReadAt, readPosition, unreadMarkedAt, privateStateVersion). Read membership?.privateStateVersion the first time a room screen loads its conversation and send that captured value with every acknowledgement of that open:

final conversation = await ConvoKit.getConversation(id);
final int? captured = conversation.membership?.privateStateVersion;

await ConvoKit.markConversationRead(
  id,
  throughMessageId: newestRenderedMessage.id,
  privateStateVersion: captured, // omitted from the body when null
);

The backend clears the marker only when the sent version equals the current one; a mismatch (a repeat mark, or a mark from another device after the capture) advances the read position as usual and keeps the marker. A call without privateStateVersion (the legacy body) never clears it. Keep the value captured at open across later refreshes of the same screen; do not re-read it from a refreshed conversation, or a mark made while the room was open would be cleared by an acknowledgement that predates it. membership is null against a 0.6 backend: send no version then.

Empty rooms: there is no message to acknowledge, so a room opened with membership.unreadMarkedAt set and nothing to render should call clearConversationUnread(id, ifVersion: captured) once instead. A version-only markConversationRead(id, privateStateVersion: captured) also clears the marker in an empty room; in a non-empty one it still clears the marker but also acknowledges through the newest stored message, like the legacy form.

Lists: apply a mark or clear response to a cached entry only when response.privateStateVersion >= entry.privateStateVersion (replace unreadMarkedAt and privateStateVersion together and recompute isUnread from the stored counts and the new marker); ignore a lower version, so a delayed response cannot resurrect a marker a newer action removed. Other devices learn of marker changes through onInboxActivity and refetch listInbox.

Validation: privateStateVersion and ifVersion must be integers in 0..2147483647; any other value throws ArgumentError before a request is sent. A version the backend rejects surfaces as ConvoKitValidationException with code == 'INVALID_ARGUMENT'. Leaving a room keeps the row and its marker; rejoining clears the marker and bumps the version; removal deletes the row. The /unread routes require the coordinated 0.7 backend release (an older backend answers 404).

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. statusCode carries the HTTP status and code the backend's machine-readable error code when the JSON body includes one (for example MESSAGE_NOT_FOUND, or REVISION_CONFLICT on the 409 a stale editMessage receives); it is null otherwise. There is no dedicated conflict exception type: a 409 is a ConvoKitValidationException, so branch on statusCode and code.

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