Skip to content

Push notifications

The XMTP backend sends push notifications through APNs, FCM, or an HTTPS webhook. The Android, iOS, Node, and React Native SDKs register a recipient for the local database and keep subscriptions current. The Browser SDK and WASM binding do not expose this API.

Ask the backend operator which channels are configured. See Push configuration for server setup. The Android example has an optional FCM build. The iOS example at apps/example-ios has an optional Push configuration with APNs registration and a silent-push receiver. Debug and Release keep push off. APNs delivery needs a signed device build and a backend with the APNs channel configured; the local development backend does not configure APNs.

Enable, disable, override updates, and conversation state reads are asynchronous. notificationState() is synchronous and reads local state. Failures throw an error or reject a promise. Conversation methods are available on Group and Dm.

MethodResult
client.enableNotifications(config)Store the config, register the recipient, and return its local state.
client.disableNotifications()Disable locally, then unregister from the backend.
client.notificationState()Read local state without a backend request.
conversation.setNotifications(value)Set an override, or reset it to the config rules.
conversation.state()Read common.notificationsEnabled for a group or notificationsEnabled for a DM.
import uniffi.xmtp_sdk.*
suspend fun configurePush(client: SDKClient, conversation: Conversation, token: String) {
client.enableNotifications(NotificationConfig(NotificationChannel.Fcm(token)))
conversation.setNotifications(NotificationOverride.Disabled)
val enabled = when (conversation) {
is Conversation.Group -> conversation.group.state().common.notificationsEnabled
is Conversation.Dm -> conversation.dm.state().notificationsEnabled
}
println(enabled)
conversation.setNotifications(NotificationOverride.Default)
val state = client.notificationState()
if (state is NotificationState.Failed) {
println(state.error)
}
client.disableNotifications()
}
import XmtpSdk
func configurePush(client: SDKClient, conversation: Conversation, token: String) async throws {
try await client.enableNotifications(config: NotificationConfig(channel: .apns(token: token)))
try await conversation.setNotifications(value: .disabled)
let enabled: Bool
switch conversation {
case let .group(group):
enabled = try await group.state().common.notificationsEnabled
case let .dm(dm):
enabled = try await dm.state().notificationsEnabled
}
print(enabled)
try await conversation.setNotifications(value: .default)
if case let .failed(error) = try client.notificationState() {
print(error)
}
try await client.disableNotifications()
}

The app requests OS permission and obtains an APNs or FCM token. It passes the token to the SDK; it must call enable again when that token changes.

const
const channel: NotificationChannel
channel
:
type NotificationChannel = {
readonly kind: "apns";
readonly token: string;
} | {
readonly kind: "fcm";
readonly token: string;
} | {
readonly kind: "http";
readonly url: string;
readonly signingKey: Uint8Array;
}
NotificationChannel
= {
kind: "apns" | "fcm"
kind
:
platform: "ios" | "android"
platform
=== "ios" ? "apns" : "fcm",
token: string
token
,
};
await
client: Client
client
.
ClientMembers.enableNotifications(config: NotificationConfig): Promise<NotificationState>
enableNotifications
({
channel: NotificationChannel
channel
});
const
const state: NotificationState
state
=
client: Client
client
.
ClientMembers.notificationState(): NotificationState
notificationState
();
if (
const state: NotificationState
state
.
kind: "disabled" | "enabled" | "failed"
kind
=== "failed") {
throw new
var Error: ErrorConstructor
new (message?: string, options?: ErrorOptions) => Error (+1 overload)
Error
(`Notification registration failed: ${
const state: {
readonly kind: "failed";
readonly error: NotificationFailure;
}
state
.
error: NotificationFailure
error
}`);
}
await
conversation: Conversation
conversation
.
function setNotifications(value: NotificationOverride): Promise<void>
setNotifications
("disabled");
await
conversation: Conversation
conversation
.
function setNotifications(value: NotificationOverride): Promise<void>
setNotifications
("default");
await
client: Client
client
.
ClientMembers.enableNotifications(config: NotificationConfig): Promise<NotificationState>
enableNotifications
({
channel: NotificationChannel
channel
: {
kind: "fcm"
kind
: "fcm",
token: string
token
} });
await
conversation: Conversation
conversation
.
function setNotifications(value: NotificationOverride): Promise<void>
setNotifications
("disabled");
const
const conversationState: GroupState | ConversationState
conversationState
= await
conversation: Conversation
conversation
.
function state(): Promise<GroupState> | Promise<ConversationState>
state
();
const
const enabled: boolean
enabled
=
"common" in
const conversationState: GroupState | ConversationState
conversationState
?
const conversationState: GroupState
conversationState
.
common: ConversationState
common
.
notificationsEnabled: boolean
notificationsEnabled
:
const conversationState: ConversationState
conversationState
.
notificationsEnabled: boolean
notificationsEnabled
;
await
conversation: Conversation
conversation
.
function setNotifications(value: NotificationOverride): Promise<void>
setNotifications
("default");
const
const state: NotificationState
state
=
client: Client
client
.
ClientMembers.notificationState(): NotificationState
notificationState
();
if (
const state: NotificationState
state
.
kind: "enabled" | "disabled" | "failed"
kind
=== "failed") {
var console: Console
console
.
Console.error(...data: any[]): void

The console.error() static method outputs a message to the console at the "error" log level. The message is only displayed to the user if the console is configured to display error output. In most cases, the log level is configured within the console UI. The message may be formatted as an error, with red colors and call stack information.

MDN Reference

error
(
const state: {
readonly kind: "failed";
readonly error: NotificationFailure;
}
state
.
error: NotificationFailure
error
);
}
await
client: Client
client
.
ClientMembers.disableNotifications(): Promise<void>
disableNotifications
();

