Core messaging

Messaging implementation guideline


Understanding of Client concept


Create a EOA or SCW signer

XMTP SDKs support message signing with 2 different types of Ethereum accounts: Externally Owned Accounts (EOAs) and Smart Contract Wallets (SCWs). All SDK clients accept a signer object (or instance), which provides a method for signing messages.


Create an Externally Owned Account signer

The EOA signer must have 3 properties: the account type, a function that returns the account identifier, and a function that signs messages.

import type { Signer, Identifier } from '@xmtp/browser-sdk';
 
const accountIdentifier: Identifier = {
  identifier: '0x...', // Ethereum address as the identifier
  identifierKind: 'Ethereum', // Specifies the identity type
};
 
const signer: Signer = {
  type: 'EOA',
  getIdentifier: () => accountIdentifier,
  signMessage: async (message: string): Uint8Array => {
    // typically, signing methods return a hex string
    // this string must be converted to bytes and returned in this function
  },
};

Create a Smart Contract Wallet signer

The SCW signer has the same 3 required properties as the EOA signer, but also requires a function that returns the chain ID of the blockchain being used and an optional function that returns the block number to verify signatures against. If a function is not provided to retrieve the block number, the latest block number will be used.

Here is a list of supported chain IDs:

  • chain_rpc_1 = string

  • chain_rpc_8453 = string

  • chain_rpc_42161 = string

  • chain_rpc_10 = string

  • chain_rpc_137 = string

  • chain_rpc_324 = string

  • chain_rpc_59144 = string

  • chain_rpc_480 = string

Need support for a different chain ID? Please post your request to the XMTP Community Forums.

The details of creating an SCW signer are highly dependent on the wallet provider and the library you're using to interact with it. Here are some general guidelines to consider:

  • Wallet provider integration: Different wallet providers (Safe, Argent, Rainbow, etc.) have different methods for signing messages. See the wallet provider documentation for more details.

  • Library selection: Choose a library that supports your wallet provider (e.g., viem, ethers.js, web3.js). Each library has its own API for interacting with wallets. See the library documentation for more details.

  • Add an Ethereum-specific prefix: Before signing, Ethereum requires a specific prefix to be added to the message. To learn more, see ERC-191: Signed Data Standard. Libraries and wallet providers might add the prefix for you, so make sure you don't add the prefix twice.

  • Hash the prefixed message with Keccak-256: The prefixed message is hashed using the Keccak-256 algorithm, which is Ethereum's standard hashing algorithm. This step creates a fixed-length representation of the message, ensuring consistency and security. Note that some wallet providers might handle this hashing internally.

  • Sign the replay-safe hash: The replay-safe hash is signed using the private key of the SCW. This generates a cryptographic signature that proves ownership of the wallet and ensures the integrity of the message.

  • Convert the signature to a Uint8Array: The resulting signature is converted to a Uint8Array format, which is required by the XMTP SDK for compatibility and further processing.

The code snippets below are examples only and will need to be adapted based on your specific wallet provider and library.

