Skip to content

Push notifications

The XMTP backend sends push notifications through APNs, FCM, or an HTTPS webhook. The Android, iOS, and Node 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()
}
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.

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. Failed state contains a NotificationFailure enum value. Kotlin uses values such as CHANNEL_NOT_CONFIGURED, Swift uses .channelNotConfigured, and Node uses "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 names 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": "AAECAwQFBgcICQoLDA0ODxA=",
"sequence_id": "9007199254740993"
}

topic is standard base64 of the backend wire topic. Decode it before routing. A group topic starts with 0x00 and contains a 16-byte group ID. A welcome topic starts with 0x01 and contains a 32-byte installation key. The group ID is the hex encoding of the bytes after the kind byte. Do not pass the base64 text to a legacy string-topic lookup.

sequence_id is decimal text. Keep it as text or an exact integer such as JavaScript bigint. 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 and decode the topic. 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 decoded 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.
const
const topic: Buffer<ArrayBuffer>
topic
=
var Buffer: BufferConstructor
Buffer
.
BufferConstructor.from(string: WithImplicitCoercion<string>, encoding?: BufferEncoding): Buffer<ArrayBuffer> (+3 overloads)

Creates a new Buffer containing string. The encoding parameter identifies the character encoding to be used when converting string into bytes.

import { Buffer } from 'node:buffer';
const buf1 = Buffer.from('this is a tést');
const buf2 = Buffer.from('7468697320697320612074c3a97374', 'hex');
console.log(buf1.toString());
// Prints: this is a tést
console.log(buf2.toString());
// Prints: this is a tést
console.log(buf1.toString('latin1'));
// Prints: this is a tést

A TypeError will be thrown if string is not a string or another type appropriate for Buffer.from() variants.

Buffer.from(string) may also use the internal Buffer pool like Buffer.allocUnsafe() does.

@since ― v5.10.0

@param ― string A string to encode.

@param ― encoding The encoding of string. Default: 'utf8'.

from
(
hint: {
topic: string;
sequence_id: string;
}
hint
.
topic: string
topic
, "base64");
if (
const topic: Buffer<ArrayBuffer>
topic
.
Buffer<ArrayBuffer>.toString(encoding?: BufferEncoding, start?: number, end?: number): string

Decodes buf to a string according to the specified character encoding inencoding. start and end may be passed to decode only a subset of buf.

If encoding is 'utf8' and a byte sequence in the input is not valid UTF-8, then each invalid byte is replaced with the replacement character U+FFFD.

The maximum length of a string instance (in UTF-16 code units) is available as

constants.MAX_STRING_LENGTH

.

import { Buffer } from 'node:buffer';
const buf1 = Buffer.allocUnsafe(26);
for (let i = 0; i < 26; i++) {
// 97 is the decimal ASCII value for 'a'.
buf1[i] = i + 97;
}
console.log(buf1.toString('utf8'));
// Prints: abcdefghijklmnopqrstuvwxyz
console.log(buf1.toString('utf8', 0, 5));
// Prints: abcde
const buf2 = Buffer.from('tést');
console.log(buf2.toString('hex'));
// Prints: 74c3a97374
console.log(buf2.toString('utf8', 0, 3));
// Prints: té
console.log(buf2.toString(undefined, 0, 3));
// Prints: té

@since ― v0.1.90

@param ― encoding The character encoding to use.

@param ― start The byte offset to start decoding at.

@param ― end The byte offset to stop decoding at (not inclusive).

toString
("base64") !==
hint: {
topic: string;
sequence_id: string;
}
hint
.
topic: string
topic
) {
throw new
var Error: ErrorConstructor
new (message?: string, options?: ErrorOptions) => Error (+1 overload)
Error
("Invalid push topic encoding");
}
if (!/^[0-9]+$/.
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
(
hint: {
topic: string;
sequence_id: string;
}
hint
.
sequence_id: string
sequence_id
)) {
throw new
var Error: ErrorConstructor
new (message?: string, options?: ErrorOptions) => Error (+1 overload)
Error
("Invalid push sequence");
}
const
const sequenceId: bigint
sequenceId
=
var BigInt: BigIntConstructor
(value: bigint | boolean | number | string) => bigint
BigInt
(
hint: {
topic: string;
sequence_id: string;
}
hint
.
sequence_id: string
sequence_id
);
if (
const sequenceId: bigint
sequenceId
=== 0n ||
const sequenceId: bigint
sequenceId
> 18446744073709551615n) {
throw new
var Error: ErrorConstructor
new (message?: string, options?: ErrorOptions) => Error (+1 overload)
Error
("Invalid push sequence");
}
const
const isGroup: boolean
isGroup
=
const topic: Buffer<ArrayBuffer>
topic
[0] === 0 &&
const topic: Buffer<ArrayBuffer>
topic
.
Uint8Array<ArrayBuffer>.length: number