These examples show each operation. In an app, call disableNotifications only when the user turns notifications off. Call enableNotifications again when the provider token, webhook URL, or rules change. A running client resumes stored notification work when it opens the same local database.

NotificationConfig.channel is required. The variants are Apns, Fcm, and Http on Kotlin; .apns, .fcm, and .http on Swift; and objects with kind equal to "apns", "fcm", or "http" on Node and React Native.

APNs and FCM need a token. HTTP needs an HTTPS url and a signingKey of 16 to 64 bytes. Use a random 32-byte key. The receiver must keep the same signing key to verify requests. Bytes use ByteArray, Data, and Uint8Array, respectively.

const
const signingKey: NonSharedBuffer
signingKey
=
function randomBytes(size: number): NonSharedBuffer (+1 overload)

Generates cryptographically strong pseudorandom data. The size argument is a number indicating the number of bytes to generate.

If a callback function is provided, the bytes are generated asynchronously and the callback function is invoked with two arguments: err and buf. If an error occurs, err will be an Error object; otherwise it is null. The buf argument is a Buffer containing the generated bytes.

// Asynchronous
const {
randomBytes,
} = await import('node:crypto');
randomBytes(256, (err, buf) => {
if (err) throw err;
console.log(`${buf.length} bytes of random data: ${buf.toString('hex')}`);
});

If the callback function is not provided, the random bytes are generated synchronously and returned as a Buffer. An error will be thrown if there is a problem generating the bytes.

// Synchronous
const {
randomBytes,
} = await import('node:crypto');
const buf = randomBytes(256);
console.log(
`${buf.length} bytes of random data: ${buf.toString('hex')}`);

The crypto.randomBytes() method will not complete until there is sufficient entropy available. This should normally never take longer than a few milliseconds. The only time when generating the random bytes may conceivably block for a longer period of time is right after boot, when the whole system is still low on entropy.

This API uses libuv's threadpool, which can have surprising and negative performance implications for some applications; see the UV_THREADPOOL_SIZE documentation for more information.

The asynchronous version of crypto.randomBytes() is carried out in a single threadpool request. To minimize threadpool task length variation, partition large randomBytes requests when doing so as part of fulfilling a client request.

@since ― v0.5.8

@param ― size The number of bytes to generate. The size must not be larger than 2**31 - 1.

@return ― if the callback function is not provided.

randomBytes
(32);
const
const channel: NotificationChannel
channel
:
type NotificationChannel = {
readonly kind: "apns";
readonly token: string;
} | {
readonly kind: "fcm";
readonly token: string;
} | {
readonly kind: "http";
readonly url: string;
readonly signingKey: Uint8Array;
}
NotificationChannel
= {
kind: "http"
kind
: "http",
url: string
url
,
signingKey: Uint8Array<ArrayBufferLike>
signingKey
};
FieldDefaultMeaning
consentStatesAllowed onlySubscribe to active conversations with one of these consent states. An empty list selects none.
includeWelcomestrueSubscribe to this installation’s welcome topic.
includeSyncGroupsfalseInclude device-sync groups.
includeCommitsfalseInclude commits and proposals on subscribed group topics.

