Developer guide: SDK guides, app examples, and deployment instructions. Start here for app development.
# Install the XMTP SDK
> Install an XMTP SDK for Browser, Node, React Native, Kotlin, or Swift.
Install the SDK for your platform. All SDKs use a backend URL. See [Create a client](/sdk/client/) for client options and [Run the backend](/get-started/run-the-backend/) if you operate the backend. ## Install a package Use **8.0.0-rc3** for Browser, Node, React Native, Android, iOS, and Agent clients. Use `ghcr.io/xmtp/backend:8.0.0-rc3` for the backend. Pin the exact release candidate version. The published Browser 7 and Node 6 packages use an earlier backend API. ### Browser, Node, React Native, and Agent packages Use the package for your platform: | Platform | Package | | ------------ | ---------------------------------- | | Browser | `@xmtp/browser-sdk@8.0.0-rc3` | | Node | `@xmtp/node-sdk@8.0.0-rc3` | | React Native | `@xmtp/react-native-sdk@8.0.0-rc3` | | Agent | `@xmtp/agent-sdk@8.0.0-rc3` | Run one command from your app directory. These commands install the Browser SDK. For Node, React Native, or Agent, replace the entire package specifier `@xmtp/browser-sdk@8.0.0-rc3` with its package and version from the table. * npm
```sh
npm install --save-exact @xmtp/browser-sdk@8.0.0-rc3
```
* pnpm
```sh
pnpm add --save-exact @xmtp/browser-sdk@8.0.0-rc3
```
* yarn
```sh
yarn add --exact @xmtp/browser-sdk@8.0.0-rc3
```
* bun
```sh
bun add --exact @xmtp/browser-sdk@8.0.0-rc3
```
Install the complete package. Keep the generated source, native or WASM bindings, and runtime assets together. ### Android package Add the XMTP Maven artifact to your app module:
```kotlin
android {
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
isCoreLibraryDesugaringEnabled = true
}
}
dependencies {
implementation("org.xmtp:android:8.0.0-rc3")
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5")
}
```
Import `uniffi.xmtp_sdk.*`. Keep the generated sources and JNI libraries from the same release. An older network SDK cannot use this backend. ### Swift package Add [libxmtp](https://github.com/xmtp/libxmtp) as a Swift package in Xcode. Select the `ios-8.0.0-rc3` tag and add the `XmtpSdk` product to your app. For a Swift package, add these dependency and target entries:
```swift
// In Package.dependencies:
.package(url: "https://github.com/xmtp/libxmtp.git", revision: "ios-8.0.0-rc3")
// In your app target's dependencies:
.product(name: "XmtpSdk", package: "libxmtp")
```
The release package supplies the matching native binary. Import `XmtpSdk`. ## Platform prerequisites * Browser Use Node.js 22.12.0 or later for package tooling and a bundler that supports WebAssembly. The Browser SDK stores its database in browser storage and does not encrypt it. Do not pass `storage.encryptionKey`; the Browser SDK rejects it. * Node Use Node.js 22.12.0 or later. If you use database encryption, pass a 32-byte `storage.encryptionKey`. Keep that key and the database location stable between launches. Omitting the key selects unencrypted storage. * React Native Use Hermes and New Architecture. Bare React Native requires iOS 15.1 or Android API 24. Expo development builds require iOS 16.4 on iOS. The package ships arm64 iOS device/simulator libraries and all four Android ABIs. See [React Native and Expo](/get-started/react-native/) for consumer validation targets, native setup, and rebuild steps. Expo Go is not supported. The app owns secure key persistence. Reuse the same optional 32-byte `storage.encryptionKey` and native database location between launches. * Kotlin Use Android API 23 or later and Java 17 for the SDK build. Provide an Android `Context`. Set `ClientOptions.backend` to `BackendSource.Options(BackendOptions(url = backendUrl))`. Set the database location and optional 32-byte key in `StorageOptions`. Call suspend methods from a coroutine. End each client in `withContext(NonCancellable)`. * Swift Use Swift 6.1 or later. The package supports iOS 14 or later and macOS 11 or later. Pass `BackendOptions(url:)` through `ClientOptions.backend`. Use `StorageOptions` for the database location. Pass a securely generated or caller-stored 32-byte `encryptionKey` for persistent storage. Keep the key and reuse it when reopening the database. Omitting it selects unencrypted storage. Call async methods from an async context. ## Next steps * [Build a quickstart app](/get-started/quickstart/) * [Create a client](/sdk/client/) * [Create a signer](/sdk/signer/)
# Migrate to a self-hosted backend
> Move to a self-hosted backend, preserve required history, and choose an SDK Upgrade Guide.
The new XMTP SDKs use a backend that you operate. The backend does not join or copy the previous XMTP network. All participants in a conversation must use the same deployment. Update the app’s SDK calls, create new local databases, and replace the old push integration. This guide compares two fixed source versions: | Before | After | | ------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | | [`main` at `cc878025da`](https://github.com/xmtp/libxmtp/tree/cc878025dab2f93bca18b67fa9fca562a65f2247) | [`self-hosted` at `610175f3b3`](https://github.com/xmtp/libxmtp/tree/610175f3b3108e7d8c6b8b01df6d99296a6e08b2) | These are source baselines, not one shared release number. The examples marked **Before** use the old SDK. The examples marked **After** use the new SDK. Do not mix imports or generated runtime assets from the two versions. React Native 8.0.0-rc3 is included; every pre-8.0.0 React Native app must migrate. ## Recommended versions Use **8.0.0-rc3** for Browser, Node, React Native, Kotlin, Swift, Agent, and the backend. Pin the exact package version in your app and deployment configuration. | Component | Package or image | Recommended version | | ------------ | --------------------------------------------------- | --------------------------------- | | Browser | `@xmtp/browser-sdk` | `8.0.0-rc3` | | Node | `@xmtp/node-sdk` | `8.0.0-rc3` | | React Native | `@xmtp/react-native-sdk` | `8.0.0-rc3` | | Android | `org.xmtp:android` | `8.0.0-rc3` | | iOS | Swift package product `XmtpSdk` from `xmtp/libxmtp` | `8.0.0-rc3` (tag `ios-8.0.0-rc3`) | | Agent | `@xmtp/agent-sdk` | `8.0.0-rc3` | | Backend | `ghcr.io/xmtp/backend` | `8.0.0-rc3` | See [Install the XMTP SDK](/get-started/install/) for the exact package commands. Run the backend with `ghcr.io/xmtp/backend:8.0.0-rc3`; see [Run the backend](/get-started/run-the-backend/#run-the-container-image). ## Migration order 1. [Deploy the backend](/deploy/overview/) and configure its URL, authentication, retention, limits, and optional attachment and push services. 2. [Install the matching SDK](/get-started/install/). An older network SDK cannot use this backend. 3. Preserve required history with the old SDK. Keep a recoverable copy of each database and its encryption key before replacing it. 4. Choose your SDK in [SDK Upgrade Guides](#sdk-upgrade-guides). Update the signer, client options, method calls, and result handling. 5. Register users and create conversations on the new backend. Test sending, reading, restart, stream recovery, and push with two installations before moving users. ## Backend, storage, and authentication Pass an explicit backend URL through `ClientOptions.backend`. Include the scheme: `https://` for a public deployment, or `http://` for local development. There is no default network or fallback endpoint. Remove the old `apiUrl`, `gatewayHost`, and mobile `isSecure` options. Use `StorageOptions` to select the database location and optional label. A label names a directory component; it does not select a network. Its default is absent. The SDK rejects unsafe path components. The Agent SDK retains `XMTP_ENV` as a storage label and uses `XMTP_BACKEND_URL` for the endpoint. For a directory location, storage contains an optional label directory, then a deployment directory and an inbox directory. Keep the database, attachment directory, and encryption key together. Browser storage does not accept a database encryption key. Native storage accepts an optional 32-byte key. Backend authentication is separate from the signer used for XMTP identity. Supply a credential source, or supply a credential and update it with `setCredential`. The credential has a header value, an expiry in Unix seconds, and an optional header name. The default header is `authorization`. For bearer authentication, include `Bearer` and one space before the API key or JWT. The SDK caches callback credentials and uses bounded recovery after expiry or rejection. A direct credential has no refresh callback; the app must replace it. Configuration discovery does not use credentials or call the credential source. See [Backend authentication](/sdk/client/#backend-authentication) for each SDK’s credential shape. ### Configuration and errors `fetchServerConfiguration` takes a backend source and needs no client or database. Each client keeps the configuration snapshot captured when it was built. Read `client.serverConfiguration` in JavaScript, or `client.serverConfiguration()` on mobile. `refreshServerConfiguration()` stores configuration for the next build; it does not replace the current client’s snapshot. Online creation fetches configuration when no cached copy exists or the URL changes. Background refresh runs about hourly. The deployment supplies group and installation limits; the defaults are 250 group members and 10 installations per inbox. Handle typed `XmtpError` variants in JavaScript and Swift, or `XmtpException` variants in Kotlin. The SDK-specific sections show their shapes. Preserve `ErrorDetails`, including its code, category, retry flag, and any stream failure. A database is bound to a backend identifier. Its URL can change while the identifier stays the same. A different identifier records a persistent conflict and stops backend operations. A later matching refresh does not clear that conflict. Use a database created for the intended deployment. ## The local database must be recreated A database created before the transition is rejected with this error:
```text
This database predates the backend transition. Delete the database file and create a new client.
```
It is a storage error whose `details.code` is `StorageError::PreTransitionDatabase`. Match the code, not the message. The error is not retryable. The SDK leaves the database intact. Close the old client before file operations. Use prepareMigrationArchive from the normal SDK package to convert a supported legacy database into an encrypted history archive. The converter uses a temporary copy and does not need a client or network access. Import that archive through the new client’s archives resource. Then use a new storage location, or remove the old database through the app’s migration flow. The normal client cannot open a legacy database directly. Browser readMigrationArchive loads the completed archive into memory. It accepts up to 100 MB (100,000,000 bytes), inclusive, and returns MigrationOutput for a larger archive before allocating its bytes. The per-record limit is separate. A new database creates a new installation. Register it on the new backend. After migration, preserve the database and its key between launches to avoid creating more installations and reaching the deployment’s limit. Await client shutdown with `end()` before external database operations. Browser storage tools are now available through `Storage.admin()`. Import requires an absent destination and a current database. It does not overwrite an existing file or convert a pre-transition database. Import changes the database identity and invalidates earlier delivery cursors and acknowledgement tokens. ## Keeping history You can import messages and conversations from a trusted database file created by a supported older SDK. Close the old SDK, keep a recoverable copy of the database and its encryption key, and use `prepareMigrationArchive` to create an encrypted archive. Import that archive through the new client’s `archives` resource. Only import history from a database you trust. Imported conversations are inactive. You can read their message history, but you cannot send or receive new messages in them. Importing history does not move contacts or active conversations to the new network. Before you can message your old contacts, they must join the same new self-hosted network. Once they have joined, create new conversations with them on that network. Every archive method requires a separate 32-byte archive key. See [Backups](/sdk/backups/) for import methods and key handling. ## Streams, sync, and delivery Message readers can deliver retained messages when they open, then continue with new messages. Do not assume that a stream contains only new traffic. Make app effects safe to repeat. | Consumer | Acknowledgement boundary | | ---------------------------------- | ---------------------------------------------------------------------------------- | | JavaScript or Swift async iterator | The next read acknowledges the previous item. | | JavaScript `onValue` callback | The stream advances after the awaited callback returns successfully. | | Kotlin `Flow` | The direct collector returns before the next read. A buffer changes this boundary. | Closing does not acknowledge the last item. A crash before acknowledgement can cause repeat delivery. Await work that must finish before acknowledgement. Custom codec decode errors remain on delivered messages with their original bytes and error details; they do not silently remove the message. One default message reader owns delivery progress for a database. An explicit delivery cursor opens an independent reader without advancing that default progress. A history snapshot provides a boundary between local history and subsequent delivery. Cursors are local to their database. Reader selection stays fixed for the reader’s lifetime. Open a new reader to change it. JavaScript’s old retry callbacks and `disableSync` options are removed. The core handles bounded recovery. A terminal error ends the reader; the app can open a replacement on the same client. Notification streams have no separate pre-sync. Use an explicit sync when the app needs a current snapshot first. Message streams perform network catch-up and local processing. Apps that keep a message stream open do not need repeated manual sync calls to receive messages. Explicit `sync`, `conversations.syncAll`, and `catchUpToLive` remain useful for a fixed read or a background handler. A completed catch-up can report rejected envelopes: its `failed` count and `completed` flag describe separate results. An incomplete barrier throws an error with the partial summary in its stream failure details. See [Sync](/sdk/sync/) and [Cursors](/protocol/cursors/). If a send returns `PublishedButUnconfirmed`, sync the conversation to finish confirmation. Do not send the message again. ## Push notifications Replace the separate push server, manual topic subscriptions, and HMAC-key updates with the SDK’s notification APIs. Node, React Native, Kotlin, and Swift expose `enableNotifications`, `notificationState`, and `disableNotifications`. Agents use `agent.client`. Browser does not expose these methods. The SDK maintains subscriptions and registration renewal while its task runner runs. Use `conversation.setNotifications` for overrides. Read the effective value from the state snapshot: `group.state().common.notificationsEnabled` or `dm.state().notificationsEnabled`, with the required `await` or `try` for your SDK. Re-register after migration; old-server registrations and overrides are not imported. Configure the backend’s [push channels](/deploy/push-configuration/). APNs needs the operator’s key, team ID, key ID, bundle ID, and environment. FCM needs a Firebase service account. Apps supply their provider token, or an HTTPS receiver URL and a random 32-byte signing key. Registration fails if the selected channel is not configured. Retire the old push server when app migration is complete; the new `disableNotifications` cannot remove its registrations. Push payloads contain a base64 binary `topic` and decimal-text `sequence_id`, with no message bytes. APNs sends a background notification; FCM sends a data message. HTTPS also includes `recipient_id` and signed Standard Webhooks headers. Treat a push as a hint. Open the correct database, process pending Welcomes, sync the conversation, and read decrypted messages. Apply consent and sender checks and suppress duplicates before showing a notification. Delivery can be delayed, repeated, or absent. See [Push notifications](/sdk/push-notifications/) for complete payload routing and handler examples. The SDK’s display topic, such as `[group_message_v1/]`, differs from the base64 push field. Neither uses the old `/xmtp/mls/1/g-/proto` routing label. Use the [topic layout](/protocol/topics/) to decode payloads. ## SDK Upgrade Guides Choose the SDK used by your app. Each guide lists only changes to calls, options, result types, or behavior. Apply the shared migration steps above before you update the SDK. * [Browser SDK Upgrade Guide](/get-started/migrate-to-self-hosted/browser-sdk-upgrade-guide/) * [Node SDK Upgrade Guide](/get-started/migrate-to-self-hosted/node-sdk-upgrade-guide/) * [Kotlin SDK Upgrade Guide](/get-started/migrate-to-self-hosted/kotlin-sdk-upgrade-guide/) * [Swift SDK Upgrade Guide](/get-started/migrate-to-self-hosted/swift-sdk-upgrade-guide/) * [React Native SDK Upgrade Guide](/get-started/migrate-to-self-hosted/react-native-sdk-upgrade-guide/) * [Agent SDK Upgrade Guide](/get-started/migrate-to-self-hosted/agent-sdk-upgrade-guide/)
# Agent SDK Upgrade Guide
> Update Agent SDK calls, options, and results for a self-hosted backend.
Start with [Migrate to a self-hosted backend](/get-started/migrate-to-self-hosted/) for the backend, database, history, and push steps. This guide lists changes from [`main` at `cc878025da`](https://github.com/xmtp/libxmtp/tree/cc878025dab2f93bca18b67fa9fca562a65f2247) to [`self-hosted` at `610175f3b3`](https://github.com/xmtp/libxmtp/tree/610175f3b3108e7d8c6b8b01df6d99296a6e08b2). 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. The package stays `@xmtp/agent-sdk`. Its source moved from `sdks/js/agent-sdk` to `sdks/agent`. The minimum Node.js version changed from 22 to 22.12.0. The Agent SDK re-exports the Node SDK. Apply the [Node SDK Upgrade Guide](/get-started/migrate-to-self-hosted/node-sdk-upgrade-guide/) to `agent.client`, `ctx.client`, and `ctx.conversation`. The Agent changes below cover the additional Agent API. ## Install the recommended version Use SDK **8.0.0-rc3** with backend **8.0.0-rc3**. Run this command from your app directory to save the exact SDK version:
```sh
npm install --save-exact @xmtp/agent-sdk@8.0.0-rc3
```
See [Install the XMTP SDK](/get-started/install/) for platform requirements. ## Create and configure an agent `Agent.create(signer, options)` now requires options with an explicit `backend` and `storage`. An environment name no longer selects a public network. Replace `apiUrl` or `gatewayHost` with `backend.url`, including its scheme. Put `appVersion` under `backend`. Supply backend authentication through `backend.credentials`. | Old option | New option or action | | ------------------------------------ | ---------------------------------------------------------------------------------------------- | | `env` | `storage.label`; this is a directory label only | | `dbPath: string` | `storage.location: { dbPath, attachmentsDir }` | | `dbPath: (inboxId) => path` | Compute an explicit location, or use `storage.location: { directory }` | | `dbPath: null` | `storage.location: "inMemory"` | | `dbEncryptionKey` | `storage.encryptionKey`, as 32 bytes | | `disableDeviceSync: true` | `deviceSync: false` | | `disableAutoRegister: true` | `registration: { auto: false }` | | `nonce` | `registration.nonce`; set `1n` explicitly when preserving the old default inbox derivation | | `minDbPoolSize`, `maxDbPoolSize` | `storage.pool.min`, `storage.pool.max` | | `useSingleConnection` | `storage.singleConnection` | | `workerConfig` | `workers`, with a default interval and per-worker interval records | | `historySyncUrl` | Removed; the backend supplies device-sync configuration | | `waitForRegistrationVisible` | Removed; no registration quorum option | | `unstableChangeCallbacks.appData` | Removed from creation options; use the client event API | | `stdoutLoggingLevel` | Removed; no separate stdout level | | `otelEndpoint`, `resourceAttributes` | `initLogging({ otel: { endpoint, serviceName }, resourceAttributes })`; attributes use a `Map` | | `loggingLevel`, `structuredLogging` | Call `initLogging({ level, structured })` | | `codecs` | Still supported; use the Node SDK codec interface with `.type` | Agent creation still sets device sync off by default. It sets `backend.appVersion` to `agent-sdk/` when the caller supplies URL options without an app version. Other Node options are now grouped under `forkRecovery`, `workers`, `handlers`, and `attachments`. `allowOffline` permits startup from an existing store when the backend is unavailable. Before: Before (main cc878025da)
```text
import { Agent, createSigner, createUser } from "@xmtp/agent-sdk";
const agent = await Agent.create(createSigner(createUser()), {
env: "production",
dbPath: (inboxId) => `./agent-data/xmtp-${inboxId}.db3`,
disableDeviceSync: true,
});
```
After:
```ts
const user = createUser();
const signer = createSigner(user);
const agent = await Agent.create(signer, {
backend: { url: "https://your-backend.example.com" },
storage: { location: { directory: "./agent-data" } },
deviceSync: false,
});
```
`createUser(key?, chain?)` keeps its arguments. Its key type is now `Hex` from viem. `createIdentifier(user)` returns `{ identifier, kind: "ethereum" }` instead of `{ identifier, identifierKind: IdentifierKind.Ethereum }`. `createSigner(user)` creates the new signer interface: async `identity()`, async `kind()`, and `sign({ text })`, which returns `{ kind: "ecdsa", value }`. Replace custom signers that use `type`, `getIdentifier()`, or `signMessage()`. ## Environment variables and stored databases Before you call `createFromEnv()`, set `XMTP_WALLET_KEY` to a hex private key with a `0x` prefix. This variable is required. For an encrypted database, set `XMTP_DB_ENCRYPTION_KEY` to the stored 32-byte key as 64 hex characters, with or without `0x`. This variable is optional. Keep the same key across restarts. These setup requirements apply before and after the upgrade. | Variable | Change | | ------------------------ | ------------------------------------------------------------------------------------ | | `XMTP_BACKEND_URL` | Required unless options supply `backend`; overrides a URL in options | | `XMTP_GATEWAY_HOST` | No longer read; use `XMTP_BACKEND_URL` | | `XMTP_ENV` | Any safe directory label; no network selection | | `XMTP_DB_DIRECTORY` | Storage root; creates a missing directory with mode `0700` where supported | | `XMTP_FORCE_DEBUG_LEVEL` | Still read; sets structured logging through `initLogging`; invalid values use `warn` | Load `.env` into `process.env` before the call. `createFromEnv()` does not read `.env` itself. Its options are now `Partial`. Caller-supplied `storage` overrides the directory variable and skips the legacy database search. `storage.encryptionKey`, when supplied, overrides `XMTP_DB_ENCRYPTION_KEY`.
```ts
const environmentAgent = await Agent.createFromEnv({
storage: { location: { directory: "./agent-data" } },
});
```
A new store uses `./xmtp` or `XMTP_DB_DIRECTORY` as its root. A non-empty `XMTP_ENV` adds a label directory. The store contains `{root}/{label}/{deployment}/{inbox-id}/xmtp.db3` and a sibling `attachments` directory. The label component is omitted when empty. Preserve the whole storage root, its deployment record, and the same encryption key across restarts. `createFromEnv()` can select one matching legacy database filename. This supports only a database already in the current format; it cannot open or convert a pre-transition database from `main`. Keep the old database as a backup and choose new storage for migration. With `XMTP_DB_DIRECTORY`, it checks `xmtp-.db3`. Without that variable, it checks the working directory for `xmtp--.db3`. `XMTP_ENV` limits that second search when set. It matches the signer, including the old default nonce `1n`; files for other inboxes do not prevent startup. More than one matching legacy file, or both a legacy and current store for that inbox, causes an error. Pass `storage.location: { dbPath, attachmentsDir }` to select the intended database. This selection does not move the file. ## Message contexts and filters The context classes and most method names remain. Their underlying `client` is now the non-generic Node `Client`. Their `message` is the new `Message` with tagged content. | Old code or type | New code or type | | ------------------------------------------------ | ------------------------------------------------------------------------------------- | | Text from `ctx.message.content` | Text from `ctx.content`, after `ctx.isText()` or in a `text` listener | | Assign `ctx.message.content` | Assign `ctx.content`; the SDK message stays unchanged | | `DecodedMessage` | `Message`; inspect `message.content.kind` | | `ctx.isAllowed`, `ctx.isDenied`, `ctx.isUnknown` | `await ctx.isAllowed()`, `await ctx.isDenied()`, `await ctx.isUnknown()` | | Synchronous conversation consent access | `await ctx.consentState()` returns `"allowed"`, `"denied"`, or `"unknown"` | | `filter.isGroupAdmin(...)` | `await filter.isGroupAdminAsync(...)` | | `filter.isGroupSuperAdmin(...)` | `await filter.isGroupSuperAdminAsync(...)` | | `usesCodec` matches `codec.contentType` | It matches `codec.type`: authority, type name, and major version | | Read-receipt payload | `ctx.content` is `undefined` | | `Reply` / `EnrichedReply` payload | Tagged reply record: `kind`, `referenceId`, `body`; enrichment stays on `ctx.message` | | Reaction enums | String values, such as schema `"unicode"` | `getClientAddress()`, `agent.address`, and `getSenderAddress()` keep their names. The first two read `client.identity.identifier`. The sender helper reads the first identity from `client.inboxStates([senderInboxId], false)`. `filter.hasContent` now accepts built-in read receipts. It rejects unknown content and custom content without a decoded `value`. `filter.usesCodec` requires decoded content and matches the new codec record. Before: Before (main cc878025da)
```text
agent.on("text", async (ctx) => {
console.log(ctx.message.content);
if (ctx.isAllowed) await ctx.sendTextReply("Received");
});
agent.use(async (ctx, next) => {
if (!filter.isGroupAdmin(ctx.conversation, ctx.message)) return;
await next();
});
```
After:
```ts
agent.on("text", async (ctx) => {
console.log(ctx.content);
console.log(ctx.message.content.kind);
if (await ctx.isAllowed()) {
await ctx.sendTextReply("Received");
await ctx.sendReaction("👍", "unicode");
}
});
agent.on("reply", (ctx) => {
console.log(ctx.content.referenceId, ctx.content.body);
});
```
```ts
const requireAdmin: AgentMiddleware = async (ctx, next) => {
if (!(await filter.isGroupAdminAsync(ctx.conversation, ctx.message))) return;
if (ctx.isText()) {
ctx.content = ctx.content.trim();
}
await next();
};
agent.use(requireAdmin);
```
Read decoded content from `ctx.content` after a context type guard narrows its type. `sendReaction(content, schema?)`, `sendTextReply(text)`, and `sendMarkdownReply(markdown)` keep their arguments. They now send with `shouldPush: false`. Use the underlying conversation send methods when the app needs another push option. ## Conversations and attachments The Agent helpers `createDmWithAddress`, `createGroupWithAddresses`, `addMembersWithAddresses`, and `getConversationContext` keep their names. They now call Node `createDm`, `createGroup`, `addMembers`, and `getById`. The Node option and result shapes changed. Do not pass old `Group` or `Dm` types. Ethereum addresses use viem `Hex`. Handles are new. `createDmWithHandle`, `createGroupWithHandles`, and `addMembersWithHandles` take handles such as `nick#2341`. See [Handles](/sdk/inboxes/#handles).
```ts
const peer = "0x0000000000000000000000000000000000000001";
const dm = await agent.createDmWithAddress(peer);
const group = await agent.createGroupWithAddresses([peer]);
await agent.addMembersWithAddresses(group, [
"0x0000000000000000000000000000000000000002",
]);
const context = await agent.getConversationContext(dm.id);
console.log(context?.getClientAddress(), createIdentifier(user));
```
| Old attachment API | New attachment API | | ---------------------------------------------- | ----------------------------------------------------------------------------------------------- | | `ctx.sendRemoteAttachment(file, upload)` | Same call; `upload` is now optional | | `createRemoteAttachmentFromFile(file, upload)` | `createRemoteAttachmentFromFile(client, file, upload?)` | | `downloadRemoteAttachment(remote)` | `downloadRemoteAttachment(client, remote)` | | Callback receives `EncryptedAttachment` | Callback receives `HostedAttachment`: encryption keys, encrypted `payload`, optional `filename` | | `createRemoteAttachment(encrypted, url)` | Same name; input is `HostedAttachment`, with Rust-generated encryption keys | Without a callback, file creation uses the client’s configured backend attachment service and awaits upload. With a callback, the SDK encrypts the encoded attachment through Rust and passes the ciphertext to app-owned hosting. Download uses the client’s attachment service for URL checks, decryption, digest checks, and local storage. It returns file content, MIME type, and filename. It no longer performs a direct unverified `fetch` in the Agent utility. Before:
```text
const remote = await createRemoteAttachmentFromFile(file, upload);
await conversation.sendRemoteAttachment(remote);
const downloaded = await downloadRemoteAttachment(remote);
```
After, with backend attachment hosting:
```ts
const file = new File(["hello"], "hello.txt", { type: "text/plain" });
const remote = await createRemoteAttachmentFromFile(agent.client, file);
await dm.sendRemoteAttachment(remote);
const downloaded = await downloadRemoteAttachment(agent.client, remote);
console.log(downloaded.filename);
```
## Streams, events, and error middleware `start()` and `stop()` keep their names. `start()` now opens native conversation and message readers without a separate conversation sync. The `start` event reports local reader readiness; the backend can be offline. Conversation events exclude groups already stored locally and include a pending Welcome when it first discovers its group. `AgentStreamingOptions` and `StreamAllMessagesOptions` now use Node `StreamOptions`: `signal`, `onClose`, and `onConnectionStateChange`. Remove old retry settings, `disableSync`, `onFail`, and `onRestart`. Agent `start()` does not expose a message selection, replay cursor, or `onValue` option. Core owns network recovery with a finite budget. When a terminal error ends a reader, the Agent closes both readers before it calls error middleware. The old Agent could restart its streams automatically after a handled streaming error. The new Agent requires an explicit `agent.start()` to open a new generation, even when error middleware calls `next()`. A second `start()` while running has no effect. `stop()` ends both readers and permits a later start on the same client. The event names remain: `actions`, `attachment`, `conversation`, `group-update`, `dm`, `group`, `inline-attachment`, `intent`, `leave-request`, `markdown`, `message`, `multi-attachment`, `reaction`, `read-receipt`, `reply`, `start`, `stop`, `text`, `transaction-reference`, `unhandledError`, `unknownMessage`, and `wallet-send-calls`. Their contexts use the new Node content records. `unknownMessage` now includes unknown content and custom content without a built-in event. The old Agent skipped messages whose decoded content was absent. Message middleware still runs in registration order. Normal `return` stops the middleware chain and accepts the message without events. `next()` continues to the content event and then `message`. The Agent skips its own inbox. It now waits for async message and conversation listeners. Await all work that must finish before delivery acceptance. A task that runs without `await` does not delay acceptance. Error middleware still uses `agent.errors.use()`. `next()` handles the error and resumes processing when possible. `next(error)` or a thrown error passes that error to the next handler. Returning without `next()` stops the reader without accepting the failed message. An unhandled message failure has the same effect. A new default reader can deliver the failed message again. Make external actions safe when delivery repeats. Error contexts always have `client`; `message` and `conversation` can be absent.
```ts
agent.on("unhandledError", (error) => console.error(error));
agent.errors.use((error, _ctx, next) => {
if (error instanceof AgentStreamingError) {
console.error("Streams ended", error.cause);
return;
}
return next(error);
});
await agent.start({
onConnectionStateChange: (_previous, current) => console.log(current),
onClose: (reason) => console.log(reason.kind),
});
// After the app resolves the cause of a terminal error, it can call start again.
await agent.stop();
await agent.start();
await environmentAgent.stop();
```
## Other exported helpers `CommandRouter` keeps its constructor, `command`, `default`, `handle`, `middleware`, and `commandList` API. Command arguments now replace `ctx.content`. They do not replace `ctx.message.content`. `ActionWizard` keeps its builder, session, and handler APIs. Its prompts now disable push. Its handlers read tagged intent content and `ctx.content`. `ActionWizardCompleteHandler` and `ActionWizardCancelHandler` are now exported types. `PerformanceMonitor`, its config, and `HealthReport` keep their API and behavior. Its health timer does not keep the process alive. Empty event-loop samples produce zero delay. `createERC20TransferCalls` and `createNativeTransferCalls` add `metadata.extra: new Map()` to the wallet-call records they return. `logDetails`, `getInstallationInfo`, `getTestUrl`, `getValidLogLevels`, and `parseLogLevel` remain. Log levels are lower-case strings instead of enum values. `InstallationInfo` is now exported. Its fields remain `totalInstallations`, `installationId`, `mostRecentInstallationId`, and `isMostRecent`. Installation details use `id` and `createdAt.ns` from the Node records. `getTestUrl` no longer includes an environment segment. Use a test client configured for the same self-hosted backend when you test delivery. `EventHandlerMap` is now exported. Node `Client`, `Group`, and `Dm` no longer take a decoded-content generic. Agent context type parameters remain, but the SDK message is always the tagged Node `Message`.
# Browser SDK Upgrade Guide
> Update Browser SDK calls, options, and results for a self-hosted backend.
Start with [Migrate to a self-hosted backend](/get-started/migrate-to-self-hosted/) for the backend, database, history, and push steps. This guide lists changes from [`main` at `cc878025da`](https://github.com/xmtp/libxmtp/tree/cc878025dab2f93bca18b67fa9fca562a65f2247) to [`self-hosted` at `610175f3b3`](https://github.com/xmtp/libxmtp/tree/610175f3b3108e7d8c6b8b01df6d99296a6e08b2). 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. The package name stays `@xmtp/browser-sdk`. The self-hosted SDK uses ESM and generated public bindings. Its source moved from `sdks/js/browser-sdk` to `sdks/browser`. Remove imports from `@xmtp/wasm-bindings` and private package paths. Import client types from `@xmtp/browser-sdk`. Import standalone codecs and pure helpers from `@xmtp/browser-sdk/pure`. ## Install the recommended version Use SDK **8.0.0-rc3** with backend **8.0.0-rc3**. Run this command from your app directory to save the exact SDK version:
```sh
npm install --save-exact @xmtp/browser-sdk@8.0.0-rc3
```
See [Install the XMTP SDK](/get-started/install/) for platform requirements. ## Create a client and adapt the signer The old signer used `type`, `getIdentifier()`, and `signMessage()`. The new signer uses asynchronous `identity()`, `kind()`, and `sign(request)`. The signature result names its kind. Replace `Identifier.identifierKind` with `PublicIdentity.kind`. Old API on main: Before (main cc878025da)
```text
import { Client, IdentifierKind, type Signer } from "@xmtp/browser-sdk";
async function createClient(
address: string,
signText: (text: string) => Promise,
) {
const signer: Signer = {
type: "EOA",
getIdentifier: () => ({
identifier: address,
identifierKind: IdentifierKind.Ethereum,
}),
signMessage: signText,
};
return Client.create(signer, { env: "dev", appVersion: "chat/1.0" });
}
```
New signer:
```ts
export function walletSigner(
address: string,
signText: (text: string) => Promise,
): Signer {
return {
identity: async () => ({ identifier: address, kind: "ethereum" }),
kind: async () => ({ kind: "eoa" }),
sign: async (request) => ({
kind: "ecdsa",
value: await signText(request.text),
}),
};
}
```
New client:
```ts
export async function createClient(signer: Signer) {
return Client.create(signer, {
backend: { url: "https://xmtp.example.com", appVersion: "chat/1.0" },
storage: { location: "default" },
});
}
```
Use an explicit backend URL. `env`, `apiUrl`, and `gatewayHost` no longer select the network. The Browser SDK also requires `storage`. `Client.create(signer, options)` can establish an identity. `Client.build(identity, options, inboxId?)` opens a stored identity. An empty database fails with `IdentityNotFound` when opened with `build`. For a smart contract wallet, return `{ kind: "scw", chainId, blockNumber }` from `kind()`. Return `{ kind: "scw", bytes, address, chainId, blockNumber }` from `sign()`. The optional block number and the chain ID use `bigint`. The new signer also supports the `passkey` kind. Remove `createEOASigner`, `createSCWSigner`, and `toSafeSigner` calls. Supply the new signer interface, or use `Client.generateKeys()` or `LocalSigningKey.fromBytes(bytes)` for an app-owned local key. | Old client option | Self-hosted option or action | | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `env`, `apiUrl`, `gatewayHost` | `backend: { url }`. There is no environment-to-URL lookup. | | `backend: Backend` | `backend: BackendSource`, with a connected `Backend` or `{ url, appVersion?, credentials? }`. `Backend.connect(options)` replaces `createBackend(options)` and the old builder. | | `appVersion` | `backend.appVersion` when using backend options. | | `dbPath: undefined` | `storage: { location: "default" }`. The new default layout differs. | | `dbPath: null` | `storage: { location: "inMemory" }`. | | `dbPath: string` | `storage: { location: { dbPath, attachmentsDir } }`. Both OPFS entry paths are required. | | `dbEncryptionKey` | Removed from the Browser API. Browser storage has no SDK database encryption key. The new SDK rejects `storage.encryptionKey` with `StorageLocation`; it does not ignore it. | | `disableDeviceSync` | `deviceSync`, with the opposite sense. The default is `true`. | | `disableAutoRegister` | `registration: { auto: false }`. The default is automatic registration. | | Old client default inbox nonce `1n` | New default is `0n`. Set `registration: { nonce: 1n }` to preserve the old derived inbox ID. This does not transfer the old backend identity. | | `waitForRegistrationVisible` | Removed. There is no old node quorum option. | | `historySyncUrl` | Removed. There is no archive-server URL option. | | `workerConfig` | `workers`, with `defaultIntervalNs` and per-worker `intervals` records. | | `codecs` | Still per client, with the new public `ContentCodec` interface. | | `loggingLevel`, `structuredLogging`, `performanceLogging` | Use the public logging configuration and `setLogSink()`. These are no longer client options. | | No equivalent | `allowOffline`, `forkRecovery`, `handlers`, and attachment limits in `attachments`. | ## Reopen an OPFS database after migration The new SDK cannot open a pre-transition database. First create a new database on the target backend with `Client.create`. The examples below compare the reopen APIs; the new example requires a database already created by the new SDK. Old `client.close()` is synchronous. The new shutdown is `await client.end()`. Do not open two clients on one database at the same time. Old API on main: Before (main cc878025da)
```text
import { Client, type Identifier } from "@xmtp/browser-sdk";
async function reopen(identity: Identifier, oldDbPath: string) {
const client = await Client.build(identity, { dbPath: oldDbPath });
client.close();
}
```
New explicit storage:
```ts
export async function reopenCurrentDatabase(
identity: PublicIdentity,
backend: BackendSource,
currentDbPath: string,
attachmentsDir: string,
) {
// Use a database already created by this SDK. End its client first.
// Both paths name OPFS entries.
const options: ClientOptions = {
backend,
storage: { location: { dbPath: currentDbPath, attachmentsDir } },
};
const client = await Client.build(identity, options);
const inboxId = client.inboxId;
try {
if ((await client.storage.path()) !== currentDbPath) {
throw new Error("The client opened another database");
}
} finally {
await client.end();
}
return Client.build(identity, { ...options, allowOffline: true }, inboxId);
}
```
Use the new database’s actual OPFS entry name for `dbPath` and its attachment directory. Browser paths name OPFS entries; they are not host filesystem paths. The old default was `xmtp-{env}-{inboxId}.db3`. The new default uses the `xmtp-sdk` OPFS pool and a deployment/inbox layout. A default location does not move or convert an old database. With default or directory storage, offline startup needs the saved backend deployment mapping and the saved inbox ID passed to `build`. Explicit paths do not need that path lookup. `allowOffline: true` permits startup from stored state. It does not prove that the backend used later is compatible. `Storage.admin()` replaces `Opfs.create()`. It opens an independent lease on the package storage worker. Await `admin.end()` after use. There is no separate `init()` call. Old API on main: Before (main cc878025da)
```text
import { Opfs } from "@xmtp/browser-sdk";
async function exportDatabase(path: string) {
const opfs = await Opfs.create();
try {
return await opfs.exportDb(path);
} finally {
opfs.close();
}
}
```
New storage administration:
```ts
export async function exportClosedDatabase(dbPath: string) {
// End every client that owns this database before an import or replacement.
const admin = await Storage.admin();
try {
if (!(await admin.fileExists(dbPath))) {
throw new Error("The database does not exist");
}
return await admin.exportDb(dbPath);
} finally {
await admin.end();
}
}
```
| Old storage member | Self-hosted member | | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | | `new Opfs()`, `Opfs.create()`, `init()` | `await Storage.admin()`. Direct construction and initialization are removed. | | `close()` | `await admin.end()`. | | No client storage object | `client.storage.path()` and `client.storage.reconnect()`. `storage.delete()` is excluded in Browser; use the admin lease for closed database files. | End each client that owns a database before import, restore, or replacement. Whole-database import rotates the delivery cursor identity. Archive import adds records and preserves that identity. Database export and archive export are different operations. ## Create, find, and list conversations `Conversation` is now a TypeScript union of `Group | Dm`. It is no longer a base class that an app constructs. Narrow it with `instanceof Group` or the `kind` property. Group and DM methods remain on their objects. Old API on main: Before (main cc878025da)
```text
import {
ConversationType,
IdentifierKind,
type Client,
} from "@xmtp/browser-sdk";
async function createConversations(client: Client, address: string) {
const peer = { identifier: address, identifierKind: IdentifierKind.Ethereum };
const dm = await client.conversations.createDmWithIdentifier(peer);
const group = await client.conversations.createGroupWithIdentifiers([peer], {
groupName: "Team",
});
const listed = await client.conversations.list({
conversationType: ConversationType.Group,
limit: 20n,
});
return { dm, group, listed };
}
```
New API:
```ts
export async function createConversations(
client: Client,
peer: PublicIdentity,
extraMember: PublicIdentity,
) {
const dm = await client.conversations.createDm(peer);
const group = await client.conversations.createGroup([peer], {
name: "Team",
permissions: { kind: "adminOnly" },
});
const result = await group.addMembers([extraMember]);
const listed = await client.conversations.list({
kind: "group",
consentStates: ["allowed"],
orderBy: "lastActivity",
limit: 20,
});
return { dm, group, result, listed };
}
```
Pass either inbox IDs or public identities to `createGroup`, `createDm`, `addMembers`, and `removeMembers`. A member list must use one form throughout. A mixed list fails with `InvalidArgument` before a call reaches the backend. An empty member list uses the inbox-ID route. | Old `Conversations` member | Self-hosted member or action | | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `getConversationById(id)` | `getById(id)`. A missing conversation is `undefined`. | | `fetchDmByIdentifier(identifier)` | `getDmByIdentity(identity)`. | | `createGroupWithIdentifiers(identifiers, options)` | `createGroup(identities, options)`. | | `createDmWithIdentifier(identifier, options)` | `createDm(identity, options)`. | | `createGroup(inboxIds, options)`, `createDm(inboxId, options)` | Same names, with the inbox/identity union inputs. | | `createGroupOptimistic(options)` | Same name, with new group options. | | `list(options)`, `listGroups(options)`, `listDms(options)` | Same names. `listGroups` and `listDms` require the options position; pass `undefined` for defaults. Results are public conversation objects; use new query options. | | `syncAll(consentStates?)` | Call `syncAll(consentStates)` with an array or explicit `undefined`. It returns the new `GroupSyncSummary`. | | `topic` | Removed from the collection. A conversation still has `topic`. | | All `stream*` methods | Use `ConversationStream`, `MessageStream`, or client events. See the stream table below. | | Old conversation option | Self-hosted option | | ----------------------------------------------- | --------------------------------------------------------------------------------------------------- | | `CreateGroupOptions.groupName` | `name`. | | `groupImageUrlSquare` | `imageUrl`. | | `groupDescription` | `description`. | | `permissions`, `customPermissionPolicySet` | `permissions: { kind: "allMembers" }`, `{ kind: "adminOnly" }`, or `{ kind: "custom", policySet }`. | | `messageDisappearingSettings: { fromNs, inNs }` | `disappearing: { from: Timestamp, retentionNs: bigint }`. This also applies to DM creation. | | `ListConversationsOptions.conversationType` | `kind: "group" \| "dm"`. | | `createdBeforeNs`, `createdAfterNs` | `createdBefore`, `createdAfter`, with `Timestamp` values. | | `limit` as an `i64`/`bigint` | `limit` as a `u32`/`number`. | | `ListConversationsOrderBy` enum | `orderBy: "createdAt" \| "lastActivity"`. | | `consentStates` | Consent values are `"allowed"`, `"denied"`, and `"unknown"`. | | No equivalent | `lastActivityBefore` and `lastActivityAfter`, with `Timestamp` values. | The same required-position rule applies to `lastActivityAt(contentTypes)`, `userData(fields, inboxIds)`, and `catchUpToLive(timeoutMs)`. Pass `undefined` for a default value instead of omitting that argument. In `PermissionPolicySet`, rename `addMemberPolicy`, `removeMemberPolicy`, `addAdminPolicy`, and `removeAdminPolicy` to `addMember`, `removeMember`, `addAdmin`, and `removeAdmin`. Rename `updateGroupNamePolicy`, `updateGroupDescriptionPolicy`, `updateGroupImageUrlSquarePolicy`, `updateMessageDisappearingPolicy`, and `updateAppDataPolicy` to `updateName`, `updateDescription`, `updateImage`, `updateDisappearing`, and `updateAppData`. Use the new string policy values in each field. ## Read group and DM state The new API reads mutable values with `await group.state()` or `await dm.state()`. A group returns `GroupState`, with common state under `common`. A DM returns `ConversationState` directly. Read state again after an update when the app needs current values. Old API on main: Before (main cc878025da)
```text
import type { Group } from "@xmtp/browser-sdk";
async function updateGroup(group: Group) {
const name = group.name;
const active = await group.isActive();
await group.updateAppData("updated");
await group.updateMessageDisappearingSettings(
1_000_000_000n,
86_400_000_000_000n,
);
await group.removeMessageDisappearingSettings();
return { name, active };
}
```
New API:
```ts
export async function readGroupState(group: Group) {
const state = await group.state();
await group.updateAppData("updated", state.appData);
await group.updateDisappearingSettings({
from: new Timestamp(BigInt(Date.now()) * 1_000_000n),
retentionNs: 86_400_000_000_000n,
});
// Pass undefined to remove the disappearing settings.
await group.updateDisappearingSettings(undefined);
return {
name: state.name,
admins: state.admins,
active: state.common.isActive,
};
}
```
| Old conversation or group member | Self-hosted member or value | | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `id`, `topic`, `addedByInboxId` | Readonly properties. An unknown `addedByInboxId` is `null`. | | `createdAtNs`, `createdAt` | `createdAt: Timestamp`; read `.ns` or `.date`. | | `metadata.creatorInboxId`, `metadata.conversationType` | `creatorInboxId` and `kind` properties. An unknown creator is `null`. `isCreator` is also available. The old `metadata` record is removed. | | `isActive()`, `consentState()`, `pausedForVersion()` | Values in common state: `isActive`, `consentState`, `pausedForVersion`. | | `messageDisappearingSettings()`, `isMessageDisappearingEnabled()` | Common state values `disappearingSettings` and `isDisappearingEnabled`. | | `updateMessageDisappearingSettings(fromNs, inNs)` | `updateDisappearingSettings({ from: new Timestamp(fromNs), retentionNs: inNs })`. | | `removeMessageDisappearingSettings()` | `updateDisappearingSettings(undefined)`. | | `updateConsentState(state)` | Same name, with string consent values. | | `name`, `imageUrl`, `description`, `appData`, `admins`, `superAdmins` | Values in `await group.state()`. | | `permissions()` | `(await group.state()).permissions`. DM state has no permission-policy getter. | | `isPendingRemoval()` | `(await group.state()).membershipState === "pendingRemove"`. | | `updateAppData(value)` | `updateAppData(value, expected)`. Pass `undefined` for an unconditional update, or the last state value for a compare-and-set update. | | `updatePermission(type, policy, metadataField?)` | Use `updatePermission(kind, policy, metadataField)` with `PermissionUpdateKind`, new policy strings, and `MetadataFieldKind`. Pass `undefined` when no metadata field applies. | | `addMembersByIdentifiers`, `removeMembersByIdentifiers` | `addMembers(identities)`, `removeMembers(identities)`. | | `removeMembers(inboxIds)` | `removeMembers(inboxIdsOrIdentities)`. A member list must use one input form throughout. | | `addMembers(inboxIds)` | Same name. Returns `MembershipResult` with `added`, `removed`, and `failedInstallationIds`. | | `members()` | Same name; returns `Member[]`, with `identities` instead of `accountIdentifiers`. The old `installationIds` field is absent. | | `lastMessage()`, `debugInfo()` | Same names; results use the new message and diagnostic types. | | `lastReadTimes()` | Same name; map values are `Timestamp`, not nanosecond integers. | | `processStreamedMessage(envelopeBytes)` | Removed. Use the SDK reader and message query APIs. | | `Dm.peerInboxId()` | Returns `Promise` instead of `Promise`. An unknown peer is `null`. | ## Send and read messages The standard send method names remain. All sends use `SendOptions`. The `optimistic` option remains available. `prepareMessage()` stores an unpublished message and queues optimistic publication. It does not keep an app draft private until a later publish call. `idempotencyKey` remains available on the new send options. Compression is optional and uses `"deflate"` or `"gzip"`. If a send fails with `PublishedButUnconfirmed`, call `conversation.sync()` before deciding what to do next. Do not immediately send the content again. Old API on main: Before (main cc878025da)
```text
import type { Conversation } from "@xmtp/browser-sdk";
async function sendMessages(conversation: Conversation) {
const id = await conversation.sendText("Hello");
await conversation.sendMarkdown("**Hello**");
await conversation.sendReadReceipt();
const pending = await conversation.sendText("Send later", {
optimistic: true,
});
await conversation.publishMessages();
return { id, pending };
}
```
New standard sends:
```ts
export async function sendMessages(conversation: Conversation) {
const messageId = await conversation.sendText("Hello");
await conversation.sendMarkdown("**Hello**", { shouldPush: true });
await conversation.sendReadReceipt();
return messageId;
}
```
New prepare and publish:
```ts
export async function prepareMessage(conversation: Conversation) {
await initPureWasm();
const codec = new TextCodec();
const messageId = await conversation.prepareMessage(codec, "Send later");
await conversation.publishMessage(messageId);
return messageId;
}
```
| Old message operation | Self-hosted operation | | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `send(encoded, SendMessageOpts)` | `send(encoded, SendOptions)` or `send(codec, value, SendOptions)`. | | `sendText`, `sendMarkdown`, `sendReadReceipt` | Same names, with `SendOptions`. | | `sendAttachment`, `sendRemoteAttachment`, `sendMultiRemoteAttachment` | Same names, with public content records and `SendOptions`. | | `sendTransactionReference`, `sendWalletSendCalls`, `sendActions`, `sendIntent` | Same names, with public content records and `SendOptions`. | | `sendReaction(reaction, options)` | `sendReaction(reference, referenceInboxId, reaction, options?)`. The new `Reaction` contains `action`, `schema`, and `content`; target IDs are separate arguments. | | `sendReply(reply, options)` | `sendReply(reference, referenceInboxId, encoded, options?)`. `message.reply(text)` is also available. | | `send*(..., { optimistic: true })` | Still supported in `SendOptions`. Alternatively, use `prepareMessage(encoded)` or `prepareMessage(codec, value)`, then `publishMessage(id)` or `publishMessages()`. | | `messages(options)` | Returns public `Message[]` with the new query options. | | `countMessages(options)` | Returns `bigint`. Pass `undefined` for the required options argument when no filter is needed. | | No message actions | `Message.refresh()`, `delete()`, `deleteLocally()`, `react()`, `reply()`, `parent()`, `conversation()`, and `client()`. Collection actions are also available. | Old API on main: Before (main cc878025da)
```text
import { SortDirection, type Conversation } from "@xmtp/browser-sdk";
async function history(conversation: Conversation) {
const first = await conversation.messages({
limit: 20n,
direction: SortDirection.Descending,
});
const last = first.at(-1);
return last
? conversation.messages({
limit: 20n,
direction: SortDirection.Descending,
sentBeforeNs: last.sentAtNs,
})
: [];
}
```
New timestamp query:
```ts
export async function readHistory(conversation: Conversation) {
const firstPage = await conversation.messages({
limit: 20,
sortBy: "sentAt",
direction: "descending",
});
const last = firstPage.at(-1);
const nextPage = last
? await conversation.messages({
limit: 20,
sortBy: "sentAt",
direction: "descending",
sentBefore: last.sentAt,
})
: [];
return { firstPage, nextPage };
}
```
Replace `sentBeforeNs`, `sentAfterNs`, `insertedBeforeNs`, and `insertedAfterNs` with `sentBefore`, `sentAfter`, `insertedBefore`, and `insertedAfter`. Use `Timestamp` values. Use `number` for a `u32` limit. `direction`, `sortBy`, `deliveryStatus`, and message `kind` use string values. `contentTypes` and `excludeContentTypes` take `ContentTypeId[]`. The old `ContentType.Custom` wildcard is removed. Keep `excludeSenderInboxIds` when needed. A sent-time cutoff is not a unique pagination cursor. Messages can have the same timestamp. Use a history snapshot and its delivery cursor for a history/live boundary. ## Replace `DecodedMessage` and content guards `DecodedMessage` is replaced by `Message`. Its `content` is a tagged union. Check `message.content.kind` before reading a variant. The SDK does not use the old generic content union or the exported `isText`, `isReply`, and related guard helpers. Old API on main: Before (main cc878025da)
```text
import { isText, type DecodedMessage } from "@xmtp/browser-sdk";
function display(message: DecodedMessage) {
return isText(message) ? (message.content ?? "") : (message.fallback ?? "");
}
```
New content handling:
```ts
export function displayMessage(message: Message): string {
switch (message.content.kind) {
case "text":
case "markdown":
return message.content.value;
case "custom":
return message.fallback ?? "Custom content";
case "unknown":
return message.fallback ?? "Cannot decode this content";
default:
return message.fallback ?? message.content.kind;
}
}
```
| Old `DecodedMessage` field | Self-hosted `Message` field | | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `sentAt: Date`, `sentAtNs: bigint` | `sentAt: Timestamp`; use `.date` or `.ns`. | | `expiresAt`, `expiresAtNs` | Optional `expiresAt: Timestamp`. `insertedAt` is also available. | | `kind`, `deliveryStatus` numeric enums | String union values. | | `contentType` always present | Optional `contentType`. A malformed envelope can have no type. | | `content: ContentTypes \| undefined` | `MessageContent`, with `kind` and variant fields. Custom content has optional `value` and `error`. Unknown content has typed `error`. | | `numReplies` | `replyCount: bigint`. | | `reactions: DecodedMessage[]` | `ReactionMessage[]`, with IDs, sender IDs, `Timestamp`, and `reaction`. | | Reply `content.referenceId`, `content.content`, `content.inReplyTo` | Reply content has `referenceId` and `body`. The message has optional `inReplyTo`, `inReplyToContent`, and `replyContent`. `parent()` fetches the full parent. | | No equivalent | Exact `rawBytes`, optional `encoded`, `topic`, and `deliveryCursor`. | Unknown or failed content does not stop delivery of later messages. Preserve `rawBytes`. Do not replace absent encoded content or a missing type with invented empty values. `CodecNotFound`, `CodecDecodeFailed`, and `MalformedEnvelope` are separate failure codes. New reply and reaction actions:
```ts
export async function actOnMessage(message: Message) {
const replyId = await message.reply("Received");
const reactionId = await message.react({
action: "added",
schema: "unicode",
content: "👍",
});
const parent = await message.parent();
const conversation = await message.conversation();
return { replyId, reactionId, parent, conversation };
}
export async function deleteMessage(message: Message) {
return message.delete();
}
```
`message.delete()` publishes a typed deletion action and returns its message ID. `message.deleteLocally()` removes only the local record. `DeleteMessageCodec` remains available in the pure entry for a typed delete content value. The old deleted-message stream is replaced by client events. ## Replace streams and save delivery progress Use `MessageStream` for durable message delivery. Use `ConversationStream` for joined or created conversations. Both implement asynchronous iteration. They open without an awaited static factory call. Client events use `await client.events(filter)`. Old API on main: Before (main cc878025da)
```text
import type { Client } from "@xmtp/browser-sdk";
async function receive(client: Client) {
const stream = await client.conversations.streamAllMessages();
try {
for await (const message of stream) console.log(message.id);
} finally {
await stream.end();
}
}
```
New durable delivery:
```ts
export async function receiveMessages(
client: Client,
handle: (message: Message) => Promise,
signal: AbortSignal,
) {
const stream = client.conversations.streamAllMessages({
consentStates: ["allowed", "unknown"],
signal,
});
try {
for await (const message of stream) {
await handle(message);
}
} finally {
await stream.end();
}
}
```
| Old stream call | Self-hosted call | | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | | `conversation.stream()` | `group.streamMessages()` or `dm.streamMessages()`. | | `conversations.streamAllMessages()` | `conversations.streamAllMessages(options?)`. | | `streamAllGroupMessages()` | `conversations.streamAllMessages({ conversationKind: "group" })`. | | `streamAllDmMessages()` | `conversations.streamAllMessages({ conversationKind: "dm" })`. | | `conversations.stream()` | `conversations.stream(options?)`. | | `streamGroups()`, `streamDms()` | `conversations.stream({ conversationKind: "group" })` or `{ conversationKind: "dm" }`. | | `streamMessageDeletions()`, `streamDeletedMessages()` | `client.events({ kinds: ["message.deleted"] })`. Read the typed event payload. | | `preferences.streamConsent()` | `client.events({ kinds: ["consent.changed"] })`. | | `preferences.streamPreferences()` | Client events for the required changes, such as `hmac_keys.updated`. There is no `UserPreferenceUpdate[]` stream. | | `AsyncStreamProxy.next()`, `return()`, `end()` | Use `for await` and `await stream.end()`. `return()` also closes. | | `StreamOptions.onValue` | `stream.onValue(async message => { ... })` as a separate consumer mode. Do not also iterate that stream. | | `onEnd`, `onError`, `onFail`, `onRestart`, `onRetry` | `onClose(reason)` and `onConnectionStateChange(previous, current)`. A failed close includes the error. | | `retryAttempts`, `retryDelay`, `retryOnFail`, `disableSync` | Removed from the generated stream options. Core handles retryable message-stream network faults. Call `sync()` explicitly when a snapshot needs a sync. | | No equivalent | `signal: AbortSignal` for cancellation; `from: DeliveryCursor` for replay. | The all-conversation message reader defaults to allowed and unknown consent. An explicit consent list replaces that default. An empty list selects no messages. A named group or DM reader has no consent filter. The selection remains fixed for the life of a reader. One default message reader owns delivery progress for a database. Another default reader fails with `ConsumerOwned`, even when it selects a different group. A reader with `from` is an independent replay/live reader. It does not change default delivery progress. The next iterator request acknowledges the previous message. Await app work before the next request. A successful `onValue` callback return acknowledges the delivered message. Loop exit, cancellation, `return()`, and `end()` do not acknowledge the last yielded message. Save a cursor only after successful app work. Deduplicate repeat work by message ID when needed. Use `messageHistorySnapshot(limit, selection)` when a history query and live delivery need one boundary. The example reads up to 100 retained messages, then starts replay strictly after the snapshot cursor. Choose the history limit for the app. This is a local retained-history snapshot; it is not a request for all historical backend messages.
```ts
export async function receiveHistoryAndLive(
client: Client,
handle: (message: Message) => Promise,
saveCursor: (cursor: DeliveryCursor) => Promise,
signal: AbortSignal,
) {
const selection = { consentStates: ["allowed" as const] };
const snapshot = await client.conversations.messageHistorySnapshot(
100,
selection,
);
for (const message of snapshot.messages) await handle(message);
await saveCursor(snapshot.cursor);
const stream = client.conversations.streamAllMessages({
...selection,
from: snapshot.cursor,
signal,
});
try {
for await (const message of stream) {
await handle(message);
if (message.deliveryCursor !== null) {
await saveCursor(message.deliveryCursor);
}
}
} finally {
await stream.end();
}
}
```
Keep a cursor unchanged. A cursor belongs to one database. Treat `InvalidCursor`, `ForeignCursor`, and `ConsumerOwned` as separate errors. Use `beginningDeliveryCursor()` when replay must start at the first retained delivery. New conversation stream:
```ts
export async function receiveConversations(
client: Client,
handle: (conversation: Conversation) => Promise,
signal: AbortSignal,
) {
const stream = client.conversations.stream({
conversationKind: "group",
signal,
});
try {
for await (const conversation of stream) await handle(conversation);
} finally {
await stream.end();
}
}
```
## Move consent and identity calls Old consent records used `entityType`, `entity`, and numeric enum values. New consent records use `{ entity: { kind, ... }, state }`. A consent entity is an inbox or a conversation. Old API on main: Before (main cc878025da)
```text
import {
ConsentEntityType,
ConsentState,
type Client,
} from "@xmtp/browser-sdk";
async function allow(client: Client, conversationId: string) {
await client.preferences.setConsentStates([
{
entityType: ConsentEntityType.ConversationId,
entity: conversationId,
state: ConsentState.Allowed,
},
]);
return client.preferences.getConsentState(
ConsentEntityType.ConversationId,
conversationId,
);
}
```
New consent and events:
```ts
export async function allowConversation(
client: Client,
conversationId: string,
) {
const entity = { kind: "conversation" as const, conversationId };
await client.preferences.setConsentStates([{ entity, state: "allowed" }]);
return client.preferences.consentState(entity);
}
export async function consentEvents(client: Client) {
return client.events({
kinds: ["consent.changed"],
references_own_messages: false,
});
}
```
| Old client or preference member | Self-hosted member | | ----------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `preferences.setConsentStates(records)` | Same names; use new records. | | `preferences.getConsentState(type, entity)` | `preferences.consentState(entity)`. | | `preferences.inboxState()`, `fetchInboxState()` | `client.inboxState(false)` or `client.inboxState(true)`. The boolean selects network refresh. | | `preferences.getInboxStates(ids)`, `fetchInboxStates(ids)` | `client.inboxStates(ids, false)` or `client.inboxStates(ids, true)`. | | Static `Client.fetchInboxStates(ids, backendOrEnv?, gatewayHost?)` | `Client.inboxStates(ids, backend)`. | | `fetchInboxIdByIdentifier(identifier)` | `client.inboxIdFor(identity)`. | | `getInboxIdForIdentifier(backend, identifier)` | `Client.inboxIdFor(identity, backend)`. It resolves an association or derives the nonce-zero inbox ID when none is registered. Use `canMessage` to check registration. | | `syncAllDeviceSyncGroups()` | `GroupSyncSummary` now has `eligible` and `synced` fields instead of `numEligible` and `numSynced`. | | `canMessage(identifiers)`, static `Client.canMessage(...)` | Same method name with `PublicIdentity[]`. The static form requires a backend. Keys include `ethereum:`, `passkey:`, or `handle:`. | | `fetchLatestInboxUpdatesCount(ids)` | `client.latestInboxUpdatesCount(ids, refreshFromNetwork)`. Pass the required boolean to select a network refresh. | | Static `Client.fetchLatestInboxUpdatesCount(...)` | Root `latestInboxUpdatesCount(ids, backend)`. | | `fetchOwnInboxUpdatesCount()` | `client.ownInboxUpdatesCount(refreshFromNetwork)`. Pass the required boolean. Counts now use `bigint`. | | `fetchKeyPackageStatuses(ids)` | `client.keyPackageStatuses(ids)`. The static `Client.keyPackageStatuses(ids, backend)` is also available. | | `accountIdentifier` | `identity: PublicIdentity`. | | `inboxId`, `installationId`, `installationIdBytes`, `libxmtpVersion`, `appVersion`, `conversations`, `preferences`, `options` | Readonly properties. `appVersion` can be absent. `options` omits credential secrets. | | `signer`, `isReady`, `env` | Removed. Retain the app signer when it is needed. Await client creation/build before use. | | Public constructor, `init(identifier)` | Removed. Use `Client.create` or `Client.build`. | | `close()` | `await client.end()`. | | Instance `verifySignedWithPublicKey(text, signature, publicKey)` | Static `Client.verifySignedWithPublicKey(text, signature, publicKey)`. | Handles are new. An inbox can claim a public name such as `nick#2341`, and a `PublicIdentity` of kind `handle` finds that inbox. Static `Client.inboxIdFor` never derives an inbox ID from a handle: it fails with `IdentityNotFound` for a handle that no inbox claimed. See [Handles](/sdk/inboxes/#handles). New identity queries and signature request:
```ts
export async function identityQueries(
client: Client,
identity: PublicIdentity,
backend: BackendSource,
) {
const inboxId = await Client.inboxIdFor(identity, backend);
const canMessage = await Client.canMessage([identity], backend);
const ownState = await client.inboxState(true);
const states = await Client.inboxStates([inboxId], backend);
return { inboxId, canMessage, ownState, states };
}
export async function signAccountRequest(
client: Client,
identity: PublicIdentity,
signer: Signer,
) {
const request = await client.unsafeRemoveAccountSignatureRequest(identity);
const text = await request.signatureText();
await request.sign(signer);
await client.unsafeApplySignatureRequest(request);
return text;
}
```
The new signature workflow returns `SignatureRequest` objects. It does not return a text/request-ID pair. Read `signatureText()`, call `sign(signer)` or `addSignature(signature)`, then apply the request. An optional request can be `undefined` when no update is needed. | Old account method | Self-hosted method | | ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------- | | `unsafe_createInboxSignatureText()` | `unsafeCreateInboxSignatureRequest()`. | | `unsafe_addAccountSignatureText(identity, allowInboxReassign)` | `unsafeAddAccountSignatureRequest(identity, allowInboxReassign)`. | | `unsafe_removeAccountSignatureText(identity)` | `unsafeRemoveAccountSignatureRequest(identity)`. | | `unsafe_revokeInstallationsSignatureText(byteIds)` | `unsafeRevokeInstallationsSignatureRequest(stringIds)`. | | `unsafe_revokeAllOtherInstallationsSignatureText()` | `unsafeRevokeAllOtherInstallationsSignatureRequest()`. | | `unsafe_changeRecoveryIdentifierSignatureText(identity)` | `unsafeChangeRecoveryIdentifierSignatureRequest(identity)`. | | `unsafe_applySignatureRequest(safeSigner, requestId)` | `unsafeApplySignatureRequest(request)`, after signing the request. | | `unsafe_addAccount(signer, allowInboxReassign)` | `unsafeAddAccount(signer, allowInboxReassign)`. | | `removeAccount(identity)` | `removeAccount(recoverySigner, identity)`. | | `revokeInstallations(byteIds)` | `revokeInstallations(signer, stringIds)`. | | `revokeAllOtherInstallations()` | `revokeAllOtherInstallations(signer)`. | | `changeRecoveryIdentifier(identity)` | `changeRecoveryIdentifier(signer, identity)`. | | Static `Client.revokeInstallations(signer, inboxId, byteIds, backendOrEnv?, gatewayHost?)` | Static `Client.revokeInstallations(signer, inboxId, stringIds, backend)`. | `InstallationId` is a validated hex string. Keep `installationIdBytes` only when an operation explicitly needs bytes. `InboxState.identities` and its recovery identity use `PublicIdentity`. Installation timestamps use `Timestamp`, and sequence/count values use `bigint`. ## Move archives and diagnostics Old API on main: Before (main cc878025da)
```text
import type { Client } from "@xmtp/browser-sdk";
async function backup(client: Client, key: Uint8Array) {
const bytes = await client.createArchive(key);
const metadata = await client.archiveMetadata(bytes, key);
await client.importArchive(bytes, key);
return metadata;
}
```
New archive export and import:
```ts
export async function exportArchive(client: Client, key: Uint8Array) {
return client.archives.exportToBytes(key, {
elements: ["messages", "consent"],
excludeDisappearingMessages: true,
});
}
export async function importArchive(
client: Client,
data: Uint8Array,
key: Uint8Array,
) {
const metadata = await client.archives.metadataFromBytes(data, key);
await client.archives.importFromBytes(data, key);
return metadata;
}
```
| Old member | Self-hosted member or action | | ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `client.createArchive(key, options)` | `client.archives.exportToBytes(key, options)`. Pass `undefined` for the required options argument when using defaults. | | `client.importArchive(bytes, key)` | `client.archives.importFromBytes(bytes, key)`. | | `client.archiveMetadata(bytes, key)` | `client.archives.metadataFromBytes(bytes, key)`. | | `ArchiveOptions.startNs`, `endNs` | `start`, `end`, with `Timestamp` values. | | `BackupElementSelectionOption` | `elements: ["messages", "consent"]` or a chosen subset. | | `ArchiveMetadata.exportedAtNs`, `startNs`, `endNs` | `exportedAt`, `start`, `end`, with `Timestamp` values. | | `sendSyncRequest`, `sendSyncArchive`, `processSyncArchive`, `listAvailableArchives` | Removed. There is no replacement API for the old archive-server and PIN workflow. Keep ordinary `deviceSync` and `syncAllDeviceSyncGroups` separate from archive import/export. | | `debugInformation.apiStatistics()` | `diagnostics.apiStatistics()`. | | `debugInformation.apiIdentityStatistics()` | `diagnostics.identityStatistics()`. | | `debugInformation.apiAggregateStatistics()` | `diagnostics.aggregateStatistics()`. | | `debugInformation.clearAllStatistics()` | `diagnostics.clearStatistics()`. | Browser archive APIs use bytes. Native filesystem archive methods are excluded. Use a 32-byte archive encryption key. Store the key separately from the archive. Archive options default to messages and consent when `elements` is absent. ## Update result records and enum imports The new package exports string unions for enums. Use values such as `"allowed"`, `"descending"`, `"published"`, `"application"`, `"admin"`, and `"superAdmin"`. Do not keep numeric enum values from the old bindings. | Old public type or result | Self-hosted type or result | | ------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------- | | `ConversationType`, `GroupMessageKind` | `ConversationKind` and `MessageKind`. | | `SortDirection`, `ListConversationsOrderBy` | `MessageOrder` and `ConversationOrder`. | | `GroupMembershipState`, `GroupPermissionsOptions` | `MembershipState` and `GroupPermissionMode`. State permissions use `GroupPolicyType`. | | `Identifier`, `IdentifierKind` | `PublicIdentity`, `PublicIdentityKind`. | | `GroupMember` | `Member`; account identities are in `identities`. | | `MessageDisappearingSettings` | `DisappearingSettings`. | | `SendOpts`, `SendMessageOpts` | `SendOptions`. | | `Consent`, `ConsentEntityType` | `ConsentRecord` and tagged `ConsentEntity`. | | `PermissionUpdateType`, `MetadataField` | `PermissionUpdateKind`, `MetadataFieldKind`. | | `BackupElementSelectionOption`, `Lifetime` | `ArchiveElement`, `KeyPackageLifetime`. | | `InboxState.accountIdentifiers`, `recoveryIdentifier` | `identities`, `recoveryIdentity`. `creationSignatureKind` is also available. | | `Installation.bytes`, `clientTimestampNs` | `Installation.id` identifies the installation; optional `createdAt` is a `Timestamp`. There is no installation `bytes` field. | | `GroupSyncSummary.numEligible`, `numSynced` | `eligible`, `synced`, both `bigint`. | | `GroupUpdated` inbox arrays of `{ inboxId }` | Inbox-ID string arrays with the same field names. | | `ConversationDebugInfo.cursor` array of `{ originatorId, sequenceId }` | Array of `bigint` cursor values. | | `ApiStats` per-operation counters | `publish`, `query`, `queryNewest`, `subscribe`, and `subscribeStatic`, all `bigint`. | | `IdentityStats.publishIdentityUpdate`, `getInboxIds`, `verifySmartContractWalletSignature` | `getInboxIds` and `verifySmartContractWalletSignatures`, both `bigint`. The old publish counter is absent. | | `LogOptions` | `LoggingOptions`, passed to `initLogging()`. | | `WorkerConfigOptions`, `WorkerIntervalOverride` | `WorkerOptions`, with `WorkerInterval` records. Each record can set `intervalNs`, `jitterNs`, and `enabled`. | | `Intent.metadata` object | `metadataJson?: string`; serialize and validate the JSON explicitly. | | `Actions.expiresAtNs`, `Action.expiresAtNs` | Optional `expiresAt: Timestamp`. | | `WalletCall.metadata` object | `WalletCallMetadata` with `description`, `transactionType`, and `extra: Map`. `WalletSendCalls.capabilities` is also a `Map`. | | `DeletedMessage.deletedBy` enum and separate `adminInboxId` | Tagged `deletedBy`: `{ kind: "sender" }` or `{ kind: "admin", inboxId }`. | `ActionStyle`, `ConsentState`, `DeliveryStatus`, `PermissionLevel`, `PermissionPolicy`, `ReactionAction`, `ReactionSchema`, `LogLevel`, and `WorkerKind` remain type names with string values. The new worker kinds include configuration refresh, HMAC epochs, and attachment cleanup. Check the new type when a switch must cover every variant. ## Replace codec and helper exports The old `encode*` and `contentType*` root helpers initialized WASM and returned promises. Use standalone standard codecs in `/pure`. Call `await initPureWasm()` before constructing or using a standalone standard codec. Encoding and decoding are synchronous after initialization. | Old export family | Self-hosted form | | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `encodeText`, `contentTypeText` | `TextCodec.encode(value)` and `TextCodec.type`. | | `encodeMarkdown`, `contentTypeMarkdown` | `MarkdownCodec`. | | `encodeReadReceipt`, `contentTypeReadReceipt` | `ReadReceiptCodec`. | | `encodeReaction`, `contentTypeReaction` | `ReactionV2Codec`; its typed value carries the target ID and the reaction. Legacy reaction v1 needs an app codec. | | `encodeAttachment`, `contentTypeAttachment` | `AttachmentCodec`. | | `encodeRemoteAttachment`, `contentTypeRemoteAttachment` | `RemoteAttachmentCodec`. | | `encodeMultiRemoteAttachment`, `contentTypeMultiRemoteAttachment` | `MultiRemoteAttachmentCodec`. | | `encodeTransactionReference`, `contentTypeTransactionReference` | `TransactionReferenceCodec`. | | `encodeWalletSendCalls`, `contentTypeWalletSendCalls` | `WalletSendCallsCodec`. | | `encodeActions`, `contentTypeActions` | `ActionsCodec`. | | `encodeIntent`, `contentTypeIntent` | `IntentCodec`. | | `contentTypeReply`, `contentTypeGroupUpdated`, `contentTypeLeaveRequest` | `ReplyCodec`, `GroupUpdatedCodec`, and `LeaveRequestCodec`. | | `encryptAttachment`, `decryptAttachment` | Public `encryptEncodedContent` and `decryptEncodedContent` for encrypted content, or `client.attachments` for managed transfers. The old attachment result shape does not apply. | | `generateInboxId(identifier, nonce?)` | Pure `generateInboxId(identity, nonce?)`, after `initPureWasm()`. Nonces remain `bigint`; the default changes from `1n` to `0n`. Pass `1n` explicitly to preserve the old derivation. | | `metadataFieldName`, `MetadataField` | `MetadataFieldKind` for permission updates; metadata descriptors and field APIs for app data. | | `SafeConversation`, `toSafeConversation` | Removed. Public `Group` and `Dm` are the supported objects. | | `HmacKeys`, `LastReadTimes` | Use the method result types. HMAC keys are maps/lists of `HmacKey`; read-time values are `Timestamp`. | | `isText`, `isMarkdown`, `isReply`, `isTextReply`, `isReaction`, `isAttachment`, `isRemoteAttachment`, `isMultiRemoteAttachment`, `isTransactionReference`, `isGroupUpdated`, `isReadReceipt`, `isLeaveRequest`, `isWalletSendCalls`, `isIntent`, `isActions` | Narrow `message.content.kind`, then read that variant. | | `ApiUrls`, `HistorySyncUrls`, `XmtpEnv`, `NetworkOptions`, `DeviceSyncOptions`, `OtherOptions`, `ContentOptions` | Removed legacy configuration exports. Use `BackendOptions` and `ClientOptions`. | | `EOASigner`, `SCWSigner`, `SafeSigner`, `BuiltInContentTypes`, `ExtractCodecContentTypes`, `EnrichedReply` | Removed. Use `Signer`, `PublicIdentity`, `MessageContent`, and `MessageBody`. | Replace the old codec `contentType` property with `type`. A custom codec now exposes `type`, synchronous `encode`, synchronous `decode`, and optional `fallback`/`shouldPush` hooks. Import `ContentCodec`, `EncodedContent`, and `ContentTypeId` from the Browser SDK root. `EncodedContent.type` is required. `parameters` changes from `Record` to `Map`. The old envelope `compression` field is removed; use `SendOptions.compression`. Register codecs in `ClientOptions.codecs`. Use the typed `conversation.send(codec, value)` overload to send a custom value. Old API on main: Before (main cc878025da)
```text
import type { ContentCodec } from "@xmtp/content-type-primitives";
import type { EncodedContent } from "@xmtp/wasm-bindings";
import type { Conversation } from "@xmtp/browser-sdk";
const noteCodec: ContentCodec = {
contentType: {
authorityId: "example.com",
typeId: "note",
versionMajor: 1,
versionMinor: 0,
},
encode(value: string): EncodedContent {
return {
type: this.contentType,
parameters: {},
content: new TextEncoder().encode(value),
};
},
decode(encoded: EncodedContent) {
return new TextDecoder().decode(encoded.content);
},
fallback: (value) => value,
shouldPush: () => true,
};
async function sendNote(conversation: Conversation) {
return conversation.send(noteCodec.encode("A typed note"));
}
```
New API:
```ts
export const noteCodec: ContentCodec = {
type: {
authorityId: "example.com",
typeId: "note",
versionMajor: 1,
versionMinor: 0,
},
encode: (value): EncodedContent => ({
type: noteCodec.type,
parameters: new Map(),
content: new TextEncoder().encode(value),
}),
decode: (encoded) => new TextDecoder().decode(encoded.content),
fallback: (value) => value,
shouldPush: () => true,
};
export async function createWithCodec(signer: Signer) {
return Client.create(signer, {
backend: { url: "https://xmtp.example.com" },
storage: { location: "default" },
codecs: [noteCodec],
});
}
export async function sendCustom(conversation: Conversation) {
return conversation.send(noteCodec, "A typed note");
}
```
Catalogue content types have defined push defaults. Read receipts, reactions, group updates, deletion, and leave requests default to no push. An explicit `SendOptions.shouldPush` wins. A custom codec’s hook does not replace the default for a catalogue type. An encrypted-content helper result uses `{ ciphertext, keys }`. The keys record has `secret`, `salt`, `nonce`, `digest`, and `length`. `encryptEncodedContent` accepts serialized envelope bytes. `decryptEncodedContent` returns serialized envelope bytes. These are not the old attachment helper records. The new attachment API adds `client.attachments` for upload, download, status, and deletion. Browser path sources refer to OPFS. Byte sources copy the caller’s bytes at the Browser boundary. Transfer failures preserve a typed attachment cause; the SDK does not add automatic transfer retries. The new API also adds backend configuration reads, credentials, catch-up state, metadata field descriptors and values, user data, client events, and message actions. These have no direct old Browser method to rename. ## Handle typed errors The old exported error classes (`ClientNotInitializedError`, `SignerUnavailableError`, `InboxReassignError`, `AccountAlreadyAssociatedError`, `GroupNotFoundError`, `StreamNotFoundError`, `StreamFailedError`, `StreamInvalidRetryAttemptsError`, `OpfsNotInitializedError`, and `OpfsInitializationError`) are removed. Catch the new `XmtpError` base or a public typed subclass. Read `details.code`, `category`, `retryable`, and `message`. Do not classify a failure by display text. A database written in an older, unsupported format is an `XmtpError.Storage` whose code is `StorageError::PreTransitionDatabase`, `StorageError::OldStreamDatabase` or `StorageError::OldEncodingDatabase`.
```ts
export function describeError(error: unknown): string {
if (error instanceof XmtpError) {
const { code, retryable, message } = error.details;
return `${code}: ${message}; retryable=${retryable}`;
}
return error instanceof Error ? error.message : String(error);
}
```
Keep a default case for codes added later. An unknown failure must remain a failure. Message and attachment actions fail with `ClientClosed` after the owning client ends.
# Kotlin SDK Upgrade Guide
> Update Kotlin SDK calls, options, and results for a self-hosted backend.
Start with [Migrate to a self-hosted backend](/get-started/migrate-to-self-hosted/) for the backend, database, history, and push steps. This guide lists changes from [`main` at `cc878025da`](https://github.com/xmtp/libxmtp/tree/cc878025dab2f93bca18b67fa9fca562a65f2247) to [`self-hosted` at `610175f3b3`](https://github.com/xmtp/libxmtp/tree/610175f3b3108e7d8c6b8b01df6d99296a6e08b2). 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. ## Install the recommended version Use Android SDK **8.0.0-rc3** with backend **8.0.0-rc3**. Add the exact SDK version to your app module:
```kotlin
dependencies {
implementation("org.xmtp:android:8.0.0-rc3")
}
```
See [Android installation requirements](/get-started/install/#android-package). ## Package, imports, and values Use the Android SDK version `8.0.0-rc3`. 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`.
```kotlin
// 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
```
```kotlin
// 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 value | New type or value | | -------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Client` | `SDKClient` | | `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` properties | `Timestamp(ns: Long)`; use `.ns` for nanoseconds or `.date` for `Instant` | | `DecodedMessage`, `DecodedMessageV2` | `Message`; queries include reactions and reply metadata in this type | | Protobuf `Content.ContentTypeId`, `Content.EncodedContent`, `ByteString` | SDK `ContentTypeId`, SDK `EncodedContent`, and `ByteArray` | | `XMTPException(message, cause)` and `FfiException` | Typed `XmtpException` variants; retain the `ErrorDetails` code, category, and retry flag. A database written in an older, unsupported format is an `XmtpException.Storage` whose code is `StorageError::PreTransitionDatabase`, `StorageError::OldStreamDatabase` or `StorageError::OldEncodingDatabase` | | `GroupMembershipState` | `MembershipState`; the allowed/rejected/pending/restored/pending-remove values remain | | `Conversation.Type`, `Conversations.ConversationFilterType` | `ConversationKind`; 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`. ## Client creation, storage, and signing `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 option | New option | | -------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `api.env`, `api.gatewayHost`, `api.isSecure` | `backend = BackendSource.Options(BackendOptions(url = ...))`; the URL selects the deployment and scheme | | `api.appVersion` | `BackendOptions.appVersion` | | `appContext` | A separate `Context` argument to Android `SDKClient.create` or `SDKClient.build` | | `dbDirectory` | `storage.location = StorageLocation.Directory(directory)` | | Default Android database directory | `StorageLocation.Default` with the Android `Context` overload; it resolves under `filesDir/xmtp_db` | | `dbEncryptionKey` | `StorageOptions.encryptionKey`; it is optional and must be 32 bytes when supplied | | `dbPoolOptions.minPoolSize`, `.maxPoolSize` | `StorageOptions.pool = StoragePoolOptions(min = ..., max = ...)`; keep `UInt?` values | | `deviceSyncEnabled` | `deviceSync` | | `forkRecoveryOptions` | `forkRecovery`; `enableRecoveryRequests` becomes `policy`, `groupsToRequestRecovery` becomes `groups`, and nullable `disableRecoveryResponses` becomes `disableResponses: Boolean = false` | | `preAuthenticateToInboxCallback` | `handlers = ClientHandlers(preAuthenticate = ...)`; implement the suspend `PreAuthenticate.run()` callback | | `waitForRegistrationVisible` and `VisibilityConfirmationOptions` | Removed; no quorum or visibility timeout option | | `unstableChangeCallbacks` | Client 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 supplied | `SDKClient.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:
```kotlin
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:
```kotlin
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:
```kotlin
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:
```kotlin
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. ## Identity and account methods | Old call | New call and return shape | | -------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `client.publicIdentity`, `.libXMTPVersion`, `.isInMemory`, `.dbPath` | `identity(): PublicIdentity`, `libxmtpVersion(): String`, `isInMemory(): Boolean`, `storagePath(): String?`; in-memory storage has no path | | `client.environment` | Removed; 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`; parameter order changes | | Static `Client.inboxStatesForInboxIds(inboxIds, api)` | `SDKClient.inboxStates(ids, backend): List` | | `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` keyed by `ethereum:`, `passkey:`, or `handle:` | | `Client.getNewestMessageMetadata(groupIds, api)` | `SDKClient.newestMessageMetadata(ids, backend): Map`; records have `sequenceId: ULong` and `createdAt: Timestamp` | | `Client.keyPackageStatusesForInstallationIds(installationIds, api)` | `SDKClient.keyPackageStatuses(ids, backend): Map`; 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): 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](/sdk/inboxes/#handles). Before:
```kotlin
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:
```kotlin
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 method | New 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)` | `unsafeRevokeInstallationsSignatureRequest(ids: List)` | | `ffiRevokeAllOtherInstallations()` | `unsafeRevokeAllOtherInstallationsSignatureRequest(): SignatureRequest?` | | Instance `ffiApplySignatureRequest(request)` and `ffiRegisterIdentity(request, visibilityOptions)` | `unsafeApplySignatureRequest(request)`; registration visibility options are removed | | Static `ffiRevokeInstallations` and `ffiApplySignatureRequest` | Removed; 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 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()` or `emptyList()` so Kotlin can select the overload. | Old call | New call and return shape | | --------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | | `newGroup(inboxIds, permissions, groupName, groupImageUrlSquare, groupDescription, disappearingMessageSettings, appData)` | `createGroup(members: List, options: CreateGroupOptions?): Group` | | `newGroupWithIdentities(identities, ...)` | `createGroup(members: List, 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`, `List`, and `List` | | `deleteMessageLocally(messageId)` | Retained as `deleteMessageLocally(id)` | | `getHmacKeys(): Keystore.GetConversationHmacKeysResponse` | `hmacKeys(): Map>` | | `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:
```kotlin
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:
```kotlin
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 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 access | New access | | ------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `.id`, `.topic`, `.createdAt`, `.createdAtNs` | `id()`, `topic()`, `createdAt(): Timestamp`; use `.ns` or `.date` | | `.lastActivityNs` | Suspend `lastActivityAt(contentTypes: List?): 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 `.peerInboxId` | Suspend `peerInboxId(): InboxId?` | | `addedByInboxId(): InboxId`, `creatorInboxId(): InboxId` | `addedByInboxId(): 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`, `updateRemoveAdminPermission` | `updatePermission(kind, policy, metadataField)`; pass `null` when no metadata field applies with the corresponding `PermissionUpdateKind` | | `updateNamePermission`, `updateDescriptionPermission`, `updateImageUrlPermission` | `updatePermission(PermissionUpdateKind.UPDATE_METADATA, policy, MetadataFieldKind.NAME / DESCRIPTION / IMAGE_URL)` | | `leaveGroup()` | `requestRemoval(): Unit` | | `membershipCapabilities()` | Retained with the new SDK capability records. The `ImmutableMetadata` extension type is removed; that extension type now arrives as `MlsExtensionType.Unknown` with `id` 61440 (`0xf000`) | | `proposalsEnabled()`, `unstable.enableProposals(force, minVersion)` | Removed; no proposal migration switch | | `getDebugInformation()` | `debugInfo(): ConversationDebugInfo`; `epoch` is `ULong`, `cursor` is `List`, and `.isCommitLogForked: Boolean?` replaces the debug record’s `.commitLogForkStatus` | | `getLastReadTimes(): Map` | `lastReadTimes(): Map` | | Per-conversation `getHmacKeys()` | `hmacKeys(): List`; each item has `key: ByteArray` and `epoch: Long` | | `getPushTopics()` | Removed; use client notification registration and per-conversation `setNotifications(value: NotificationOverride)` | Before:
```kotlin
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:
```kotlin
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`, `metadataField(name: String): MetadataFieldDescriptor?`, `metadataValue(field: MetadataFieldRef): MetadataValue?`, `metadataValues(fields: List): List`, `mapValue(field, key: FieldKey): FieldValue?`, `userData(fields, inboxIds): Map>`, `updateUserData(values: List): 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. ## Send and query messages 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 call | New call and return shape | | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | | `send(text: String)` | `sendText(text, options: SendOptions? = null): MessageId` | | `send(content, SendOptions(contentType = ...))` | `send(codec: ContentCodec, 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`; 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 parameter | `ListMessagesOptions` field | | -------------------------------------------------- | --------------------------------------------------------------------------- | | `limit: Int?` | `limit: UInt?` | | `beforeNs`, `afterNs` | `sentBefore`, `sentAfter`, both `Timestamp?` | | `insertedBeforeNs`, `insertedAfterNs` | `insertedBefore`, `insertedAfter`, both `Timestamp?` | | `direction = SortDirection.ASCENDING / DESCENDING` | `direction = MessageOrder.ASCENDING / DESCENDING` | | `sortBy = SortBy.SENT_TIME / INSERTED_TIME` | `sortBy = MessageSortBy.SENT_AT / INSERTED_AT` | | `MessageDeliveryStatus.ALL` | Omit `deliveryStatus`; there is no `ALL` enum value | | Other `MessageDeliveryStatus` values | `DeliveryStatus.PUBLISHED / UNPUBLISHED / FAILED` | | `excludeContentTypes: List?` | `excludeContentTypes: List?`; use actual content type IDs | | No old equivalent | `kind` and `contentTypes` select a message kind or include content type IDs | Before:
```kotlin
import org.xmtp.android.library.Group
import org.xmtp.android.library.libxmtp.DecodedMessage
suspend fun oldMessages(group: Group): List {
group.send("Hello")
val pending = group.prepareMessage("Draft")
group.publishMessage(pending)
return group.enrichedMessages(limit = 20).map { it.content() ?: it.fallbackText.orEmpty() }
}
```
After:
```kotlin
import uniffi.xmtp_sdk.*
suspend fun messages(group: Group): List {
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()` 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 method | New 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`; the nested `.reaction` has content, action, and schema | | No old equivalent | `.replyCount: ULong`, `.inReplyTo: ReplyParent?` | | V2 `.hasReactions`, `.reactionCount`, `.fallbackText`, `.contentTypeId` | Use `.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:
```kotlin
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:
```kotlin
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`. ## Codecs, records, and attachments `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` 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 helper | New shape | | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `ContentTypeIdBuilder`, `ContentType*` protobuf constants, `.id` and `.description` convenience keys | Construct `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()` | 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` singleton | Use `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 objects | Uppercase 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` | `.attachments: List`; no separate `RemoteAttachmentInfo` type | | Protobuf `GroupUpdated` and nested inbox wrappers | SDK `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 payload | `DeleteMessageContent(messageId)`; `DeletedMessage` and `DeletedBy` move to the new package | | `TransactionReference.Metadata` | Top-level `TransactionMetadata`; the transaction record fields remain namespace, network ID, reference, and optional metadata | | `WalletSendCalls = FfiWalletSendCalls` | Public `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`, `.decryptAttachment` | `encryptBytes`, `decryptBytes`, `encryptEncodedContent`, `decryptEncodedContent`, `encryptFile`, and `decryptFile`; use SDK records | | `RemoteAttachment.load()` and its `fetcher` property, `Fetcher`, `HTTPFetcher` | Removed; use `client.attachments().download(remote)` for SDK-managed download and cache | | `Crypto`, `KeyUtil`, `messages.PrivateKey` | Removed; use SDK encryption helpers and a wallet signer or local signer helpers | Before, a custom codec used the global registry and a protobuf envelope:
```kotlin
import com.google.protobuf.ByteString
import org.xmtp.android.library.*
import org.xmtp.android.library.codecs.*
class OldNoteCodec : ContentCodec {
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:
```kotlin
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.withContext
import uniffi.xmtp_sdk.*
class NoteCodec : ContentCodec {
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:
```kotlin
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:
```kotlin
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. ## Streams, replay, and events | Old stream | New 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.manageStreamLifecycle` | `AndroidStreamLifecycle.enabled`; set it before the first Android client is created | The message flows return `Flow`. Conversation flows return `Flow`. 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:
```kotlin
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:
```kotlin
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` 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?`; convert stored hex IDs when selecting groups. Use `referencesOwnMessages` and `contentTypes` only for the event kinds that support those filters. ## Consent and notifications | Old consent method or record | New method or record | | ----------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | | `client.preferences` | `client.preferences(): Preferences` | | `sync()`, `syncConsent()` | `sync(): Unit` | | `setConsentState(entries)` | `setConsentStates(records: List): Unit` | | `ConsentRecord(value, entryType, consentType)` and `.conversationId(...)` / `.inboxId(...)` factories | `ConsentRecord(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:
```kotlin
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:
```kotlin
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:
```kotlin
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:
```kotlin
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 | Old method | New 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`, `archiveElements` | `start: Timestamp?`, `end: Timestamp?`, `elements: List?`; omitted elements select messages and consent | | Metadata `archiveVersion`, `exportedAtNs`, `startNs`, `endNs` | `backupVersion: UShort`, `exportedAt: Timestamp`, `start: Timestamp?`, `end: Timestamp?` | | `sendSyncRequest(opts, serverUrl)`, `sendSyncArchive(opts, serverUrl, pin)`, `processSyncArchive(archivePin)`, `listAvailableArchives(daysCutoff)`, `AvailableArchive` | Removed; 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_PATH` | `storage().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`, `.clearXMTPLogs` | Same 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` adapters | Removed; 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:
```kotlin
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:
```kotlin
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:
```kotlin
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:
```kotlin
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.withContext
import uniffi.xmtp_sdk.*
suspend fun reopen(
client: SDKClient,
savedOptions: ClientOptions,
savedCodecs: List>,
): 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.
# Node SDK Upgrade Guide
> Update Node SDK calls, options, and results for a self-hosted backend.
Start with [Migrate to a self-hosted backend](/get-started/migrate-to-self-hosted/) for the backend, database, history, and push steps. This guide lists changes from [`main` at `cc878025da`](https://github.com/xmtp/libxmtp/tree/cc878025dab2f93bca18b67fa9fca562a65f2247) to [`self-hosted` at `610175f3b3`](https://github.com/xmtp/libxmtp/tree/610175f3b3108e7d8c6b8b01df6d99296a6e08b2). 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. ## Install the recommended version Use SDK **8.0.0-rc3** with backend **8.0.0-rc3**. Run this command from your app directory to save the exact SDK version:
```sh
npm install --save-exact @xmtp/node-sdk@8.0.0-rc3
```
See [Install the XMTP SDK](/get-started/install/) for platform requirements. ## Node package and value types Keep the package name `@xmtp/node-sdk`. SDK 8 requires ESM and Node 22.12 or later. Use `import` instead of `require`. Install the recommended version before checking your app. The old `@xmtp/node-bindings` package is not the public SDK 8 API. The **previous** examples below use [`main` at `cc878025d`](https://github.com/xmtp/libxmtp/tree/cc878025dab2f93bca18b67fa9fca562a65f2247/sdks/js/node-sdk/src). They were checked against that source. The **current** examples use the local SDK 8 declarations and are checked as complete TypeScript programs. | Previous value | Current value | | --------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Identifier` with `identifierKind: IdentifierKind.Ethereum` | `PublicIdentity` with `kind: "ethereum"`. Use `"passkey"` for a passkey. The `identifier` field remains a string. | | Numeric enum imports such as `ConsentState.Allowed`, `DeliveryStatus.Published`, and `SortDirection.Descending` | String values such as `"allowed"`, `"published"`, and `"descending"`. Import the type when you need a type annotation. | | `ConversationType`, `GroupMessageKind`, `SortDirection`, `ListConversationsOrderBy` | Types `ConversationKind`, `MessageKind`, `MessageOrder`, `ConversationOrder`. Values use lower camel case, such as `"dm"`, `"membershipChange"`, and `"lastActivity"`. | | Nanosecond fields ending in `Ns`, with a separate `Date` getter | `Timestamp`. Use `.ns` for a `bigint`, `.date` for a `Date`, and `new Timestamp(ns)` for an input. This applies to message, conversation, identity, archive, and action times. | | Mutable records and binding classes | Read-only public records. IDs remain strings. Byte buffers are `Uint8Array`. Maps are `Map`, not plain JavaScript objects. | | Generic `Client`, `Group`, `Dm`, `Conversation`, and `DecodedMessage` | Classes `Client`, `Group`, `Dm`, and `Message`. `Conversation` is the type `Group \| Dm`. Narrow a conversation with `instanceof Group` or `instanceof Dm`. Narrow received content with `content.kind`. | | Direct `new Client(...)`, `client.init(...)`, or direct conversation/message constructors | Use `Client.create`, `Client.build`, and SDK lookup or creation methods. Public SDK objects have private constructors. | ## Signers and client setup A signer has three asynchronous methods: `identity()`, `kind()`, and `sign(request)`. Replace `type`, `getIdentifier`, and `signMessage`. Previous EOA signer: Before (main cc878025da)
```text
import { IdentifierKind, type Signer } from "@xmtp/node-sdk";
export function createSigner(
address: string,
signText: (text: string) => Promise,
): Signer {
return {
type: "EOA",
getIdentifier: () => ({
identifier: address,
identifierKind: IdentifierKind.Ethereum,
}),
signMessage: signText,
};
}
```
Current EOA signer:
```ts
const signer: Signer = {
identity: async () => ({ identifier: address, kind: "ethereum" }),
kind: async () => ({ kind: "eoa" }),
sign: async ({ text }) => ({ kind: "ecdsa", value: await signText(text) }),
};
```
For a smart contract wallet, replace `getChainId` and `getBlockNumber` with fields in the result of `kind()`. Return the signature bytes, address, chain ID, and optional block number from `sign()`. Chain IDs and block numbers are unsigned 64-bit `bigint` values.
```ts
const signer: Signer = {
identity: async () => ({ identifier: address, kind: "ethereum" }),
kind: async () => ({ kind: "scw", chainId, blockNumber }),
sign: async ({ text }) => ({
kind: "scw",
bytes: await signText(text),
address,
chainId,
blockNumber,
}),
};
```
Passkey signers are also supported. Their identity uses `kind: "passkey"`; `kind()` returns `{ kind: "passkey" }`; `sign()` returns a `"passkey"` signature with `signature`, `publicKey`, `authenticatorData`, and `clientDataJson` byte buffers. `Client.generateKeys()` and `LocalSigningKey.fromBytes(bytes)` are new helpers for local Ethereum signers. Previous client setup: Before (main cc878025da)
```text
import { Client, type Signer } from "@xmtp/node-sdk";
export async function createClient(signer: Signer, key: Uint8Array) {
return Client.create(signer, {
env: "dev",
appVersion: "chat/7.0.0",
dbPath: "./chat.db3",
dbEncryptionKey: key,
minDbPoolSize: 5,
maxDbPoolSize: 25,
disableDeviceSync: false,
});
}
```
Current client setup:
```ts
const client = await Client.create(signer, {
backend: { url: "https://xmtp.example.com", appVersion: "chat/8.0.0" },
storage: {
location: { directory: "./xmtp-data" },
label: "chat",
encryptionKey: key,
pool: { min: 5, max: 25 },
},
deviceSync: true,
registration: { auto: true },
});
```
| Previous option or helper | Current option or helper | | ------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `env`, `apiUrl`, `gatewayHost`, optional network selection | Pass `backend: { url }` or a `Backend`. There is no network selected from an environment name. `backendUrl` is not a Node SDK 8 client option. | | Top-level `appVersion` | `backend.appVersion` in connection options. | | `createBackend(options)`, `BackendBuilder`, `envToString`, `ApiUrls`, `XmtpEnv` | `await Backend.connect({ url, appVersion?, credentials? })`. A connected `Backend` can be shared by clients and standalone queries. Environment helpers are removed. | | Old `Backend.env`, `v3Host`, `gatewayHost`, `appVersion` getters and builder `setApiUrl`, `setGatewayHost`, `setReadonly` | Removed from the public `Backend`. Keep connection options in your app and use `Backend.connect(options)`. | | `dbPath` omitted | `storage: { location: "default" }`. SDK 8 has no implicit `dev` network label. | | `dbPath: null` | `storage: { location: "inMemory" }`. | | `dbPath: "./chat.db3"` | `storage.location: { dbPath: "./chat.db3", attachmentsDir: "./chat-attachments" }`. Both paths are required. | | `dbPath(inboxId)` callback | Choose a directory with `storage.location: { directory }`, or compute explicit paths in your app before creation. No path callback is accepted. | | `dbEncryptionKey` bytes or hex string | `storage.encryptionKey: Uint8Array`. Convert a hex key to bytes in the app. Keep the same 32-byte key when you reopen a database. | | `minDbPoolSize`, `maxDbPoolSize`, `useSingleConnection` | `storage.pool: { min, max }` and `storage.singleConnection`. | | Environment used in database filename | `storage.label`. Keep the label and storage location stable. | | `disableDeviceSync` | `deviceSync: false`. Device sync defaults to `true`. | | `disableAutoRegister` | `registration: { auto: false }`. Automatic registration defaults to `true`. `register()` remains available. | | Top-level `nonce` | `registration.nonce`. It is an unsigned 64-bit `bigint`. New identity generation defaults to nonce `0`, rather than the old `1`. Keep an explicit nonce where the app requires it. | | `historySyncUrl`, `HistorySyncUrls` | Removed. No history server is selected by the client. | | `waitForRegistrationVisible` and `VisibilityConfirmationOptions` | Removed from the public client options. Registration waits for the self-hosted backend’s readable state. | | `WorkerConfigOptions`, `WorkerIntervalOverride`, `WorkerJitterOverride` | `WorkerOptions` and `WorkerInterval`, used by `ClientOptions.workers`. | | `workerConfig.defaultIntervalNs` | `workers.defaultIntervalNs`. | | `workerConfig.workerIntervalsNs`, `workerJittersNs`, `disabledWorkers` | `workers.intervals: [{ kind, intervalNs?, jitterNs?, enabled? }]`. Use `enabled: false` to disable that worker. Worker kinds use strings. New kinds include `configurationRefresh`, `hmacEpoch`, and `attachmentCleanup`. | | `unstableChangeCallbacks.appData`, `AppDataChange` | Observe `"conversation.metadata_changed"` through `client.events()` or `startListener()`. Events report committed changes; they do not await an app merge before message processing continues. | | Top-level logging and OTLP options | Configure logging with `await initLogging(...)`; see the diagnostics section below. | `Client.create(signer, options)` and `Client.build(identity, options, inboxId?)` require an options object. `build` reopens a client without storing a signer. Its optional third argument selects the inbox explicitly. SDK 8 can use the inbox stored in explicit storage. `allowOffline: true` permits startup from stored state when the backend is unavailable. It does not supply the data needed for a first registration.
```ts
const client = await Client.build(identity, {
backend: { url: "https://xmtp.example.com" },
storage: {
location: { dbPath: "./chat.db3", attachmentsDir: "./chat-attachments" },
encryptionKey: key,
},
});
```
The previous database is not an input to this migration. Create a new database for the deployment. See [the database migration rules](/get-started/migrate-to-self-hosted/#the-local-database-must-be-recreated) before choosing paths. Configure backend credentials separately from the identity signer:
```ts
const client = await Client.create(signer, {
backend: {
url: "https://xmtp.example.com",
credentials: {
async credential() {
const credential = await fetchToken();
return {
value: `Bearer ${credential.token}`,
expiresAtSeconds: credential.expiresAtSeconds,
};
},
},
},
storage: { location: "default" },
});
```
`backend.credentials` accepts a `Credential` or a source with asynchronous `credential()`. A credential has `value`, signed 64-bit `expiresAtSeconds` in Unix seconds, and optional header `name`. Use `client.setCredential(credential)` to replace a supplied credential. `handlers.preAuthenticate` accepts a callback with asynchronous `run()` for work that must precede authentication. `forkRecovery` selects a policy (`"none"`, `"allowlistedGroups"`, or `"all"`), group IDs, whether to disable responses, and an optional worker interval. `attachments` can set `maxDownloadBytes`, `maxPendingAgeSeconds`, and `allowPrivateNetwork`. `Client.fetchServerConfiguration(backend)` fetches configuration without a client. `client.serverConfiguration` is a property; `await client.refreshServerConfiguration()` returns refreshed configuration. Deployment limits and retention counts use `bigint`. ## Identity methods and result records Previous standalone calls could omit a backend or pass an environment string. Current calls require a `BackendSource`: `{ url, appVersion?, credentials? }` or a connected `Backend`.
```ts
const backend = await Backend.connect({ url: "https://xmtp.example.com" });
const availability = await Client.canMessage([identity], backend);
const available = availability.get(
`${identity.kind}:${identity.identifier.toLowerCase()}`,
);
const states = await Client.inboxStates([inboxId], backend);
const counts = await latestInboxUpdatesCount([inboxId], backend);
```
| Previous call | Current call and result | | ------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `client.accountIdentifier` | `client.identity: PublicIdentity`. `client.signer` and `client.env` are removed. Keep the signer in your app. | | `client.isRegistered` | `await client.isRegistered(): boolean`. | | `client.preferences.inboxState()` / `fetchInboxState()` | `await client.inboxState(false)` / `client.inboxState(true)`. The refresh flag is required. | | `client.preferences.getInboxStates(ids)` / `fetchInboxStates(ids)` | `await client.inboxStates(ids, false)` / `client.inboxStates(ids, true)`. | | `Client.fetchInboxStates(ids, backendOrEnv?, gatewayHost?)` | `Client.inboxStates(ids, backend): Promise`. | | `client.fetchInboxIdByIdentifier(identifier)` | `client.inboxIdFor(identity): Promise`. | | `getInboxIdForIdentifier(backend, identifier)` | `Client.inboxIdFor(identity, backend): Promise`. This static method calculates a nonce-0 inbox ID when no network association exists. Use `canMessage` to test registration. | | `client.canMessage(identifiers)` / `Client.canMessage(identifiers, backendOrEnv?)` | Same method names, with `PublicIdentity[]` and a required backend for the static method. Both return `Map`. Keys are `ethereum:`, `passkey:`, or `handle:`, rather than an address alone. | | `client.fetchLatestInboxUpdatesCount(ids)` | `client.latestInboxUpdatesCount(ids, refreshFromNetwork): Promise