Notifications
A notification lets you reach a user outside the flow they're currently in — a report finishing, an invite arriving, a reminder firing on a schedule. Every notification lands in a durable inbox the recipient can read at any time, and can also ring their phone as a push alert in the same call. Send one from your app or from a server function; the recipient reads their inbox and manages push devices from the client.
Sending a Notification and Reading the Inbox
Send a notification to a specific user with client.notifications.send(). Sending is an app-admin operation — a member-level caller gets a permission error — so a user-triggered notification goes through a server function or your own admin-authenticated path. channels defaults to ["in-app"]; add "ios" or "android" to also push to the user's registered devices:
const { results } = await client.notifications.send({
title: "Your report is ready",
body: "Tap to view this week's summary.",
target: { userId },
channels: ["in-app", "ios"],
deepLink: "myapp://reports/latest",
idempotencyKey: `weekly-report-${userId}-2026-07-14`,
});
for (const result of results) {
console.log(result.channel, result.status); // "in-app" "delivered", "ios" "delivered"
}let response = try await client.notifications.send(
params: SendNotificationParams(
title: "Your report is ready",
body: "Tap to view this week's summary.",
target: SendNotificationParams.Target(userId: userId),
channels: ["in-app", "ios"],
deepLink: "myapp://reports/latest",
idempotencyKey: "weekly-report-\(userId)-2026-07-14"
)
)
for result in response.results {
print(result.channel, result.status) // "in-app" "delivered", "ios" "delivered"
}The response's results array has one entry per requested channel, so you can tell exactly what happened even when a send is partially successful — one channel can deliver while another fails or is skipped.
On the receiving side, client.notifications.list() returns the caller's own inbox, newest first, along with an unreadCount:
const { items, unreadCount } = await client.notifications.list({ limit: 20 });
for (const notification of items) {
console.log(notification.title, notification.read);
}
if (items[0] && !items[0].read) {
await client.notifications.markRead(items[0].notificationId);
}let inbox = try await client.notifications.list(limit: 20)
for notification in inbox.items {
print(notification.title, notification.read)
}
if let first = inbox.items.first, !first.read {
_ = try await client.notifications.markRead(notificationId: first.notificationId)
}markAllRead() marks unread items read in bulk when you don't need to mark them individually. Both it and unreadCount() work by scanning recent history rather than maintaining a counter — the count is exact and the sweep complete for any ordinarily sized inbox, but a very large backlog (thousands of rows) may need another markAllRead() call, and its unreadCount is an approximation until the backlog shrinks.
Registering for Push
Push delivery needs a device token on file. Register one after the user grants notification permission on their device — typically right after login, or the first time they opt in:
const device = await client.notifications.registerDevice({
token: deviceToken,
platform: "ios",
environment: "production",
bundleId: "com.example.myapp",
});
console.log(device.tokenSuffix); // last 8 chars only — the full token is never echoed backlet device = try await client.notifications.registerDevice(
params: RegisterPushDeviceParams(
token: deviceToken,
platform: .ios,
environment: .production,
bundleId: "com.example.myapp"
)
)
// Last 8 chars only — the full token is never echoed back
print(device.tokenSuffix ?? "")registerDevice() upserts by token: calling it again with the same token (e.g. on every app launch) just refreshes the device's metadata rather than creating a duplicate. listDevices() returns the caller's registered devices, and unregisterDevice(token) removes one — call it on logout so a signed-out device stops receiving pushes for that account. A registered device only ever exposes a tokenSuffix (the last 8 characters) back to your app; the full token is never echoed over the wire once it's been registered.
Sending from a Server Function
A server function sends the same way the client does, through ctx.api.notifications.send — the server-side equivalent of client.notifications.send(), reachable on the app's own authority with nothing to declare. A cron-fired digest or a webhook handler that needs to notify a user calls it directly, in the same handler that decided to.
Live Updates
While a user is connected, a sent notification also arrives immediately as a notification event — useful for updating a badge or toast without waiting on a poll:
client.on("notification", (event) => {
console.log(event.title, event.body);
});// In a SwiftUI `.task`. The payload type names the event, so nothing else is
// needed to resolve the subscription; leaving the loop unsubscribes.
for await event in client.stream(for: NotificationEvent.self) {
print(event.title, event.body)
}This event is a live, best-effort nudge — the inbox row from list() is the durable, authoritative record. If the recipient isn't connected at send time, they simply see the notification the next time they call list() or unreadCount(); nothing is lost.
Idempotency and Deduplication
Pass idempotencyKey on send() to make a retry safe. A repeat call with the same key inside the dedupe window doesn't re-send — the response reports deduplicated: true when every requested channel was already handled, or deduplicatedChannels naming just the channels served from the earlier send while the rest were retried fresh. Dedupe is tracked per channel and, for push, per device token — so retrying a send where one device succeeded and another temporarily failed re-sends only to the device that still needs it, without duplicating the one that already got the alert.
Rate Limiting
Sends are capped per app, per hour, per channel: 5,000 in-app notifications and 1,000 push dispatches. Exceeding the limit for every requested channel raises a rate-limit error that reports the limit and when it resets; a request spanning multiple channels only fails the channels that are actually over their limit; the rest deliver normally.