An enabled or disabled override takes priority over consentStates. Reset with NotificationOverride.Default, .default, or "default". Overrides cannot enable a group after this installation leaves it. Device-sync groups follow includeSyncGroups and have no per-conversation override. The value in conversation state reports the local rule. It does not confirm backend subscription delivery or active membership.

The SDK updates subscriptions after consent, membership, and key changes. It uploads sender-filter keys and renews registrations automatically while its task runner is active. An update is asynchronous; it does not make messaging wait for push registration. A lost wake can delay an update until the next periodic sync, which normally runs at least once an hour. Sender filtering can miss during key rotation or an epoch boundary, so an app must still suppress its own messages when it displays notifications.

Local state is Disabled, Enabled, or Failed(error) on Kotlin; .disabled, .enabled, or .failed(error) on Swift; and a union with kind set to "disabled", "enabled", or "failed" on Node and React Native. Failed state contains a NotificationFailure enum value. Kotlin uses values such as CHANNEL_NOT_CONFIGURED, Swift uses .channelNotConfigured, and Node and React Native use "channelNotConfigured".

Terminal failures are permission denied, invalid argument, out of range, unimplemented, and channel not configured. Operation errors use the matching public error variants and codes: PermissionDenied, InvalidArgument, OutOfRange, Unimplemented, and ChannelNotConfigured. These failures stop notification work. Correct the cause and call enableNotifications again. TaskRunnerDisabled rejects enable without storing a config. Keep the task runner enabled if the app uses notifications. Configure it through ClientOptions.workers; Node and React Native name this worker kind "taskRunner".

Other registration failures leave the local state enabled so the task can retry. ResourceExhausted waits for a change to the desired subscriptions before it retries additions. Each notification request has a 30-second limit.

Disable keeps the local recipient identity and conversation overrides. It stays disabled even if unregister fails. In that case the backend registration remains until expiry. A later enable reuses the identity from the same database.

Every push identifies an envelope. It contains no encrypted message, message content, inbox ID, or recipient secret.

{
"topic": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8g",
"sequence_id": "9007199254740993"
}

topic is standard base64 of the backend wire topic. Pass topic and sequence_id to the SDK’s parsePushHint. It rejects a malformed payload with InvalidArgument and returns the topic kind and identifier: a groupMessages topic carries the conversation ID, and a welcomeMessages topic carries the installation ID. Push subscriptions carry only these two kinds, so ignore any other. Topics describes the byte layout. Do not pass the base64 text to a legacy string-topic lookup.

sequence_id is decimal text. parsePushHint returns it as an exact integer, a bigint in JavaScript. Do not convert it to a JavaScript number or use it alone as a message-delivery cursor.

APNs adds "aps": { "content-available": 1 }. FCM puts topic and sequence_id in the message’s data object. HTTPS adds recipient_id as hex. The HTTPS payload contains only topic, sequence_id, and recipient_id. HTTPS requests carry the Standard Webhooks headers webhook-id, webhook-timestamp, and webhook-signature. Verify the signature over the exact body bytes before parsing, check the timestamp, and reject replayed webhook IDs.

The app owns notification reception and display. It must obtain the provider token, request the required OS permissions, and install its background handler. APNs sends a background notification, not an alert with display text. FCM sends a data message. A notification does not contain encrypted envelope bytes. Use the SDK sync methods to fetch and process the envelopes.

  1. Open the correct XMTP installation and its local database in the handler.
  2. Validate the payload with parsePushHint. A webhook receiver first verifies the signature. Treat a push as a sync hint, not as proof of a message.
  3. Fetch and process pending welcomes. For a group topic, find the group by its conversation ID, then sync it to fetch and decrypt the envelopes. Kotlin uses client.catchUpToLive(timeoutMs = 5_000uL) for a bounded sync. Swift uses try await client.catchUpToLive(timeoutMs: 5_000). Check the returned summary’s completed and failed fields. Node also exposes catchUpToLive; the example below uses explicit welcome and conversation sync.
  4. Read decoded messages from the local database. Check consent, sender, and the app’s display policy. Suppress messages already displayed, including notifications that repeat after a backend restart.
  5. Show the permitted notification and complete the OS background callback. If the OS budget ends or the envelope is not available yet, keep work for the next allowed background run or foreground sync.
