Skip to content

Kotlin SDK Upgrade Guide

Start with Migrate to a self-hosted backend for the backend, database, history, and push steps. This guide lists changes from main at cc878025da to self-hosted at 610175f3b3. A call can keep its name and still have different parameters, results, or behavior. The Before examples use the old SDK. The After examples use the new SDK.

Use Android SDK 8.0.0-rc1 with backend 8.0.0-rc1. Add the exact SDK version to your app module:

dependencies {
implementation("org.xmtp:android:8.0.0-rc1")
}

See Android installation requirements.

Use the Android SDK version 8.0.0-rc1. Keep the Gradle artifact org.xmtp:android; replace the old Kotlin imports with uniffi.xmtp_sdk. The public client is SDKClient. Do not use the generated internal Client as the app entry point. The AAR now includes the generated xmtp_sdk bindings and libxmtp_sdk.so.

// Before
import org.xmtp.android.library.Client
import org.xmtp.android.library.ClientOptions
import org.xmtp.android.library.libxmtp.PublicIdentity
import org.xmtp.android.library.libxmtp.IdentityKind
import org.xmtp.android.library.codecs.TextCodec
// After
import uniffi.xmtp_sdk.*

Keep Android core library desugaring enabled. The SDK requires API 23 or later and uses java.time.Instant. Use com.android.tools:desugar_jdk_libs:2.1.5 for desugaring.

Old type or valueNew type or value
ClientSDKClient
org.xmtp.android.library.*, .libxmtp.*, .codecs.*, uniffi.xmtpv3.*uniffi.xmtp_sdk.*; use public SDK types instead of old Ffi* types
PublicIdentity(IdentityKind.ETHEREUM, address)PublicIdentity(identifier = address, kind = PublicIdentityKind.ETHEREUM); the constructor order changes
Date and separate *Ns: Long propertiesTimestamp(ns: Long); use .ns for nanoseconds or .date for Instant
DecodedMessage, DecodedMessageV2Message; queries include reactions and reply metadata in this type
Protobuf Content.ContentTypeId, Content.EncodedContent, ByteStringSDK ContentTypeId, SDK EncodedContent, and ByteArray
XMTPException(message, cause) and FfiExceptionTyped XmtpException variants; retain the ErrorDetails code, category, and retry flag
GroupMembershipStateMembershipState; the allowed/rejected/pending/restored/pending-remove values remain
Conversation.Type, Conversations.ConversationFilterTypeConversationKind; omit the kind to select both groups and DMs

Many old client and conversation properties are now methods. For example, use client.inboxId(), client.installationId(), client.identity(), client.preferences(), group.id(), and group.createdAt(). client.conversations remains a property. Message fields remain properties, such as message.id and message.sentAt.

ClientOptions.Api and XMTPEnvironment are removed. Set the backend URL in BackendOptions. Wrap it in BackendSource.Options. You can also connect once with Backend.connect(options) and pass BackendSource.Connected(backend).

Old optionNew option
api.env, api.gatewayHost, api.isSecurebackend = BackendSource.Options(BackendOptions(url = ...)); the URL selects the deployment and scheme
api.appVersionBackendOptions.appVersion
appContextA separate Context argument to Android SDKClient.create or SDKClient.build
dbDirectorystorage.location = StorageLocation.Directory(directory)
Default Android database directoryStorageLocation.Default with the Android Context overload; it resolves under filesDir/xmtp_db
dbEncryptionKeyStorageOptions.encryptionKey; it is optional and must be 32 bytes when supplied
dbPoolOptions.minPoolSize, .maxPoolSizeStorageOptions.pool = StoragePoolOptions(min = ..., max = ...); keep UInt? values
deviceSyncEnableddeviceSync
forkRecoveryOptionsforkRecovery; enableRecoveryRequests becomes policy, groupsToRequestRecovery becomes groups, and nullable disableRecoveryResponses becomes disableResponses: Boolean = false
preAuthenticateToInboxCallbackhandlers = ClientHandlers(preAuthenticate = ...); implement the suspend PreAuthenticate.run() callback
waitForRegistrationVisible and VisibilityConfirmationOptionsRemoved; no quorum or visibility timeout option
unstableChangeCallbacksClient events with an EventFilter
createInMemory(account, options)SDKClient.create(signer, options.copy(storage = StorageOptions(location = StorageLocation.InMemory)))
build(publicIdentity, options, inboxId) implies offline startup when inboxId is suppliedSDKClient.build(identity, options, inboxId); use ClientOptions.allowOffline = true explicitly when needed

New options include StorageLocation.Explicit(dbPath, attachmentsDir), storage.label, storage.singleConnection, RegistrationOptions(auto, nonce), workers, and attachments. Keep the same encryption key when reopening a stored database. Choose a new storage location for the new deployment; changing the URL is not a database conversion.

The signer contract also changes. SigningKey.publicIdentity, .type, .chainId, and .blockNumber become suspend Signer.identity() and Signer.kind(). sign(message: String): SignedData becomes sign(request: SigningRequest): Signature. Return Signature.Ecdsa(bytes) for an EOA. For an SCW, return SignerKind.Scw(chainId, blockNumber) and Signature.Scw(bytes, address, chainId, blockNumber). A passkey returns SignerKind.Passkey and Signature.Passkey(signature, publicKey, authenticatorData, clientDataJson).

Before, using the app’s wallet signature callback:

import org.xmtp.android.library.*
import org.xmtp.android.library.libxmtp.*
fun oldWalletSigner(
address: String,
signBytes: suspend (String) -> ByteArray,
): SigningKey = object : SigningKey {
override val publicIdentity = PublicIdentity(IdentityKind.ETHEREUM, address)
override val type = SignerType.EOA
override var chainId: Long? = null
override var blockNumber: Long? = null
override suspend fun sign(message: String) = SignedData(signBytes(message))
}

After, using the same app callback:

import uniffi.xmtp_sdk.*
fun walletSigner(
address: String,
signBytes: suspend (String) -> ByteArray,
): Signer = object : Signer {
override suspend fun identity() = PublicIdentity(address, PublicIdentityKind.ETHEREUM)
override suspend fun kind(): SignerKind = SignerKind.Eoa
override suspend fun sign(request: SigningRequest): Signature =
Signature.Ecdsa(signBytes(request.text))
}

