Skip to content

Inboxes

With XMTP, a user can have one or more inboxes they use to access their messages. An inbox ID is a stable identifier for a user’s messaging identity and is used as the destination for messages in direct message and group conversations. It is the SHA-256 digest of the first identity’s identifier text followed by a decimal nonce. It does not depend on a key package.

An inbox can have multiple identities associated with it. A public identity has an ethereum or passkey kind and an identifier string. EOA and SCW signers both use an Ethereum identity. A passkey uses a public key encoded as hex.

Messages addressed to these identities use the same inbox ID on the same backend. A new installation must join or sync conversations to obtain their state.

The first time someone uses your app with an identity they’ve never used with any app built with XMTP, your app creates an inbox ID and installation associated with the identity. To do this, you create a client for their identity.

The client creates an inbox ID and installation ID associated with the identity. The first identity is the recovery identity. Its signer authorizes identity removal, installation revocation, and recovery identity changes.

Diagram showing creation of an inbox and installation

When you make subsequent calls to create a client for the same identity and a local database is not present, the client uses the same inbox ID, but creates a new installation ID.

The backend defaults to 10 active installations per inbox. At the limit, the user needs to revoke installations.

Diagram showing an added installation

You can enable a user to add multiple identities to their inbox. Adding an identity does not create an installation. A later client created with that identity can register its own installation for the same inbox.

Diagram showing an added identity

You can enable a user to remove an identity from their inbox. You cannot remove the recovery identity.

The backend defaults to 256 identity log entries per inbox. The operator can change limits.max_identity_entries. The creation entry counts. Later entries include actions such as:

  • Add a wallet
  • Remove a wallet
  • Add an installation
  • Revoke an installation
  • Claim a handle

The backend enforces the limit only when it receives the update. The SDK does not reject it in advance. If the backend rejects an update because the log is full, contact the backend operator. A separate inbox does not inherit the old inbox’s conversations.

Why this limit exists: Every client that interacts with the inbox must fetch and validate its identity updates. In a group, the work increases for every member with a long update history.

The backend defaults to 10 active installations per inbox. The operator can change mls.max_installations_per_inbox. The SDK reads this limit from server configuration. At the limit, the user must revoke an installation before adding one. The limit controls MLS group size and avoids repeated registration.

Important

The only way to avoid consuming installation updates is to ensure your XMTP database is persisted and reloaded between app sessions and deployments. When you reuse an existing database, you maintain the same installation instead of creating a new one each time.

Installations accumulate per backend. Separate backend deployments have separate identity logs and counts. The same identifier and nonce can derive the same inbox ID in each deployment.

Default and directory storage place the database at {root}/{deployment}/{inboxId}/xmtp.db3. A storage label adds a directory below the root. Keep the same storage options to reuse the installation. There is no env client option.

  1. Reuse the same local database and encryption key on every launch.
  2. Point tests at a backend you can reset. Clear its database when a limit blocks the test.
  3. Revoke unused installations before the active count reaches the configured limit.
  4. Record enough installation data to help a user select the correct device.

All four SDKs expose generateInboxId to calculate an inbox ID from an identity and nonce. This calculation does not register or move an identity. Client creation uses registration.nonce when it needs to derive an inbox ID; the default is 0. An existing identifier association still selects its current inbox. A new nonce does not reset a full log or merge conversations.

Show a user the installation ID, last activity time, app version, and device data that your app stores. Revoking an installation removes its authorization from the identity log. It does not delete its local copy of earlier messages.

TaskBrowser, NodeKotlin, Swift
Read inbox stateclient.inboxState(true)client.inboxState(refreshFromNetwork = true) (Kotlin) or client.inboxState(refreshFromNetwork: true) (Swift)
Read several inbox statesClient.inboxStates(ids, backend)SDKClient.inboxStates(ids, backend) (Kotlin) or SDKClient.inboxStates(ids: ids, backend: backend) (Swift)
Revoke selected installationsclient.revokeInstallations(signer, ids)client.revokeInstallations(signer, ids) (Kotlin) or client.revokeInstallations(signer: signer, ids: ids) (Swift)
Revoke all other installationsclient.revokeAllOtherInstallations(signer)client.revokeAllOtherInstallations(signer) (Kotlin) or client.revokeAllOtherInstallations(signer: signer) (Swift)