// Throws InvalidArgument for a malformed topic or sequence id.
const {
const topic: Topic
topic
,
const sequenceId: bigint
sequenceId
} =
function parsePushHint(topic: string, sequenceId: string): PushHint
parsePushHint
(
hint: {
topic: string;
sequence_id: string;
}
hint
.
topic: string
topic
,
hint: {
topic: string;
sequence_id: string;
}
hint
.
sequence_id: string
sequence_id
);
// Push subscriptions carry only group and welcome topics.
if (
const topic: Topic
topic
.
kind: "groupMessages" | "welcomeMessages" | "identityUpdates" | "keyPackages" | "commitLogEntries"
kind
!== "groupMessages" &&
const topic: {
readonly kind: "welcomeMessages";
readonly installationId: InstallationId;
} | {
readonly kind: "identityUpdates";
readonly inboxId: InboxId;
} | {
readonly kind: "keyPackages";
readonly installationId: InstallationId;
} | {
readonly kind: "commitLogEntries";
readonly commitLogId: string;
}
topic
.
kind: "welcomeMessages" | "identityUpdates" | "keyPackages" | "commitLogEntries"
kind
!== "welcomeMessages") {
throw new
var Error: ErrorConstructor
new (message?: string, options?: ErrorOptions) => Error (+1 overload)
Error
("Unexpected push topic kind");
}
// Fetch and process welcomes before looking up the group.
await
client: Client
client
.
ClientMembers.conversations: Conversations
conversations
.
Conversations.sync(): Promise<void>
sync
();
if (
const topic: {
readonly kind: "groupMessages";
readonly conversationId: ConversationId;
} | {
readonly kind: "welcomeMessages";
readonly installationId: InstallationId;
}
topic
.
kind: "groupMessages" | "welcomeMessages"
kind
=== "welcomeMessages") return;
const
const conversation: Conversation | undefined
conversation
= await
client: Client
client
.
ClientMembers.conversations: Conversations
conversations
.
Conversations.getById(id: ConversationId): Promise<Conversation | undefined>
getById
(
const topic: {
readonly kind: "groupMessages";
readonly conversationId: ConversationId;
}
topic
.
conversationId: string
conversationId
);
if (!
const conversation: Conversation | undefined
conversation
) throw new
var Error: ErrorConstructor
new (message?: string, options?: ErrorOptions) => Error (+1 overload)
Error
("Conversation is not available yet");
await
const conversation: Conversation
conversation
.
function sync(): Promise<void>
sync
();
const
const messages: Message[]
messages
= await
const conversation: Conversation
conversation
.
function messages(options?: ListMessagesOptions): Promise<Array<Message>>
messages
();

The snippet returns local messages for the app to filter. It does not implement signature verification, display deduplication, OS scheduling, or user-interface updates. A welcome can reveal an existing DM, so it need not produce an alert. Push delivery can be delayed, dropped, or repeated. Normal message sync remains the source of message state.

React Native foreground or permitted background JavaScript

Section titled “React Native foreground or permitted background JavaScript”

When an OS integration has started a live JavaScript runtime, use the same sync APIs as other native clients. parsePushHint validates the payload without a base64 decoder from your app, and returns the sequence as a bigint:

// Throws InvalidArgument for a malformed topic or sequence id.
const {
const topic: Topic
topic
,
const sequenceId: bigint
sequenceId
} =
function parsePushHint(topic: string, sequenceId: string): PushHint
parsePushHint
(
hint: {
topic: string;
sequence_id: string;
}
hint
.
topic: string
topic
,
hint: {
topic: string;
sequence_id: string;
}
hint
.
sequence_id: string
sequence_id
);
// Push subscriptions carry only group and welcome topics.
if (
const topic: Topic
topic
.
kind: "groupMessages" | "welcomeMessages" | "identityUpdates" | "keyPackages" | "commitLogEntries"
kind
=== "welcomeMessages") {
await
client: Client
client
.
ClientMembers.conversations: Conversations
conversations
.
Conversations.sync(): Promise<void>
sync
();
return;
}
if (
const topic: {
readonly kind: "groupMessages";
readonly conversationId: ConversationId;
} | {
readonly kind: "identityUpdates";
readonly inboxId: InboxId;
} | {
readonly kind: "keyPackages";
readonly installationId: InstallationId;
} | {
readonly kind: "commitLogEntries";
readonly commitLogId: string;
}
topic
.
kind: "groupMessages" | "identityUpdates" | "keyPackages" | "commitLogEntries"
kind
!== "groupMessages") {
throw new
var Error: ErrorConstructor
new (message?: string, options?: ErrorOptions) => Error (+1 overload)
Error
("Unexpected push topic kind");
}
let
let conversation: Conversation | undefined
conversation
= await
client: Client
client
.
ClientMembers.conversations: Conversations
conversations
.
Conversations.getById(id: ConversationId): Promise<Conversation | undefined>
getById
(
const topic: {
readonly kind: "groupMessages";
readonly conversationId: ConversationId;
}
topic
.
conversationId: string
conversationId
);
if (!
let conversation: Conversation | undefined
conversation
) {
await
client: Client
client
.
ClientMembers.conversations: Conversations
conversations
.
Conversations.sync(): Promise<void>
sync
();
let conversation: Conversation | undefined
conversation
= await
client: Client
client
.
ClientMembers.conversations: Conversations
conversations
.
Conversations.getById(id: ConversationId): Promise<Conversation | undefined>
getById
(
const topic: {
readonly kind: "groupMessages";
readonly conversationId: ConversationId;
}
topic
.
conversationId: string
conversationId
);
}
if (!
let conversation: Conversation | undefined
conversation
) throw new
var Error: ErrorConstructor
new (message?: string, options?: ErrorOptions) => Error (+1 overload)
Error
("Conversation is not available yet");
await
let conversation: Conversation
conversation
.
function sync(): Promise<void>
sync
();
const
const messages: Message[]
messages
= await
let conversation: Conversation
conversation
.
function messages(options?: ListMessagesOptions): Promise<Array<Message>>
messages
();

