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 the installation 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 example apps do not include push registration or a notification receiver.

These five methods are asynchronous. Failures throw an error or reject a promise. Conversation methods are also available on Group and Dm.

MethodResult
client.enableNotifications(config)Store the config, register the installation, and return its 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.notificationsEnabled()Read the effective value for the conversation.
suspend fun configurePush(client: Client, conversation: Conversation, token: String) {
client.enableNotifications(NotificationConfig(NotificationChannel.Fcm(token)))
conversation.setNotifications(NotificationOverride.Disabled)
val enabled = conversation.notificationsEnabled()
conversation.setNotifications(NotificationOverride.Default)
val state = client.notificationState()
if (state is NotificationState.Failed) {
println(state.error.code)
}
client.disableNotifications()
}
func configurePush(client: Client, conversation: Conversation, token: String) async throws {
_ = try await client.enableNotifications(NotificationConfig(channel: .apns(token: token)))
try await conversation.setNotifications(.disabled)
let enabled = try await conversation.notificationsEnabled()
try await conversation.setNotifications(.default)
if case let .failed(error) = try await client.notificationState() {
print(error.code)
}
try await client.disableNotifications()
}
await
client: Client<BuiltInContentTypes>
client
.
Client<BuiltInContentTypes>.enableNotifications(config: NotificationConfig): Promise<NotificationState>

Enable notifications and register the delivery channel.

enableNotifications
({
channel: NotificationChannel
channel
: {
type: "fcm"
type
: "fcm",
token: string
token
} });
await
conversation: Conversation<unknown>
conversation
.
Conversation<unknown>.setNotifications(value: NotificationOverride): Promise<void>

Override notification rules, or reset the override with "default".

setNotifications
("disabled");
const
const enabled: boolean
enabled
= await
conversation: Conversation<unknown>
conversation
.
Conversation<unknown>.notificationsEnabled(): Promise<boolean>

Read the effective notification value for this conversation.

notificationsEnabled
();
await
conversation: Conversation<unknown>
conversation
.
Conversation<unknown>.setNotifications(value: NotificationOverride): Promise<void>

Override notification rules, or reset the override with "default".

setNotifications
("default");
const
const state: NotificationState
state
= await
client: Client<BuiltInContentTypes>
client
.
Client<BuiltInContentTypes>.notificationState(): Promise<NotificationState>

Read the local state without a backend request.

notificationState
();
if (
const state: NotificationState
state
.
state: "enabled" | "disabled" | "failed"
state
=== "failed") {
var console: Console

The console module provides a simple debugging console that is similar to the JavaScript console mechanism provided by web browsers.

The module exports two specific components:

  • A Console class with methods such as console.log(), console.error() and console.warn() that can be used to write to any Node.js stream.
  • A global console instance configured to write to process.stdout and process.stderr. The global console can be used without importing the node:console module.

Warning: The global console object's methods are neither consistently synchronous like the browser APIs they resemble, nor are they consistently asynchronous like all other Node.js streams. See the note on process I/O for more information.

Example using the global console:

console.log('hello world');
// Prints: hello world, to stdout
console.log('hello %s', 'world');
// Prints: hello world, to stdout
console.error(new Error('Whoops, something bad happened'));
// Prints error message and stack trace to stderr:
// Error: Whoops, something bad happened
// at [eval]:5:15
// at Script.runInThisContext (node:vm:132:18)
// at Object.runInThisContext (node:vm:309:38)
// at node:internal/process/execution:77:19
// at [eval]-wrapper:6:22
// at evalScript (node:internal/process/execution:76:60)
// at node:internal/main/eval_string:23:3
const name = 'Will Robinson';
console.warn(`Danger ${name}! Danger!`);
// Prints: Danger Will Robinson! Danger!, to stderr

Example using the Console class:

const out = getStreamSomehow();
const err = getStreamSomehow();
const myConsole = new console.Console(out, err);
myConsole.log('hello world');
// Prints: hello world, to out
myConsole.log('hello %s', 'world');
// Prints: hello world, to out
myConsole.error(new Error('Whoops, something bad happened'));
// Prints: [Error: Whoops, something bad happened], to err
const name = 'Will Robinson';
myConsole.warn(`Danger ${name}! Danger!`);
// Prints: Danger Will Robinson! Danger!, to err

@seesource

console
.
Console.error(message?: any, ...optionalParams: any[]): void (+1 overload)

Prints to stderr with newline. Multiple arguments can be passed, with the first used as the primary message and all additional used as substitution values similar to printf(3) (the arguments are all passed to util.format()).

const code = 5;
console.error('error #%d', code);
// Prints: error #5, to stderr
console.error('error', code);
// Prints: error 5, to stderr

If formatting elements (e.g. %d) are not found in the first string then util.inspect() is called on each argument and the resulting string values are concatenated. See util.format() for more information.

@sincev0.1.100

error
(
const state: {
state: "failed";
error: NotificationError;
}
state
.
error: NotificationError
error
.
NotificationError.code: string
code
);
}
await
client: Client<BuiltInContentTypes>
client
.
Client<BuiltInContentTypes>.disableNotifications(): Promise<void>