Before:

import android.content.Context
import org.xmtp.android.library.*
suspend fun oldOpen(context: Context, wallet: SigningKey, dbKey: ByteArray): Client =
Client.create(
account = wallet,
options = ClientOptions(
api = ClientOptions.Api(env = XMTPEnvironment.DEV),
appContext = context,
dbEncryptionKey = dbKey,
),
)

After:

import android.content.Context
import uniffi.xmtp_sdk.*
suspend fun open(context: Context, wallet: Signer, dbKey: ByteArray): SDKClient =
SDKClient.create(
context = context,
signer = wallet,
options = ClientOptions(
backend = BackendSource.Options(BackendOptions(url = "https://xmtp.example.com")),
storage = StorageOptions(
location = StorageLocation.Default,
encryptionKey = dbKey,
),
),
)

To reopen without signing, use SDKClient.build(context, identity, options, inboxId). Without a Context, choose Directory, Explicit, or InMemory, or pass defaultDirectory. Default without a directory fails with StorageLocationRequired. For tests, SDKClient.generateKeys() and LocalSigningKey.fromBytes(key) replace app use of the old messages.PrivateKey helper.

The client now has an explicit lifetime. End it in withContext(NonCancellable) { client.end() }. Keep a strong reference to the client while using its messages. Message action helpers use that client reference.

Old callNew call and return shape
client.publicIdentity, .libXMTPVersion, .isInMemory, .dbPathidentity(): PublicIdentity, libxmtpVersion(): String, isInMemory(): Boolean, storagePath(): String?; in-memory storage has no path
client.environmentRemoved; read deployment configuration with serverConfiguration() or the saved backend options
Client.connectToApiBackend(api)Backend.connect(options): Backend; no old API client cache interface
Client.getOrCreateInboxId(api, identity)SDKClient.inboxIdFor(identity, backend): InboxId; instance inboxIdFor(identity): InboxId? only looks up an existing association
client.inboxIdFromIdentity(identity)client.inboxIdFor(identity): InboxId?
Instance inboxStatesForInboxIds(refreshFromNetwork, inboxIds)inboxStates(ids, refreshFromNetwork): List<InboxState>; parameter order changes
Static Client.inboxStatesForInboxIds(inboxIds, api)SDKClient.inboxStates(ids, backend): List<InboxState>
client.inboxState(refreshFromNetwork)Same method name and boolean; InboxState.recoveryPublicIdentity becomes recoveryIdentity; installation .installationId becomes .id; installation .createdAt becomes Timestamp?
Instance or static canMessage(identities, api)Instance canMessage(identities) or static SDKClient.canMessage(identities, backend); both return Map<String, Boolean> keyed by ethereum:<normalized identifier>, passkey:<lowercase hex>, or handle:<lowercase handle>
Client.getNewestMessageMetadata(groupIds, api)SDKClient.newestMessageMetadata(ids, backend): Map<String, MessageMetadataEntry>; records have sequenceId: ULong and createdAt: Timestamp
Client.keyPackageStatusesForInstallationIds(installationIds, api)SDKClient.keyPackageStatuses(ids, backend): Map<String, KeyPackageStatus>; the instance also has keyPackageStatuses(ids)
client.addAccount(newAccount, allowReassignInboxId = false)unsafeAddAccount(signer, allowInboxReassign); supply the boolean explicitly
client.removeAccount(recoverAccount, publicIdentityToRemove)removeAccount(recoverySigner, identity): Unit
Instance revokeInstallations(signingKey, installationIds)revokeInstallations(signer, ids: List<InstallationId>): Unit
Static revokeInstallations(api, signingKey, inboxId, installationIds)SDKClient.revokeInstallations(signer, inboxId, ids, backend): Unit; parameter order changes
revokeAllOtherInstallations(signingKey)revokeAllOtherInstallations(signer): Unit
Synchronous signWithInstallationKey(message)Suspend signWithInstallationKey(text): ByteArray
Synchronous verifySignature(message, signature)Suspend verifySignedWithInstallationKey(text, signature): Boolean; malformed input can throw
Instance verifySignatureWithInstallationId(message, signature, installationId)Static SDKClient.verifySignedWithPublicKey(text, signature, publicKey: ByteArray): Boolean; convert the hex installation ID to bytes
catchUpToLive(timeoutMs: Long?)catchUpToLive(timeoutMs: ULong?): CatchUpSummary; counters become ULong, the result adds failed: ULong, and completed remains a boolean; do not pass a negative timeout
syncAllDeviceSyncGroups()Retained with GroupSyncSummary; numEligible and numSynced become eligible and synced

New identity methods include register(), isRegistered(), installationIdBytes(), options(), appVersion(), changeRecoveryIdentifier(signer, identity), latestInboxUpdatesCount(ids, refreshFromNetwork), ownInboxUpdatesCount(refreshFromNetwork), serverConfiguration(), refreshServerConfiguration(), and setCredential(credential). Static helpers also include isAddressAuthorized(inboxId, address, backend) and isInstallationAuthorized(inboxId, installationId, backend), both returning Boolean. A configuration refresh returns the fetched value; it does not replace the running client’s held configuration. fetchServerConfiguration(backend) can discover configuration before client creation. Supply a fixed Credential or a suspend CredentialSource.credential() in BackendOptions when the deployment requires authentication.

Handles are new. An inbox can claim a public name such as nick#2341, and a PublicIdentity of kind PublicIdentityKind.HANDLE finds that inbox. SDKClient.inboxIdFor(identity, backend) never derives an inbox ID from a handle: it throws XmtpException.IdentityNotFound for a handle that no inbox claimed. InboxState and Member also have handle and otherHandles. See Handles.

Before:

import org.xmtp.android.library.*
import org.xmtp.android.library.libxmtp.*
suspend fun oldIdentity(client: Client, wallet: SigningKey, address: String): Boolean {
val identity = PublicIdentity(IdentityKind.ETHEREUM, address)
client.addAccount(wallet, allowReassignInboxId = false)
val states = client.inboxStatesForInboxIds(false, listOf(client.inboxId))
check(states.isNotEmpty())
return client.canMessage(listOf(identity))[identity.identifier] == true
}