export const createSCWSigner = (
  address: `0x${string}`,
  walletClient: WalletClient,
  chainId: bigint,
): Signer => {
  return {
    type: "SCW",
    getIdentifier: () => ({
      identifier: address.toLowerCase(),
      identifierKind: "Ethereum",
    }),
    signMessage: async (message: string) => {
      const signature = await walletClient.signMessage({
        account: address,
        message,
      });
      return toBytes(signature);
    },
    getChainId: () => {
      return chainId;
    },
  };

Understand creating and building a client

When you call Client.create(), the following steps happen under the hood:

  1. Extracts the signer and retrieves the wallet address from it.

  2. Checks the XMTP identity ledger to find an inbox ID associated with the signer address. The inbox ID serves as the user's identity on the XMTP network.

    1. If it doesn't find an existing inbox ID, it requests a wallet signature to register the identity and create an inbox ID.

    2. If it finds an existing inbox ID, it uses the existing inbox ID.

  3. Checks if a local SQLite database exists. This database contains the identity's installation state and message data.

    1. If it doesn't find an existing local database, it creates one. On non-web platforms, it encrypts the database with the provided dbEncryptionKey.

    2. If it finds an existing local database:

      • For the Node, React Native, Android, and iOS SDKs: It checks if the provided dbEncryptionKey matches. If it matches, it uses the existing database. If not, it creates a new database encrypted with the provided key.

      • For the Browser SDK: A dbEncryptionKey is not used for encryption due to technical limitations in web environments. Be aware that the database is not encrypted.

  4. Returns the XMTP client, ready to send and receive messages.

The dbEncryptionKey client option is used by the Node, React Native, Android, and Swift SDKs only.

The encryption key is critical to the stability and continuity of an XMTP client. It encrypts the local SQLite database created when you call Client.create(), and must be provided every time you create or build a client.

As long as the local database and encryption key remain intact, you can use Client.build() to rehydrate the same client without re-signing.

This encryption key is not stored or persisted by the XMTP SDK, so it's your responsibility as the app developer to store it securely and consistently.

If the encryption key is lost, rotated, or passed incorrectly during a subsequent Client.create() or Client.build() call (on non-web platforms), the app will be unable to access the local database. Likewise, if you initially provided the dbPath option, you must always provide it with every subsequent call or the client may be unable to access the database. The client will assume that the database can't be decrypted or doesn't exist, and will fall back to creating a new installation.

Creating a new installation requires a new identity registration and signature—and most importantly, results in loss of access to all previously stored messages unless the user has done a history sync.

To ensure seamless app experiences persist the dbEncryptionKey securely, and make sure it's available and correctly passed on each app launch

The dbEncryptionKey client option is not used by the Browser SDK for due to technical limitations in web environments. In this case, be aware that the database is not encrypted.

To learn more about database operations, see the XMTP MLS protocol spec.

For debugging, it can be useful to decrypt a locally stored database. When a dbEncryptionKey is used, the XMTP client creates a SQLCipher database which applies transparent 256-bit AES encryption. A .sqlitecipher_salt file is also generated alongside the database.

To open this database, you need to construct the password by prefixing 0x (to indicate hexadecimal numbers), then appending the encryption key (64 hex characters, 32 bytes) and the salt (32 hex characters, 16 bytes). For example, if your encryption key is A and your salt is B, the resulting password would be 0xAB.

The database also uses a plaintext header size of 32 bytes.

If you want to inspect the database visually, you can use DB Browser for SQLite, an open source tool that supports SQLite and SQLCipher. In its Custom encryption settings, set the Plaintext Header Size to 32, and use the full Password as a Raw key.


Create a client

To call Client.create(), you must pass in a required signer and can also pass in any of the optional parameters covered in Configure an XMTP client.

import { Client, type Signer } from '@xmtp/browser-sdk';
 
// create a signer
const signer: Signer = {
  /* ... */
};
 
const client = await Client.create(
  signer,
  // client options
  {
    // Note: dbEncryptionKey is not used for encryption in browser environments
  }
);

Configure an XMTP client

You can configure an XMTP client with these options passed to Client.create:

import type { ContentCodec } from '@xmtp/content-type-primitives';
 
type ClientOptions = {
  /**
   * Specify which XMTP environment to connect to. (default: `dev`)
   */
  env?: 'local' | 'dev' | 'production';
  /**
   * Add a client app version identifier that's included with API requests.
   * Production apps are strongly encouraged to set this value.
   *
   * You can use the following format: `appVersion: 'APP_NAME/APP_VERSION'`.
   *
   * For example: `appVersion: 'alix/2.x'`
   *
   * If you have an app and an agent, it's best to distinguish them from each other by
   * adding `-app` and `-agent` to the names. For example:
   *
   * - App: `appVersion: 'alix-app/3.x'`
   * - Agent: `appVersion: 'alix-agent/2.x'`
   *
   * Setting this value provides telemetry that shows which apps are using the
   * XMTP client SDK. This information can help XMTP core developers provide you with app
   * support, especially around communicating important SDK updates, deprecations,
   * and required upgrades.
   */
  appVersion?: string;
  /**
   * apiUrl can be used to override the `env` flag and connect to a
   * specific endpoint
   */
  apiUrl?: string;
  /**
   * historySyncUrl can be used to override the `env` flag and connect to a
   * specific endpoint for syncing history
   */
  historySyncUrl?: string | null;
  /**
   * Allow configuring codecs for additional content types
   */
  codecs?: ContentCodec[];
  /**
   * Path to the local DB
   *
   * There are 4 value types that can be used to specify the database path:
   *
   * - `undefined` (or excluded from the client options)
   *    The database will be created in the current working directory and is based on
   *    the XMTP environment and client inbox ID.
   *    Example: `xmtp-dev-<inbox-id>.db3`
   *
   * - `null`
   *    No database will be created and all data will be lost once the client disconnects.
   *
   * - `string`
   *    The given path will be used to create the database.
   *    Example: `./my-db.db3`
   *
   * - `function`
   *    A callback function that receives the inbox ID and returns a string path.
   *    Example: `(inboxId) => string`
   */
  dbPath?: string | null | ((inboxId: string) => string);
  /**
   * Encryption key for the local DB
   */
  dbEncryptionKey?: Uint8Array;
  /**
   * Enable structured JSON logging
   */
  structuredLogging?: boolean;
  /**
   * Enable performance metrics
   */
  performanceLogging?: boolean;
  /**
   * Logging level
   */
  loggingLevel?: 'off' | 'error' | 'warn' | 'info' | 'debug' | 'trace';
  /**
   * Disable automatic registration when creating a client
   */
  disableAutoRegister?: boolean;
  /**
   * Disable device sync
   */
  disableDeviceSync?: boolean;
};

Log out a client

When you log a user out of your app, you can give them the option to delete their local database.

Important

If the user chooses to delete their local database, they will lose all of their messages and will have to create a new installation the next time they log in.

/**
 * The Browser SDK client does not currently support deleting the local database.
 */
 
// this method only terminates the client's associated web worker
client.close();

List conversations


List existing conversations

Get a list of existing group chat and DM conversations in the local database.

By default, list returns only conversations with a consent state of allowed or unknown.

We recommend listing allowed conversations only. This ensures that spammy conversations with a consent state of unknown don't degrade the user experience.

To list all conversations regardless of consent state, use the consentStates option and pass all three consent states.

Conversations are listed in descending order by their lastMessage created at value. If a conversation has no messages, the conversation is ordered by its createdAt value.

const allConversations = await client.conversations.list({
  consentStates: [ConsentState.Allowed],
});
const allGroups = await client.conversations.listGroups({
  consentStates: [ConsentState.Allowed],
});
const allDms = await client.conversations.listDms({
  consentStates: [ConsentState.Allowed],
});

List a user's active conversations

The isActive() method determines whether the current user is still an active member of a group conversation. For example:

  • When a user is added to a group, isActive() returns true for that user

  • When a user is removed from a group, isActive() returns false for that user

You can use a user's isActive: true value as a filter parameter when listing conversations. You can potentially have a separate section for "archived" or "inactive" conversations where you could use isActive: false.


Stream conversations and messages


List existing conversations

Get a list of existing group chat and DM conversations in the local database.

By default, list returns only conversations with a consent state of allowed or unknown.

We recommend listing allowed conversations only. This ensures that spammy conversations with a consent state of unknown don't degrade the user experience.

To list all conversations regardless of consent state, use the consentStates option and pass all three consent states.

Conversations are listed in descending order by their lastMessage created at value. If a conversation has no messages, the conversation is ordered by its createdAt value.

const stream = await client.conversations.stream({
  onValue: (conversation) => {
    // Received a conversation
    console.log('New conversation:', conversation);
  },
  onError: (error) => {
    // Log any stream errors
    console.error(error);
  },
  onFail: () => {
    console.log('Stream failed');
  },
});
 
// Or use for-await loop
for await (const conversation of stream) {
  // Received a conversation
  console.log('New conversation:', conversation);
}

Stream new group chat and DM messages

This function listens to the network for new messages within all active group chats and DMs.

Whenever a new message is sent to any of these conversations, the callback is triggered with a DecodedMessage object. This keeps the inbox up to date by streaming in messages as they arrive.

By default, streamAll streams only conversations with a consent state of allowed or unknown.

We recommend streaming messages for allowed conversations only. This ensures that spammy conversations with a consent state of unknown don't take up networking resources. This also ensures that unwanted spam messages aren't stored in the user's local database.

To stream all conversations regardless of consent state, pass [Allowed, Unknown, Denied].

const stream = await client.conversations.stream({
  onValue: (conversation) => {
    // Received a conversation
    console.log('New conversation:', conversation);
  },
  onError: (error) => {
    // Log any stream errors
    console.error(error);
  },
  onFail: () => {
    console.log('Stream failed');
  },
});
 
// Or use for-await loop
for await (const conversation of stream) {
  // Received a conversation
  console.log('New conversation:', conversation);
}

Handle stream failures

// Browser SDK also supports stream retry options
const stream = await client.conversations.streamAllMessages({
  consentStates: [ConsentState.Allowed],
  retryAttempts: 5,
  retryDelay: 15000, // 15 seconds
  onValue: (message) => {
    console.log('New message:', message);
  },
  onError: (error) => {
    console.error('Stream error:', error);
  },
  onFail: () => {
    console.log('Stream failed after retries');
  },
  onRestart: () => {
    console.log('Stream restarted');
  },
  onRetry: (attempt, maxAttempts) => {
    console.log(`Stream retry attempt ${attempt} of ${maxAttempts}`);
  },
});

Sync conversations and messages


Note

Syncing does not refetch existing conversations and messages. It also does not fetch messages for group chats you are no longer a part of.


Sync a specific conversation

Get all new messages and group updates (name, description, etc.) for a specific conversation from the network.

await client.conversation.sync();

Sync new conversations

Get any new group chat or DM conversations from the network.

await client.conversation.sync();

Sync all new welcomes, conversations, messages, and preferences

Sync all new welcomes, group chat and DM conversations, messages, and preference updates from the network.

By default, syncAll streams only conversations with a consent state of allowed or unknown.

We recommend streaming messages for allowed conversations only. This ensures that spammy conversations with a consent state of unknown don't take up networking resources. This also ensures that unwanted spam messages aren't stored in the user's local database.

To sync all conversations regardless of consent state, pass [ALLOWED, UNKNOWN, DENIED].

To sync preferences only, you can call preferences.sync. Note that preferences.sync will also sync welcomes to ensure that you have all potential new installations before syncing.

await client.conversations.syncAll(['allowed']);

Last updated