InboxState returns inboxId, identities, installations, recoveryIdentity, optional creationSignatureKind, optional handle, and otherHandles (see Show handles). Static inboxStates leaves creationSignatureKind absent. Each installation has id and an optional createdAt. The SDK does not return a last activity time or device name. Store those values in your app if needed.

Static state lookup and revocation need no local client. JavaScript accepts backend options or a connected Backend. Kotlin and Swift use BackendSource.

const
const state: InboxState
state
= await
client: Client
client
.
ClientMembers.inboxState(refreshFromNetwork: boolean): Promise<InboxState>
inboxState
(true);
const
const states: InboxState[]
states
= await
class Client

An XMTP client. Its inputs and results are public values; the binding Client and the target transport stay private to the package.

Client
.
ClientMembers.inboxStates(ids: Array<InboxId>, backend: BackendSource): Promise<Array<InboxState>>
inboxStates
(
inboxIds: string[]
inboxIds
,
backend: Backend
backend
);
await
client: Client
client
.
ClientMembers.revokeInstallations(signer: Signer, ids: Array<InstallationId>): Promise<void>
revokeInstallations
(
signer: Signer
signer
,
installationIds: string[]
installationIds
);
await
client: Client
client
.
ClientMembers.revokeAllOtherInstallations(signer: Signer): Promise<void>
revokeAllOtherInstallations
(
signer: Signer
signer
);

If the user cannot open an active installation, call static Client.revokeInstallations(signer, inboxId, ids, backend) on JavaScript, or SDKClient.revokeInstallations on Kotlin or Swift. Supply the recovery signer. The SDK builds the request, fetches the backend chain configuration, asks the signer to sign it, and publishes the revocation.

Reassigning an identity that belongs to another inbox moves its identifier lookup to the new inbox. The user loses access through that identity to the former inbox. Enable reassignment only after the user chooses it.

Browser and Node call client.unsafeAddAccount(signer, allowInboxReassign). Kotlin calls client.unsafeAddAccount(signer, allowInboxReassign); Swift calls client.unsafeAddAccount(signer: signer, allowInboxReassign: allowInboxReassign). Use false to reject an identity associated with another inbox. Use true to permit reassignment. The new identity signs the association; the current installation also signs it.

Use removeAccount(recoverySigner, identity) on Browser, Node, or Kotlin. Swift uses removeAccount(recoverySigner: recoverySigner, identity: identity). The recovery signer signs the removal. To remove the current recovery identity, first change the recovery identity with changeRecoveryIdentifier.

When an inbox has multiple associated identities, the identities array is ordered by the client_timestamp_ns field, which sorts identities based on when they were added to the inbox, placing identities with recorded times in ascending order. An identity with no recorded time, including the initial identity, sorts after them. Equal timestamps have no promised order.

For UI display purposes, show the inbox’s current handle when it has one. See Show handles. Otherwise, use the only identity when there is one, or the first non-recovery identity in the ordered array.

A handle is a short public name, such as nick or nick#2341, that an inbox claims so that other people can find it. Handles are optional. An inbox can claim a handle after it registers.

A handle is a name part, then an optional # and a code of exactly 4 digits. The name part starts with a lowercase letter and has 1 to 27 characters: lowercase letters, digits, _, and -. nick and nick#2341 are two different handles. When a user types a handle, the SDK changes ASCII capitals to lowercase.

The client claims a handle with an entry in the inbox’s identity log, signed with its installation key. The user does not sign with a wallet. Each claim uses one entry of the inbox update limit. The backend decides whether a claim is accepted. A claim fails with one of these errors:

ErrorCause
HandleTakenAnother inbox holds the handle.
HandleNotAllowedThe name part is on the backend’s reserved list, or is shorter than the deployment’s minimum name length.
HandleTooSoonThe inbox claimed a handle less than 7 days ago.
HandleLimitReachedThe inbox made 64 claims. The SDK finds this in the inbox’s own state before it signs.