After:

import uniffi.xmtp_sdk.*
suspend fun identity(client: SDKClient, wallet: Signer, address: String): Boolean {
val identity = PublicIdentity(address, PublicIdentityKind.ETHEREUM)
client.unsafeAddAccount(wallet, allowInboxReassign = false)
val states = client.inboxStates(listOf(client.inboxId()), false)
check(states.isNotEmpty())
return client.canMessage(listOf(identity))["ethereum:${address.lowercase()}"] == true
}

For independent signature flows, replace the old ffi* methods as follows. Use RegistrationOptions(auto = false) when client creation must not register automatically.

Old methodNew method
Client.ffiCreateClient(identity, clientOptions)Create with a signer and registration.auto = false, or use SDKClient.build for an existing identity
ffiSignatureRequest(): SignatureRequest?Suspend unsafeCreateInboxSignatureRequest(): SignatureRequest?
ffiAddIdentity(identity, allowReassignInboxId)unsafeAddAccountSignatureRequest(identity, allowInboxReassign)
ffiRevokeIdentity(identity)unsafeRemoveAccountSignatureRequest(identity)
ffiRevokeInstallations(ids: List<ByteArray>)unsafeRevokeInstallationsSignatureRequest(ids: List<InstallationId>)
ffiRevokeAllOtherInstallations()unsafeRevokeAllOtherInstallationsSignatureRequest(): SignatureRequest?
Instance ffiApplySignatureRequest(request) and ffiRegisterIdentity(request, visibilityOptions)unsafeApplySignatureRequest(request); registration visibility options are removed
Static ffiRevokeInstallations and ffiApplySignatureRequestRemoved; use the static signed revocation helper or the client signature request methods
request.addEcdsaSignature(bytes), .addScwSignature(bytes, address, chainId, blockNumber)request.addSignature(Signature.Ecdsa(bytes)) or request.addSignature(Signature.Scw(...))

Create, find, and list Kotlin conversations

Section titled “Create, find, and list Kotlin conversations”

Group creation uses an options record. DM creation returns Dm directly. Both inbox ID and account identity forms are supported. For an empty member list, write emptyList<InboxId>() or emptyList<PublicIdentity>() so Kotlin can select the overload.

Old callNew call and return shape
newGroup(inboxIds, permissions, groupName, groupImageUrlSquare, groupDescription, disappearingMessageSettings, appData)createGroup(members: List<InboxId>, options: CreateGroupOptions?): Group
newGroupWithIdentities(identities, ...)createGroup(members: List<PublicIdentity>, options): Group
newGroupCustomPermissions(inboxIds, permissionPolicySet, ...), newGroupCustomPermissionsWithIdentities(identities, ...)The same createGroup overloads with CreateGroupOptions(permissions = GroupPermissionMode.Custom(policySet))
newGroupOptimistic(...)createGroupOptimistic(options): Group
newConversation(peerInboxId, disappearingMessageSettings): Conversation and findOrCreateDm(peerInboxId, ...)createDm(peer: InboxId, options: CreateDmOptions?): Dm
newConversationWithIdentity(peerPublicIdentity, ...) and findOrCreateDmWithIdentity(...)createDm(peer: PublicIdentity, options): Dm
findConversation(id)getById(id): Conversation?
findGroup(id)getById(id) then match Conversation.Group; do not assume every ID is a group
findDmByInboxId(id), findDmByIdentity(identity)getDmByInboxId(peer): Dm?, getDmByIdentity(identity): Dm?
findMessage(id), findEnrichedMessage(id)getMessageById(id): Message?
findConversationByTopic(topic)Removed; store the conversation ID and call getById
fromWelcome(envelopeBytes)Removed; use SDK synchronization or a conversation reader
syncAllConversations(consentStates)syncAll(consentStates): GroupSyncSummary
list(...), listGroups(...), listDms(...)Same names, with one ListConversationsOptions? argument; return List<Conversation>, List<Group>, and List<Dm>
deleteMessageLocally(messageId)Retained as deleteMessageLocally(id)
getHmacKeys(): Keystore.GetConversationHmacKeysResponsehmacKeys(): Map<String, List<HmacKey>>
allPushTopics()Removed; enable client notifications

CreateGroupOptions fields are permissions, name, imageUrl, description, disappearing, and appData. The old group permission constants become GroupPermissionMode.AllMembers and .AdminOnly. CreateDmOptions has disappearing. Replace DisappearingMessageSettings(disappearStartingAtNs, retentionDurationInNs) with DisappearingSettings(from = Timestamp(...), retentionNs = ...).

For ListConversationsOptions, change createdAfterNs / createdBeforeNs to createdAfter / createdBefore with Timestamp values. Change the last activity bounds in the same way. limit: Int? becomes UInt?. Use orderBy = ConversationOrder.CREATED_AT or .LAST_ACTIVITY, kind, consentStates, and includeDuplicateDms. Omit kind for the old ALL behavior.

Before:

import org.xmtp.android.library.*
suspend fun oldConversations(client: Client, peer: InboxId): Group {
val group = client.conversations.newGroup(listOf(peer), groupName = "Support")
val dm: Conversation = client.conversations.newConversation(peer)
check(dm.id.isNotEmpty())
val groups = client.conversations.listGroups(limit = 20)
check(groups.any { it.id == group.id })
return group
}

After:

import uniffi.xmtp_sdk.*
suspend fun conversations(client: SDKClient, peer: InboxId): Group {
val group = client.conversations.createGroup(
listOf(peer), CreateGroupOptions(name = "Support"),
)
val dm: Dm = client.conversations.createDm(peer)
check(dm.id().isNotEmpty())
val groups = client.conversations.listGroups(
ListConversationsOptions(limit = 20u, orderBy = ConversationOrder.LAST_ACTIVITY)
)
check(groups.any { it.id() == group.id() })
return group
}

Conversation is still a tagged group or DM value. Match Conversation.Group and .Dm to use a group-only or DM-only operation. Shared generated forwarding methods support common conversation operations.