The app must arrange the native APNs or FCM handler before any foreground UI mounts. Route app-process work through a supported Hermes runtime, including the app’s headless background integration when available, then call the React Native SDK. Do not initialize a second Swift or Kotlin SDK runtime in that same process. Persist the validated hint if JavaScript cannot run, then sync when the OS next permits execution. A push does not authorize a new installation: reopen the correct current database and use the existing installation. Do not create a new signing key in a background handler.

On Android, complete the provider callback within its budget. Longer work needs the app’s supported background scheduling integration. On iOS, enable the required push entitlement and background remote-notification mode in the app target. Finish the OS completion handler even on timeout or SDK failure. Neither system guarantees delivery or JavaScript execution after suspension or termination.

A notification extension is a separate native process. Metro and the app’s React context do not provide a JavaScript runtime inside it. Do not call React hooks or assume the app TurboModule exists in the extension.

The React Native npm product provides the app TurboModule. It does not expose a Swift or Kotlin client facade for a native extension or a handler that runs without JavaScript. Link the Swift package into the separate iOS extension process. The native SDK in that process owns its own client and runtime. A native-only main-app or Android cold-start path that mixes a sibling SDK with the React Native runtime is not a verified integration. Use the app’s supported Hermes/headless runtime for those handlers.

An iOS extension that decrypts messages also needs an App Group container for shared current storage and a keychain access group for the saved database key. The app owns these entitlements and access policies. Close one database owner before another process opens the same files; do not open the app’s active database concurrently from an extension. Within the separate iOS extension process, use this order:

  1. Read the saved public identity, database location, and key from app-owned storage. Use the native SDK’s build operation to reopen the current installation without generating or registering a new identity.
  2. Validate the hint, then run catchUpToLive with a timeout inside the remaining OS budget. Check the summary’s completed and failed values. An incomplete result needs later foreground or scheduled processing.
  3. Read the conversation and messages from local storage. Apply consent, sender, and display-deduplication rules.
  4. Await end() to release the client, then complete the native callback.

For an iOS extension, invoke its content handler after releasing the client. Always provide a fallback when its time budget expires. The silent APNs payload above does not by itself request a notification service extension; alert and extension routing must be configured by the app’s native notification integration.

See React Native and Expo for native setup and Create a client for storage ownership.

Different installations can create separate groups for the same DM. The SDK presents these groups as one visible DM. It checks every underlying active group for notification subscriptions, using each group’s consent and override rules. A DM lookup can return a different group ID as the underlying groups converge. Use the peer inbox ID for app state that belongs to the DM. Do not show a new-conversation alert when a welcome only adds a duplicate DM.

This is a breaking change. XMTPPush, the generated push-service stubs, getPushTopics, and allPushTopics are removed. Apps must call enableNotifications to register again. Previous subscriptions and per-conversation notification choices are not migrated. The new default selects only allowed conversations.

Rewrite receivers for the payload above. The old encryptedMessage field and string routing topics are absent. The backend operator must decommission the old notification server and remove its registrations. The new disableNotifications cannot remove registrations from that server; old pushes can continue until the operator completes cleanup.