None of these errors is retryable. Text outside the handle grammar fails with InvalidArgument before any request. A switch back to an older handle of the inbox is a new claim. It must pass every check in the table, and it counts toward the 64 claims. See Handle limits.

The backend publishes its minimum name length in the server configuration as handles.minNameLength. Use it for a hint while the user types. The SDK never applies it: it does not refuse a claim or hide a handle because of it. The client’s configuration snapshot can be older than the backend’s setting, so the backend’s answer decides. A handle claimed before the minimum changed stays valid.

/** Return a hint for the name that the user typed, or undefined. */
export function
function handleHint(client: Client, typed: string): string | undefined

Return a hint for the name that the user typed, or undefined.

handleHint
(
client: Client
client
:
class Client

An XMTP client. Its inputs and results are public values; the binding Client and the target transport stay private to the package.

Client
,
typed: string
typed
: string) {
// Lowercase ASCII capitals only, as the SDK does. toLowerCase() also
// folds U+212A KELVIN SIGN to "k", which the SDK refuses.
const
const handle: string
handle
=
typed: string
typed
.
String.replace(searchValue: {
[Symbol.replace](string: string, replacer: (substring: string, ...args: any[]) => string): string;
}, replacer: (substring: string, ...args: any[]) => string): string (+3 overloads)

Replaces text in a string, using an object that supports replacement within a string.

@param ― searchValue A object can search for and replace matches within a string.

@param ― replacer A function that returns the replacement text.

replace
(/[A-Z]/g, (
letter: string
letter
) =>
letter: string
letter
.
String.toLowerCase(): string

Converts all the alphabetic characters in a string to lowercase.

toLowerCase
());
if (!/^[a-z][a-z0-9_-]{0,26}(#[0-9]{4})?$/.
RegExp.test(string: string): boolean

Returns a Boolean value that indicates whether or not a pattern exists in a searched string.

@param ― string String on which to perform the search.

test
(
const handle: string
handle
))
return "Start with a letter. Use letters, digits, _ and -.";
// A hint only: the backend decides each claim.
const {
const minNameLength: number
minNameLength
} =
client: Client
client
.
ClientMembers.serverConfiguration: ServerConfiguration
serverConfiguration
.
handles: HandlesConfiguration
handles
;
const [
const name: string
name
= ""] =
const handle: string
handle
.
String.split(separator: string | RegExp, limit?: number): string[] (+1 overload)

Split a string into substrings using the specified separator and return them as an array.

@param ― separator A string that identifies character or characters to use in separating the string. If omitted, a single-element array containing the entire string is returned.

@param ― limit A value used to limit the number of elements returned in the array.

split
("#");
if (
const name: string
name
.
String.length: number

Returns the length of a String object.

length
<
const minNameLength: number
minNameLength
)
return `Use at least ${
const minNameLength: number
minNameLength
} characters before #.`;
return
var undefined
undefined
;
}

Before the client exists, read the same value with Client.fetchServerConfiguration(backend) on Browser and Node, or SDKClient.fetchServerConfiguration on Kotlin and Swift.

availableHandles(name) returns the handles that no inbox holds, with the typed name first when it is free. For a name without a code, it checks the name and 100 random codes in one lookup. For a name with a code, it checks only that handle. An empty result means that every checked handle is taken: call it again for new codes, or ask for another name. An unregistered client can call it. It does not check the reserved list or the minimum name length, so a claim of a returned handle can still fail with HandleNotAllowed.

claimHandle(handle) claims the handle for the client’s inbox. The handle becomes the inbox’s current handle.

The candidate loop is app code. The SDK does not choose a handle, and it does not retry a claim. When a person chooses the handle:

  • On HandleTaken, another inbox claimed the handle first, or a lookup did not see that claim yet. Show new candidates and let the user pick again.
  • On HandleNotAllowed, ask for another name. Every code of that name fails the same way.
  • On HandleTooSoon, tell the user that the handle can change again 7 days after the last change.
  • On HandleLimitReached, tell the user that the inbox cannot claim more handles.

Never claim a handle that the user did not choose. A claim is permanent, and the inbox must then wait 7 days before its next claim.

/**
* Let the user pick a free handle, then claim it. `pick` shows the
* candidates and returns the user's choice, or undefined to cancel. After
* another inbox takes the choice first, `pick` also gets that handle.
*/
export async function
function chooseHandle(client: Client, name: string, pick: (candidates: string[], taken?: string) => Promise<string | undefined>): Promise<string | undefined>

Let the user pick a free handle, then claim it. pick shows the candidates and returns the user's choice, or undefined to cancel. After another inbox takes the choice first, pick also gets that handle.

chooseHandle
(
client: Client
client
:
class Client

An XMTP client. Its inputs and results are public values; the binding Client and the target transport stay private to the package.

Client
,
name: string
name
: string,
pick: (candidates: string[], taken?: string) => Promise<string | undefined>
pick
: (
candidates: string[]
candidates
: string[],
taken: string | undefined
taken
?: string) =>
interface Promise<T>

Represents the completion of an asynchronous operation

Promise
<string | undefined>,
) {
let
let candidates: string[]
candidates
= await
client: Client
client
.
ClientMembers.availableHandles(name: string): Promise<Array<string>>
availableHandles
(
name: string
name
);
let
let taken: string | undefined
taken
: string | undefined;
for (;;) {
const
const choice: string | undefined
choice
= await
pick: (candidates: string[], taken?: string) => Promise<string | undefined>
pick
(
let candidates: string[]
candidates
,
let taken: string | undefined
taken
);
if (
const choice: string | undefined
choice
===
var undefined
undefined
) return
var undefined
undefined
;
try {
await
client: Client
client
.
ClientMembers.claimHandle(handle: string): Promise<void>
claimHandle
(
const choice: string
choice
);
return
const choice: string
choice
;
} catch (
function (local var) error: unknown
error
) {
// Show other errors to the user. Never claim a handle they did not pick.
if (!(
function (local var) error: unknown
error
instanceof
class XmtpError

A failure from the XMTP SDK. Its subclass names the failure variant. details.code keeps the Rust cause code.

XmtpError
.
XmtpError.HandleTaken: typeof XmtpErrorHandleTaken
HandleTaken
)) throw
function (local var) error: unknown
error
;
let taken: string | undefined
taken
=
const choice: string
choice
;
let candidates: string[]
candidates
= await
client: Client
client
.
ClientMembers.availableHandles(name: string): Promise<Array<string>>
availableHandles
(
name: string
name
);
}
}
}

An automated app, such as an agent, has no person to ask. On HandleTaken, it can claim the next candidate itself. Claim once, and keep the handle on later starts. availableHandles and claimHandle are on agent.client.

/** Give the agent a handle for `name` once. The agent picks the code itself. */
export async function
function claimAgentHandle(agent: Agent, name: string): Promise<string | undefined>

Give the agent a handle for name once. The agent picks the code itself.

claimAgentHandle
(
agent: Agent<unknown>
agent
:
class Agent<ContentTypes = unknown>

Event-driven XMTP agent that routes conversations and messages to middleware.

Agent
,
name: string
name
: string) {
// Claims are permanent and limited. Keep the handle the agent already has.
const
const state: InboxState
state
= await
agent: Agent<unknown>
agent
.
Agent<unknown>.client: Client

Return the wrapped client for operations outside agent middleware.

client
.
ClientMembers.inboxState(refreshFromNetwork: boolean): Promise<InboxState>
inboxState
(false);
if (
const state: InboxState
state
.
handle?: string | undefined
handle
!==
var undefined
undefined
) return
const state: InboxState
state
.
handle?: string
handle
;
for (const
const handle: string
handle
of await
agent: Agent<unknown>
agent
.
Agent<unknown>.client: Client

Return the wrapped client for operations outside agent middleware.

client
.
ClientMembers.availableHandles(name: string): Promise<Array<string>>
availableHandles
(
name: string
name
)) {
try {
await
agent: Agent<unknown>
agent
.
Agent<unknown>.client: Client

Return the wrapped client for operations outside agent middleware.

client
.
ClientMembers.claimHandle(handle: string): Promise<void>
claimHandle
(
const handle: string
handle
);
return
const handle: string
handle
;
} catch (
function (local var) error: unknown
error
) {
// Another inbox took this handle after the lookup. Try the next one.
if (!(
function (local var) error: unknown
error
instanceof
class XmtpError

A failure from the XMTP SDK. Its subclass names the failure variant. details.code keeps the Rust cause code.

XmtpError
.
XmtpError.HandleTaken: typeof XmtpErrorHandleTaken
HandleTaken
)) throw
function (local var) error: unknown
error
;
}
}
return
var undefined
undefined
;
}

InboxState and Member have handle, the inbox’s current handle, and otherHandles, its other handles with the most recent first. The current handle is the handle that the inbox claimed last. A Member reads them from the newest identity state that the client holds for that inbox.

When an inbox has a current handle, show that handle as the text that identifies the inbox. Show the other handles only as secondary information, for example in a detail view. An old handle still finds its inbox, so when a user finds an inbox by an old handle, show the current handle.

/** Name each member by its current handle. Other handles are secondary. */
export async function
function memberLabels(group: Group): Promise<{
inboxId: string;
title: string;
alsoKnownAs: string[];
}[]>

Name each member by its current handle. Other handles are secondary.

memberLabels
(
group: Group
group
:
class Group
Group
) {
const
const members: Member[]
members
= await
group: Group
group
.
Group.members(): Promise<Array<Member>>
members
();
return
const members: Member[]
members
.
Array<Member>.map<{
inboxId: string;
title: string;
alsoKnownAs: string[];
}>(callbackfn: (value: Member, index: number, array: Member[]) => {
inboxId: string;
title: string;
alsoKnownAs: string[];
}, thisArg?: any): {
inboxId: string;
title: string;
alsoKnownAs: string[];
}[]

Calls a defined callback function on each element of an array, and returns an array that contains the results.

@param ― callbackfn A function that accepts up to three arguments. The map method calls the callbackfn function one time for each element in the array.

@param ― thisArg An object to which the this keyword can refer in the callbackfn function. If thisArg is omitted, undefined is used as the this value.

map
((
member: Member
member
) => ({
inboxId: string
inboxId
:
member: Member
member
.
inboxId: string
inboxId
,
title: string
title
:
member: Member
member
.
handle?: string | undefined
handle
??
member: Member
member
.
inboxId: string
inboxId
,
alsoKnownAs: string[]
alsoKnownAs
:
member: Member
member
.
otherHandles: string[]
otherHandles
,
}));
}
/** Find an inbox by a handle. An old handle also finds it. */
export async function
function findByHandle(client: Client, typed: string): Promise<{
inboxId: string;
title: string;
} | undefined>

Find an inbox by a handle. An old handle also finds it.

findByHandle
(
client: Client
client
:
class Client

An XMTP client. Its inputs and results are public values; the binding Client and the target transport stay private to the package.

Client
,
typed: string
typed
: string) {
const
const inboxId: string | undefined
inboxId
= await
client: Client
client
.
ClientMembers.inboxIdFor(identity: PublicIdentity): Promise<InboxId | undefined>
inboxIdFor
({
identifier: string
identifier
:
typed: string
typed
,
kind: PublicIdentityKind
kind
: "handle",
});
if (
const inboxId: string | undefined
inboxId
===
var undefined
undefined
) return
var undefined
undefined
;
const [
const state: InboxState
state
] = await
client: Client
client
.
ClientMembers.inboxStates(ids: Array<InboxId>, refreshFromNetwork: boolean): Promise<Array<InboxState>>
inboxStates
([
const inboxId: string
inboxId
], false);
// Show the current handle, also when the user typed an old one.
return {
inboxId: string
inboxId
,
title: string
title
:
const state: InboxState
state
?.
handle?: string | undefined
handle
??
const inboxId: string
inboxId
};
}

When the client stores a newer identity state for an inbox with a different current handle, it emits a handle.changed event. Read the members again to show the new handle. See Events.

A PublicIdentity with the kind handle names the inbox that claimed the handle. Calls that take a PublicIdentity to find an inbox also take a handle: inboxIdFor, getDmByIdentity, createDm, createGroup, addMembers, and removeMembers. One list can mix handles and accounts. Before one of these calls, client.inboxIdFor or static inboxIdFor included, uses a lookup answer, the SDK checks that the inbox’s own identity log claimed the handle. On both the client and the static path, it checks any smart contract wallet signature in that log with the backend’s verifier, as it does for every identity log, so it trusts the backend for those verdicts. An old handle finds the inbox too.

The SDK never derives an inbox ID from a handle. For a handle that no inbox claimed, client.inboxIdFor returns no inbox, static inboxIdFor fails with IdentityNotFound, and a call that creates a conversation or changes members fails with IdentityNotFound.

canMessage also takes a handle. It reports the handle under the key handle:<handle>, in lowercase. true means only that the backend names an inbox for the handle. canMessage gives you no inbox, so the SDK does not check this answer against an identity log. A true is enough to remove a taken handle from a list of candidates. To reach the inbox, use one of the calls above.

A handle is not an account: it signs nothing. Client creation, generateInboxId, and the calls that add, remove, or change an account fail with InvalidArgument for a handle.

The Agent SDK adds createDmWithHandle, createGroupWithHandles, and addMembersWithHandles:

export async function
function startByHandle(agent: Agent): Promise<{
dm: Dm;
group: Group;
}>
startByHandle
(
agent: Agent<unknown>
agent
:
class Agent<ContentTypes = unknown>

Event-driven XMTP agent that routes conversations and messages to middleware.

Agent
) {
const
const dm: Dm
dm
= await
agent: Agent<unknown>
agent
.
Agent<unknown>.createDmWithHandle(handle: string, options?: CreateDmOptions): Promise<Dm>

Create a DM with the inbox that claimed a handle, such as nick or nick#2341. An old handle of that inbox also resolves to it. A handle that no inbox claimed fails with IdentityNotFound.

createDmWithHandle
("nick#2341");
const
const group: Group
group
= await
agent: Agent<unknown>
agent
.
Agent<unknown>.createGroupWithHandles(handles: string[], options?: CreateGroupOptions): Promise<Group>

Create a group with the inboxes that claimed these handles.

createGroupWithHandles
(["nick", "ana"]);
await
agent: Agent<unknown>
agent
.
Agent<unknown>.addMembersWithHandles(group: Group, handles: string[]): ReturnType<Group["addMembers"]>

Add the inboxes that claimed these handles to an existing group.

addMembersWithHandles
(
const group: Group
group
, ["bo#0042"]);
return {
dm: Dm
dm
,
group: Group
group
};
}

What happens when a user removes an identity?

Section titled “What happens when a user removes an identity?”

If the user removes an identity from the inbox, the identity no longer has access to the inbox it was removed from.

An identity removed from an inbox

That identity no longer resolves to the former inbox. This does not revoke existing installation keys or erase their local messages. If someone sends a message to the identity, the message is not associated with the original inbox. A later client resolves the identity again. If it has no current association, the SDK falls back to the inbox ID derived from that identity and nonce. Do not assume this always creates a new inbox ID.

The recovery identity and its signer can be used to sign transactions that remove identities and revoke installations. An added identity cannot remove the recovery identity because it does not control the recovery signer.

There is no way to combine two inboxes. Reassigning an identity from inbox 2 to inbox 1 moves identifier lookup to inbox 1. It does not move the conversations or revoke inbox 2’s installations.

Moving an identity between inboxes

To help users avoid this state, ensure that your UX surfaces their ability to add multiple identities to a single inbox.

Does an associated identity open the existing inbox?

Section titled “Does an associated identity open the existing inbox?”

A new client for the identity resolves to the associated inbox. If the identity is reassigned, a new client resolves to the new inbox. Existing installation access is controlled separately through installation revocation.