Conversation lists now default to creation time instead of last activity. Set orderBy = ConversationOrder.LAST_ACTIVITY to retain the old sort key. Message lists now default to ascending order instead of descending order. Set direction = MessageOrder.DESCENDING to retain newest-first message order.

Nullable does not mean that an argument can be omitted. Pass a value or null for catchUpToLive(timeoutMs), syncAll(consentStates), countMessages(options), lastActivityAt(contentTypes), updateAppData(value, expected), updatePermission(kind, policy, metadataField), and the options argument of archives().exportToFile and exportToBytes. A group or DM uses messageHistorySnapshot(limit: UInt). The collection uses messageHistorySnapshot(limit: UInt, options: MessageReaderOptions? = null).

Conversation state, members, and permissions

Section titled “Conversation state, members, and permissions”

Read one state record instead of separate getters. A Group.state() returns GroupState. Its .common field holds shared state. A Dm.state() returns ConversationState. Match a Conversation as group or DM before reading its state; the two state return types differ.

Old group or DM accessNew access
.id, .topic, .createdAt, .createdAtNsid(), topic(), createdAt(): Timestamp; use .ns or .date
.lastActivityNsSuspend lastActivityAt(contentTypes: List<ContentTypeId>?): Timestamp
Group .name, .name(), .imageUrl, .imageUrl(), .description, .description(), .appData, .appData()group.state().name, .imageUrl, .description, .appData
isActive(), consentState(), pausedForVersion(), commitLogForkStatus()Shared state .isActive, .consentState, .pausedForVersion, .commitLogForkStatus
.disappearingMessageSettings, disappearingMessageSettings(), .isDisappearingMessagesEnabled, isDisappearingMessagesEnabled()Shared state .disappearingSettings, .isDisappearingEnabled
Group membershipState(), permissions(), permissionPolicySet()Group state .membershipState, .permissions, .permissions.policySet
DM .peerInboxIdSuspend peerInboxId(): InboxId?
addedByInboxId(): InboxId, creatorInboxId(): InboxIdaddedByInboxId(): InboxId?, creatorInboxId(): InboxId?; both are now synchronous and nullable
Suspend isCreator()Synchronous isCreator(): Boolean
clearDisappearingMessageSettings()updateDisappearingSettings(null)
updateDisappearingMessageSettings(settings)updateDisappearingSettings(settings: DisappearingSettings?)
addMembers(inboxIds), addMembersByIdentity(identities)addMembers(members) overloads; GroupMembershipResult.addedMembers becomes MembershipResult.added, .removedMembers becomes .removed, and .failedInstallationIds remains a list of hex IDs
removeMembers(inboxIds), removeMembersByIdentity(identities)removeMembers(members) overloads; return Unit
members()Retained; use new Member / PermissionLevel / ConsentState types
updateName(name), updateImageUrl(imageUrl), updateDescription(description)Retained names; parameter name is value
updateAppData(appData, expectedAppData)updateAppData(value, expected); pass the required nullable expected argument (null for no comparison)
updateAddMemberPermission, updateRemoveMemberPermission, updateAddAdminPermission, updateRemoveAdminPermissionupdatePermission(kind, policy, metadataField); pass null when no metadata field applies with the corresponding PermissionUpdateKind
updateNamePermission, updateDescriptionPermission, updateImageUrlPermissionupdatePermission(PermissionUpdateKind.UPDATE_METADATA, policy, MetadataFieldKind.NAME / DESCRIPTION / IMAGE_URL)
leaveGroup()requestRemoval(): Unit
membershipCapabilities()Retained with the new SDK capability records
proposalsEnabled(), unstable.enableProposals(force, minVersion)Removed; no proposal migration switch
getDebugInformation()debugInfo(): ConversationDebugInfo; epoch is ULong, cursor is List<ULong>, and .isCommitLogForked: Boolean? replaces the debug record’s .commitLogForkStatus
getLastReadTimes(): Map<InboxId, Long>lastReadTimes(): Map<String, Timestamp>
Per-conversation getHmacKeys()hmacKeys(): List<HmacKey>; each item has key: ByteArray and epoch: Long
getPushTopics()Removed; use client notification registration and per-conversation setNotifications(value: NotificationOverride)

Before:

import org.xmtp.android.library.Group
import org.xmtp.android.library.InboxId
import org.xmtp.android.library.libxmtp.PermissionOption
suspend fun oldGroupState(group: Group, peer: InboxId): String {
val added = group.addMembers(listOf(peer))
check(added.addedMembers.contains(peer))
group.updateNamePermission(PermissionOption.Admin)
return group.name()
}

After:

import uniffi.xmtp_sdk.*
suspend fun groupState(group: Group, peer: InboxId): String {
val added = group.addMembers(listOf(peer))
check(added.added.contains(peer))
group.updatePermission(
PermissionUpdateKind.UPDATE_METADATA,
PermissionPolicy.ADMIN,
MetadataFieldKind.NAME,
)
return group.state().name
}

In PermissionPolicySet, rename addMemberPolicy, removeMemberPolicy, addAdminPolicy, and removeAdminPolicy to addMember, removeMember, addAdmin, and removeAdmin. Rename updateGroupNamePolicy, updateGroupDescriptionPolicy, updateGroupImagePolicy, updateMessageDisappearingPolicy, and updateAppDataPolicy to updateName, updateDescription, updateImage, updateDisappearing, and updateAppData. Use PermissionPolicy in place of PermissionOption.

New metadata methods are metadataFields(): List<MetadataFieldDescriptor>, metadataField(name: String): MetadataFieldDescriptor?, metadataValue(field: MetadataFieldRef): MetadataValue?, metadataValues(fields: List<MetadataFieldRef>): List<MetadataFieldValue>, mapValue(field, key: FieldKey): FieldValue?, userData(fields, inboxIds): Map<InboxId, List<UserFieldValue>>, updateUserData(values: List<UserFieldUpdate>): Unit, and updateMetadataField(field, operation: ComponentMutation): Unit. The two userData selectors are nullable lists. Use the published component definitions and typed metadata records. These methods do not replace appData with an untyped map. Dm.duplicateDms() exposes the stitched duplicate conversations.