The length of the array.

length
=== 17;
const
const isWelcome: boolean
isWelcome
=
const topic: Buffer<ArrayBuffer>
topic
[0] === 1 &&
const topic: Buffer<ArrayBuffer>
topic
.
Uint8Array<ArrayBuffer>.length: number

The length of the array.

length
=== 33;
if (!
const isGroup: boolean
isGroup
&& !
const isWelcome: boolean
isWelcome
) throw new
var Error: ErrorConstructor
new (message?: string, options?: ErrorOptions) => Error (+1 overload)
Error
("Invalid push topic");
// Fetch and process welcomes before looking up the group.
await
client: Client
client
.
ClientMembers.conversations: Conversations
conversations
.
Conversations.sync(): Promise<void>
sync
();
if (
const isWelcome: boolean
isWelcome
) return;
const
const groupId: string
groupId
=
const topic: Buffer<ArrayBuffer>
topic
.
Buffer<ArrayBuffer>.subarray(start?: number, end?: number): Buffer<ArrayBuffer>

Returns a new Buffer that references the same memory as the original, but offset and cropped by the start and end indices.

Specifying end greater than buf.length will return the same result as that of end equal to buf.length.

This method is inherited from TypedArray.prototype.subarray().

Modifying the new Buffer slice will modify the memory in the original Bufferbecause the allocated memory of the two objects overlap.

import { Buffer } from 'node:buffer';
// Create a `Buffer` with the ASCII alphabet, take a slice, and modify one byte
// from the original `Buffer`.
const buf1 = Buffer.allocUnsafe(26);
for (let i = 0; i < 26; i++) {
// 97 is the decimal ASCII value for 'a'.
buf1[i] = i + 97;
}
const buf2 = buf1.subarray(0, 3);
console.log(buf2.toString('ascii', 0, buf2.length));
// Prints: abc
buf1[0] = 33;
console.log(buf2.toString('ascii', 0, buf2.length));
// Prints: !bc

Specifying negative indexes causes the slice to be generated relative to the end of buf rather than the beginning.

import { Buffer } from 'node:buffer';
const buf = Buffer.from('buffer');
console.log(buf.subarray(-6, -1).toString());
// Prints: buffe
// (Equivalent to buf.subarray(0, 5).)
console.log(buf.subarray(-6, -2).toString());
// Prints: buff
// (Equivalent to buf.subarray(0, 4).)
console.log(buf.subarray(-5, -2).toString());
// Prints: uff
// (Equivalent to buf.subarray(1, 4).)

@since ― v3.0.0

@param ― start Where the new Buffer will start.

@param ― end Where the new Buffer will end (not inclusive).

subarray
(1).
Buffer<ArrayBuffer>.toString(encoding?: BufferEncoding, start?: number, end?: number): string

Decodes buf to a string according to the specified character encoding inencoding. start and end may be passed to decode only a subset of buf.

If encoding is 'utf8' and a byte sequence in the input is not valid UTF-8, then each invalid byte is replaced with the replacement character U+FFFD.

The maximum length of a string instance (in UTF-16 code units) is available as

constants.MAX_STRING_LENGTH

.

import { Buffer } from 'node:buffer';
const buf1 = Buffer.allocUnsafe(26);
for (let i = 0; i < 26; i++) {
// 97 is the decimal ASCII value for 'a'.
buf1[i] = i + 97;
}
console.log(buf1.toString('utf8'));
// Prints: abcdefghijklmnopqrstuvwxyz
console.log(buf1.toString('utf8', 0, 5));
// Prints: abcde
const buf2 = Buffer.from('tést');
console.log(buf2.toString('hex'));
// Prints: 74c3a97374
console.log(buf2.toString('utf8', 0, 3));
// Prints: té
console.log(buf2.toString(undefined, 0, 3));
// Prints: té

@since ― v0.1.90

@param ― encoding The character encoding to use.

@param ― start The byte offset to start decoding at.

@param ― end The byte offset to stop decoding at (not inclusive).

toString
("hex");
const
const conversation: Conversation | undefined
conversation
= await
client: Client
client
.
ClientMembers.conversations: Conversations
conversations
.
Conversations.getById(id: ConversationId): Promise<Conversation | undefined>
getById
(
const groupId: string
groupId
);
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.

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.