Disable notifications. Per-conversation overrides remain stored.

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 type equal to "apns", "fcm", or "http" on Node.

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

const
const channel: NotificationChannel
channel
:
type NotificationChannel = {
type: "apns";
token: string;
} | {
type: "fcm";
token: string;
} | {
type: "http";
url: string;
signingKey: Uint8Array;
}

Delivery channel for this installation.

NotificationChannel
= {
type: "http"
type
: "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 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 hourly sync. 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 state set to "disabled", "enabled", or "failed" on Node. Failed state contains a NotificationError with a stable code.

Terminal codes have the prefix NotificationError::: PermissionDenied, InvalidArgument, OutOfRange, Unimplemented, or ChannelNotConfigured. They stop notification work. Correct the cause and call enableNotifications again. TaskRunnerDisabled rejects enable without storing a config. Node apps must not disable WorkerKind.TaskRunner if they use notifications.

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 the bytes accepted by processMessage or fromWelcome.

  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. On mobile, client.catchUpToLive(timeoutMs: ...) provides a bounded sync; use the language’s argument syntax. On Node, use the calls below.
  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.

@sincev5.10.0

@paramstring A string to encode.

@paramencoding The encoding of string. Default: 'utf8'.

from
(
hint: {
topic: string;
sequence_id: string;
}
hint
.
topic: string
topic
, "base64");
if (!/^[0-9]+$/.
RegExp.test(string: string): boolean

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

@paramstring 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
);
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<BuiltInContentTypes>
client
.
Client<BuiltInContentTypes>.conversations: Conversations<BuiltInContentTypes>

Gets the conversations manager for this client

@throws{ClientNotInitializedError} if the client is not initialized

conversations
.
Conversations<BuiltInContentTypes>.sync(): Promise<void>

Synchronizes conversations for the current client from the network

@returnsPromise that resolves when sync is complete

@seehttps://docs.xmtp.org/sdk/sync/

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).)

@sincev3.0.0

@paramstart Where the new Buffer will start.

@paramend 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é

@sincev0.1.90

@paramencoding The character encoding to use.

@paramstart The byte offset to start decoding at.

@paramend The byte offset to stop decoding at (not inclusive).

toString
("hex");
const
const conversation: Group<BuiltInContentTypes> | Dm<BuiltInContentTypes> | undefined
conversation
= await
client: Client<BuiltInContentTypes>
client
.
Client<BuiltInContentTypes>.conversations: Conversations<BuiltInContentTypes>

Gets the conversations manager for this client

@throws{ClientNotInitializedError} if the client is not initialized

conversations
.
Conversations<BuiltInContentTypes>.getConversationById(id: string): Promise<Group<BuiltInContentTypes> | Dm<BuiltInContentTypes> | undefined>

Retrieves a conversation by its ID

@paramid - The conversation ID to look up

@returnsThe conversation if found, undefined otherwise

@seehttps://docs.xmtp.org/sdk/conversations/

getConversationById
(
const groupId: string
groupId
);
if (!
const conversation: Group<BuiltInContentTypes> | Dm<BuiltInContentTypes> | undefined
conversation
) throw new
var Error: ErrorConstructor
new (message?: string, options?: ErrorOptions) => Error (+1 overload)
Error
("Conversation is not available yet");
await
const conversation: Group<BuiltInContentTypes> | Dm<BuiltInContentTypes>
conversation
.
Conversation<ContentTypes = unknown>.sync(): Promise<void>

Synchronizes conversation data from the network

@returnsPromise that resolves when synchronization is complete

sync
();
const
const messages: DecodedMessage<BuiltInContentTypes>[]
messages
= await
const conversation: Group<BuiltInContentTypes> | Dm<BuiltInContentTypes>
conversation
.
Conversation<ContentTypes = unknown>.messages(options?: ListMessagesOptions): Promise<DecodedMessage<BuiltInContentTypes>[]>

Lists messages in this conversation

@paramoptions - Optional filtering and pagination options

@returnsPromise that resolves with an array of decoded messages

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 and automatically subscribes to every matching group. 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.