Both Group and Dm provide the methods below. A send returns a hex MessageId string. Use sendText for text. Use send(encoded, options) for a complete envelope or send(codec, value, options) for a typed custom value. Standard content does not need registration.

Old callNew call and return shape
send(text: String)sendText(text, options: SendOptions? = null): MessageId
send(content, SendOptions(contentType = ...))send(codec: ContentCodec<T>, value: T, options): MessageId or a standard send method
send(encodedContent, MessageVisibilityOptions)send(encoded: EncodedContent, options: SendOptions?): MessageId
prepareMessage(content, options, noSend)prepareMessage(codec, value, options); for text use prepareMessage(encodeText(text), options)
prepareMessage(encodedContent, MessageVisibilityOptions, noSend)prepareMessage(encoded, options): MessageId; this sets optimistic sending
deleteMessage(messageId)Retained as deleteMessage(id): MessageId
lastMessage()lastMessage(): Message?
messages(...), messagesWithReactions(...), enrichedMessages(...)messages(options: ListMessagesOptions?): List<Message>; one enriched result type
countMessages(...)countMessages(options: ListMessagesOptions?): ULong; the old result was Long
processMessage(messageBytes)Removed; SDK readers and synchronization process received messages
encodeContent(content, options), encode(codec, content)Removed; use a codec’s encode(value) or an encode* helper

SendOptions has shouldPush: Boolean?, optimistic: Boolean, idempotencyKey: String?, and compression: Compression?. MessageVisibilityOptions is removed. The old contentType is selected by the codec or encoder. The old ephemeral field and prepareMessage.noSend argument are removed. There is no separate noSend option in the new API. prepareMessage queues optimistic publication; background processing can publish it without an explicit publish call. Keep private drafts outside the SDK until the app is ready to send. Use Compression.DEFLATE or .GZIP instead of EncodedContentCompression.

The standard methods are sendMarkdown, sendReadReceipt, sendReaction(reference, referenceInboxId, reaction, options), sendReply(reference, referenceInboxId, content, options), sendAttachment, sendRemoteAttachment, sendMultiRemoteAttachment, sendTransactionReference, sendWalletSendCalls, sendActions, and sendIntent.

For queries, use this option mapping:

Old query parameterListMessagesOptions field
limit: Int?limit: UInt?
beforeNs, afterNssentBefore, sentAfter, both Timestamp?
insertedBeforeNs, insertedAfterNsinsertedBefore, insertedAfter, both Timestamp?
direction = SortDirection.ASCENDING / DESCENDINGdirection = MessageOrder.ASCENDING / DESCENDING
sortBy = SortBy.SENT_TIME / INSERTED_TIMEsortBy = MessageSortBy.SENT_AT / INSERTED_AT
MessageDeliveryStatus.ALLOmit deliveryStatus; there is no ALL enum value
Other MessageDeliveryStatus valuesDeliveryStatus.PUBLISHED / UNPUBLISHED / FAILED
excludeContentTypes: List<FfiContentType>?excludeContentTypes: List<ContentTypeId>?; use actual content type IDs
No old equivalentkind and contentTypes select a message kind or include content type IDs

Before:

import org.xmtp.android.library.Group
import org.xmtp.android.library.libxmtp.DecodedMessage
suspend fun oldMessages(group: Group): List<String> {
group.send("Hello")
val pending = group.prepareMessage("Draft")
group.publishMessage(pending)
return group.enrichedMessages(limit = 20).map { it.content<String>() ?: it.fallbackText.orEmpty() }
}

After:

import uniffi.xmtp_sdk.*
suspend fun messages(group: Group): List<String> {
group.sendText("Hello")
val pending = group.prepareMessage(encodeText("Draft"), null)
group.publishMessage(pending)
return group.messages(
ListMessagesOptions(limit = 20u, direction = MessageOrder.DESCENDING)
).map { message ->
val standard = (message.content as? SDKMessageContent.Standard)?.value
(standard as? MessageContent.Text)?.v1 ?: message.fallback.orEmpty()
}
}

Message.content<T>() and .body are removed. Match message.content as SDKMessageContent.Standard, .Custom, or .Unknown. For standard content, match its .value as MessageContent.Text, .Reaction, .Reply, .Attachment, and the other variants. A standard text value is .v1. A custom value is .value: Any?; check .error. Unknown content includes the original bytes and ErrorDetails. A decode failure is retained in the result; it is not removed from the message list.

Old message field or methodNew field or method
.encodedContent.encoded: EncodedContent?; .rawBytes holds the original received serialization
.sentAt: Date, .sentAtNs.sentAt: Timestamp
.insertedAtNs, .expiresAtNs, .expiresAt: Date?.insertedAt: Timestamp, .expiresAt: Timestamp?
.fallback: String.fallback: String?
.kind: FfiConversationMessageKind.kind: MessageKind
.deliveryStatus: MessageDeliveryStatus.deliveryStatus: DeliveryStatus
Reaction child messages / V2 reaction wrappers.reactions: List<ReactionMessage>; the nested .reaction has content, action, and schema
No old equivalent.replyCount: ULong, .inReplyTo: ReplyParent?
V2 .hasReactions, .reactionCount, .fallbackText, .contentTypeIdUse .reactions.isNotEmpty(), .reactions.size, .fallback, and nullable .contentType
No old equivalent.deliveryCursor: String?, .contentType: ContentTypeId?, .replyContent, .inReplyToContent

New message helpers are refresh(): Message?, delete(): MessageId, deleteLocally(): Unit, react(reaction, options): MessageId, reply(text, options): MessageId, reply(encoded, options): MessageId, reply(codec, value, options): MessageId, parent(): Message?, conversation(): Conversation?, and client(): SDKClient.

Before, a reaction required a registered codec and a reference inside the value:

import org.xmtp.android.library.*
import org.xmtp.android.library.codecs.*
suspend fun oldReaction(group: Group, messageId: String) {
Client.register(ReactionV2Codec())
group.send(
Reaction(messageId, ReactionAction.Added, "👍", ReactionSchema.Unicode),
SendOptions(contentType = ContentTypeReactionV2),
)
}

After, the reference is a separate send argument. The message helper can supply it:

import uniffi.xmtp_sdk.*
suspend fun reaction(message: Message) {
message.react(Reaction("👍", ReactionAction.ADDED, ReactionSchema.UNICODE))
message.reply("Thanks")
}

The native action and intent records also change. Intent.metadata becomes metadataJson. Action.expiresAtNs and Actions.expiresAtNs become expiresAt with a nullable Timestamp. TransactionReference no longer supplies default values for namespace and metadata; pass both arguments explicitly, using null in Kotlin or nil in Swift when absent. RemoteAttachment also requires explicit nullable contentLength and filename arguments; their old constructor defaults are gone.

Message query content filters accept only supported catalogue kinds. Matching uses authority, type name, and major version; minor version is ignored. A custom content ID fails with InvalidArgument.

Client.register and the public CodecRegistry are removed. The SDK handles the standard catalogue. Pass custom codecs to each SDKClient.create(..., codecs = listOf(codec)) or .build(...). The registration key uses authority, type ID, and major version. Minor version does not select a separate codec.

ContentCodec<T> now requires T : Any. Rename .contentType to immutable .type. Keep encode, decode, fallback, and shouldPush; fallback and shouldPush now have defaults. Return an SDK EncodedContent(type, parameters, fallback, content: ByteArray) instead of a protobuf builder. A typed send checks the codec value type. A failed encode, fallback, or push callback fails with CodecEncodeFailed before publish.

The standard wrappers include TextCodec, MarkdownCodec, AttachmentCodec, RemoteAttachmentCodec, MultiRemoteAttachmentCodec, ReactionV2Codec, ReadReceiptCodec, ReplyCodec, TransactionReferenceCodec, WalletSendCallsCodec, ActionsCodec, IntentCodec, GroupUpdatedCodec, DeleteMessageCodec, and LeaveRequestCodec. They are optional tools for encode/decode work; they do not need registration. The old JSON ReactionCodec is removed. Use ReactionV2Codec with ReactionV2Content(reference, referenceInboxId, reaction) or sendReaction with the new reaction payload.

Old content record or helperNew shape
ContentTypeIdBuilder, ContentType* protobuf constants, .id and .description convenience keysConstruct ContentTypeId(authorityId, typeId, versionMajor: UInt, versionMinor: UInt) or use standardContentType(StandardContentKind...) / a codec’s .type
Protobuf envelope .parametersMap, .content: ByteString, .toByteArray(), .parseFrom(bytes)SDK .parameters, .content: ByteArray, encodeEncodedContent(encoded), decodeEncodedContent(bytes)
EncodedContent.decoded<T>()Use a codec’s decode(encoded) for a known type; use client.decodeContent(encoded) for SDK content
.compress(...), .decompressContent()Supply SendOptions.compression; envelope decoding applies the shared decompression limits
Attachment(filename: String, mimeType, data: ByteString)Attachment(filename: String?, mimeType, content: ByteArray)
ReadReceipt singletonUse Unit with ReadReceiptCodec, or call sendReadReceipt()
Reaction(reference, action, content, schema, referenceInboxId)Reaction(content, action, schema); reference fields move to sendReaction / ReactionV2Content
ReactionAction / ReactionSchema sealed singleton objectsUppercase SDK enum values, including UNKNOWN
Generic Reply(reference, content, contentType)ReplyContent(reference, referenceInboxId, content: EncodedContent) for ReplyCodec; send or message reply helpers accept the nested envelope
RemoteAttachment(url: URL, ..., secret/salt/nonce: ByteString, contentLength: Int?, filename: String?)URL String, key bytes ByteArray, contentLength: UInt?, filename: String?
MultiRemoteAttachment.remoteAttachments: List<RemoteAttachmentInfo>.attachments: List<RemoteAttachment>; no separate RemoteAttachmentInfo type
Protobuf GroupUpdated and nested inbox wrappersSDK GroupUpdated with string inbox ID lists and MetadataFieldChange(fieldName, oldValue, newValue) records
LeaveRequest.create(authenticatedNote)Construct LeaveRequest(authenticatedNote); note bytes remain nullable
DeleteMessageRequest(messageId) codec payloadDeleteMessageContent(messageId); DeletedMessage and DeletedBy move to the new package
TransactionReference.MetadataTop-level TransactionMetadata; the transaction record fields remain namespace, network ID, reference, and optional metadata
WalletSendCalls = FfiWalletSendCallsPublic WalletSendCalls, WalletCall, and WalletCallMetadata records; fields remain version, chain ID, from, calls, and optional capabilities map
EncryptedEncodedContent(contentDigest, secret, salt, nonce, payload, contentLength)EncryptedEncodedContent(ciphertext: ByteArray, keys: EncryptionKeys); keys contain secret, salt, nonce, digest, and length
RemoteAttachment.encodeEncrypted, .encodeEncryptedBytes, .decryptEncoded, .from, and MultiRemoteAttachmentCodec.encryptBytesForLocalAttachment, .buildRemoteAttachmentInfo, .buildEncryptAttachmentResult, .decryptAttachmentencryptBytes, decryptBytes, encryptEncodedContent, decryptEncodedContent, encryptFile, and decryptFile; use SDK records
RemoteAttachment.load<T>() and its fetcher property, Fetcher, HTTPFetcherRemoved; use client.attachments().download(remote) for SDK-managed download and cache
Crypto, KeyUtil, messages.PrivateKeyRemoved; use SDK encryption helpers and a wallet signer or local signer helpers

Before, a custom codec used the global registry and a protobuf envelope:

import com.google.protobuf.ByteString
import org.xmtp.android.library.*
import org.xmtp.android.library.codecs.*
class OldNoteCodec : ContentCodec<String> {
override val contentType = ContentTypeIdBuilder.builderFromAuthorityId("example.com", "note", 1, 0)
override fun encode(content: String): EncodedContent = EncodedContent.newBuilder()
.setType(contentType).setContent(ByteString.copyFromUtf8(content)).build()
override fun decode(content: EncodedContent): String = content.content.toStringUtf8()
override fun fallback(content: String): String = content
override fun shouldPush(content: String): Boolean = true
}
suspend fun oldCustom(group: Group) {
val codec = OldNoteCodec()
Client.register(codec)
group.send("A note", SendOptions(contentType = codec.contentType))
}

After, pass the codec to the client factory for receive decoding. Pass it to the send method for typed encoding:

import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.withContext
import uniffi.xmtp_sdk.*
class NoteCodec : ContentCodec<String> {
override val type = ContentTypeId("example.com", "note", 1u, 0u)
override fun encode(value: String) = EncodedContent(type = type, content = value.encodeToByteArray())
override fun decode(encoded: EncodedContent): String = encoded.content.decodeToString()
override fun fallback(value: String): String = value
}
suspend fun custom(signer: Signer, options: ClientOptions, peer: InboxId) {
val codec = NoteCodec()
val client = SDKClient.create(signer, options, codecs = listOf(codec))
try {
client.conversations.createDm(peer).send(codec, "A note")
} finally {
withContext(NonCancellable) { client.end() }
}
}

Before, an inline attachment used protobuf bytes and explicit codec registration:

import com.google.protobuf.ByteString
import org.xmtp.android.library.*
import org.xmtp.android.library.codecs.*
suspend fun oldAttachment(group: Group, bytes: ByteArray) {
Client.register(AttachmentCodec())
group.send(
Attachment("note.txt", "text/plain", ByteString.copyFrom(bytes)),
SendOptions(contentType = ContentTypeAttachment),
)
}

After:

import uniffi.xmtp_sdk.*
suspend fun attachment(group: Group, bytes: ByteArray) {
group.sendAttachment(Attachment("note.txt", "text/plain", bytes))
}

For remote attachments, the SDK now offers a complete upload and local cache flow. attachments().offered() reports whether the deployment has an attachment service. create(source: AttachmentSource) returns PendingAttachment; upload() uploads it; remoteAttachment() supplies the send value. download(remote) returns DownloadedAttachment. Other methods are pending(remote), listPending(), localPath(remote), listLocal(), and deleteLocal(remote). A failed transfer is not retried automatically; call the operation again. Use AttachmentOptions for download limits, pending age, and private network access.

Old streamNew stream
group.streamMessages(onClose) / dm.streamMessages(onClose) / conversation.streamMessages(onClose)conversation.streamMessages(ConversationMessageStreamOptions(from, onClose, onConnectionStateChange))
conversations.streamAllMessages(type, consentStates, onClose)client.conversations.streamAllMessages(MessageStreamOptions(conversationKind, consentStates, from, onClose, onConnectionStateChange))
conversations.stream(type, onClose)client.conversations.stream(ConversationStreamOptions(conversationKind, consentStates, onClose, onConnectionStateChange))
conversations.streamMessageDeletions(onClose)Client events selected with EventKind.MESSAGE_DELETED; the event has IDs, not a decoded message
preferences.streamConsent(onClose)Client events selected with EventKind.CONSENT_CHANGED; use the new consent event payload
preferences.streamPreferenceUpdates(onClose)Client events selected with EventKind.HMAC_KEYS_UPDATED
Client.manageStreamLifecycleAndroidStreamLifecycle.enabled; set it before the first Android client is created

The message flows return Flow<Message>. Conversation flows return Flow<Conversation>. The close callback receives SDKStreamCloseReason.Closed or .Failed(error). The connection callback receives the previous nullable ConnectionState and the current state.

A default message reader owns persisted delivery progress. Only one can be active in a client database, including across separate group and DM scopes. A second reader fails with ConsumerOwned. With direct sequential collect, the next read acknowledges the previous message after the collector returns. A buffer can move that boundary before downstream work finishes. Do not use buffered collection as a durable acknowledgement for each consumer.

Use MessageStreamOptions(from = cursor) or ConversationMessageStreamOptions(from = cursor) for independent replay and live reading. The cursor is exclusive. These readers do not advance default progress. messageHistorySnapshot(limit: UInt, options) returns messages and a cursor from one history boundary. beginningDeliveryCursor() returns a replay boundary before stored delivery rows. Lower-level messageReader(...) / conversationReader(...) expose next(), end(), and connection state methods.

Before:

import kotlinx.coroutines.flow.collect
import org.xmtp.android.library.Group
suspend fun oldStream(group: Group) {
group.streamMessages(onClose = { println("Stream closed") }).collect { message ->
println(message.body)
}
}

After:

import kotlinx.coroutines.flow.collect
import uniffi.xmtp_sdk.*
suspend fun stream(client: SDKClient, group: Group) {
group.streamMessages(ConversationMessageStreamOptions(onClose = { reason -> println(reason) })).collect { message ->
val text = ((message.content as? SDKMessageContent.Standard)?.value as? MessageContent.Text)?.v1
println(text ?: message.fallback.orEmpty())
}
}

Replace app-specific unstable change callbacks with client.events(filter): Flow<ClientEvent> or client.startListener(filter, onEvent): ListenerId. Call client.stopListener(id) to stop a listener. Events are live notifications. They are not message delivery checkpoints. EventFilter.groupIds uses List<ByteArray>?; convert stored hex IDs when selecting groups. Use referencesOwnMessages and contentTypes only for the event kinds that support those filters.

Old consent method or recordNew method or record
client.preferencesclient.preferences(): Preferences
sync(), syncConsent()sync(): Unit
setConsentState(entries)setConsentStates(records: List<ConsentRecord>): Unit
ConsentRecord(value, entryType, consentType) and .conversationId(...) / .inboxId(...) factoriesConsentRecord(entity = ConsentEntity.Conversation(id) / ConsentEntity.Inbox(id), state)
conversationState(groupId), inboxIdState(inboxId)consentState(entity): ConsentState
group.updateConsentState(state), dm.updateConsentState(state)Uses the new ConsentState type.

Before:

import org.xmtp.android.library.*
suspend fun oldConsent(client: Client, group: Group) {
client.preferences.setConsentState(
listOf(ConsentRecord.conversationId(group.id, ConsentState.ALLOWED)),
)
check(client.preferences.conversationState(group.id) == ConsentState.ALLOWED)
}

After:

import uniffi.xmtp_sdk.*
suspend fun consent(client: SDKClient, group: Group) {
val entity = ConsentEntity.Conversation(group.id())
client.preferences().setConsentStates(listOf(ConsentRecord(entity, ConsentState.ALLOWED)))
check(client.preferences().consentState(entity) == ConsentState.ALLOWED)
}

XMTPPush, PushPreferences, NotificationsGrpc, and push Service protobuf types are removed. There is no replacement for XMTPPush.subscribe, subscribeWithMetadata, or unsubscribe, or for the old push UUID. Register a delivery channel on the SDK client. The SDK uses its installation and manages the subscriptions.

Before:

import android.content.Context
import org.xmtp.android.library.Client
import org.xmtp.android.library.push.XMTPPush
suspend fun oldPush(context: Context, client: Client, token: String, pushServer: String) {
val push = XMTPPush(context, pushServer)
push.register(token)
push.subscribe(client.conversations.allPushTopics())
}

After:

import uniffi.xmtp_sdk.*
suspend fun push(client: SDKClient, group: Group, token: String) {
client.enableNotifications(NotificationConfig(channel = NotificationChannel.Fcm(token)))
group.setNotifications(NotificationOverride.ENABLED)
println(client.notificationState())
}

enableNotifications(config) is a suspend method that returns NotificationState. disableNotifications() is a suspend method that returns Unit. notificationState() returns NotificationState.Disabled, .Enabled, or .Failed(error). NotificationConfig also accepts consent states and flags for welcomes, sync groups, and commits. The channels are Fcm(token), Apns(token), and Http(url, signingKey: ByteArray). Use group.setNotifications(NotificationOverride.DISABLED) or the DM form to mute one conversation. Use NotificationOverride.DEFAULT to restore its default policy; use client.disableNotifications() to disable the client registration.

Archives, storage, diagnostics, and removed helpers

Section titled “Archives, storage, diagnostics, and removed helpers”
Old methodNew method and return shape
client.createArchive(path, encryptionKey, opts)client.archives().exportToFile(path, keyBytes, options): ArchiveMetadata; export now returns metadata
client.importArchive(path, encryptionKey)client.archives().importFromFile(path, keyBytes): Unit
client.archiveMetadata(path, encryptionKey)client.archives().metadataFromFile(path, keyBytes): ArchiveMetadata
Archive startNs, endNs, archiveElementsstart: Timestamp?, end: Timestamp?, elements: List<ArchiveElement>?; omitted elements select messages and consent
Metadata archiveVersion, exportedAtNs, startNs, endNsbackupVersion: UShort, exportedAt: Timestamp, start: Timestamp?, end: Timestamp?
sendSyncRequest(opts, serverUrl), sendSyncArchive(opts, serverUrl, pin), processSyncArchive(archivePin), listAvailableArchives(daysCutoff), AvailableArchiveRemoved; use archive export/import for explicit backup; automatic device sync remains controlled by deviceSync
deleteLocalDatabase()storage().delete(): Unit; it ends an active persistent client before deleting the file; in-memory deletion now fails because there is no file
dropLocalDatabaseConnection()Removed; use explicit client end() and reopen with build
reconnectLocalDatabase()storage().reconnect(): Unit
dbPath / IN_MEMORY_DB_PATHstorage().path(): String? or storagePath(); in-memory has null
debugInformation.apiStatistics, .identityStatistics, .aggregateStatistics, .clearAllStatistics()diagnostics().apiStatistics(), .identityStatistics(), .aggregateStatistics(), .clearStatistics(); suspend methods and new per-operation counters; aggregateStatistics() remains a String
Client.activatePersistentLibXMTPLogWriter(context, FfiLogLevel, FfiLogRotation, maxFiles: Int, processType)SDKClient.activatePersistentLibXMTPLogWriter(context, LogLevel, LogRotation, maxFiles: UInt, LogProcessType)
Client.deactivatePersistentLibXMTPLogWriter, .getXMTPLogFilePaths, .clearXMTPLogsSame names on SDKClient with Android Context helpers
Client.setLibXMTPNativeLogLevel(FfiLogLevel)initLogging(LoggingOptions(level = ...)); call it again to change the level
Topic.userWelcome, Topic.groupMessage, old wire envelope processing and toFfi adaptersRemoved; use SDK conversation IDs, topics from topic(), readers, and public records

The archive key remains exactly 32 bytes. Byte-based methods are also available: exportToBytes(keyBytes, options): ByteArray, importFromBytes(data, keyBytes): Unit, and metadataFromBytes(data, keyBytes): ArchiveMetadata.

Before:

import org.xmtp.android.library.Client
import org.xmtp.android.library.libxmtp.ArchiveOptions
suspend fun oldArchive(client: Client, path: String, key: ByteArray): UShort {
client.createArchive(path, key, ArchiveOptions())
val metadata = client.archiveMetadata(path, key)
client.importArchive(path, key)
return metadata.archiveVersion
}

After:

import uniffi.xmtp_sdk.*
suspend fun archive(client: SDKClient, path: String, key: ByteArray): UShort {
val metadata = client.archives().exportToFile(path, key, ArchiveOptions())
client.archives().importFromFile(path, key)
return metadata.backupVersion
}

Before, the app released and reconnected the same database connection:

import org.xmtp.android.library.Client
suspend fun oldCloseDatabase(client: Client) {
client.dropLocalDatabaseConnection()
client.reconnectLocalDatabase()
}

After, the app ends the client and builds a new client from its saved identity, original options, and codecs. Keep those options in the app: client.options() removes encryption keys and backend credentials, so it is not a complete reopen configuration:

import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.withContext
import uniffi.xmtp_sdk.*
suspend fun reopen(
client: SDKClient,
savedOptions: ClientOptions,
savedCodecs: List<ContentCodec<*>>,
): SDKClient {
val identity = client.identity()
val inbox = client.inboxId()
withContext(NonCancellable) { client.end() }
return SDKClient.build(identity, savedOptions, inbox, codecs = savedCodecs)
}

New logging methods include initLogging(LoggingOptions), setLogSink(sink: LogSink?), and flushTelemetry(). Clear the sink with setLogSink(null). Initialize logging before enabling the persistent writer. ApiStats has publish, query, queryNewest, subscribe, and subscribeStatic, all ULong. IdentityStats has getInboxIds and verifySmartContractWalletSignatures, both ULong. Replace the old RPC-specific statistics fields with these counters.