> ## Documentation Index
> Fetch the complete documentation index at: https://docs.sublay.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Push Notifications

> Deliver native OS push notifications to your users on iOS, Android, and Web — from a single server-side call.

Push Notifications lets your app deliver native OS push notifications to users on iOS (via APNs), Android (via FCM), and the browser (via Web Push) — all from a single server-side call. Sublay stores each user's registered devices, fans a message out to every platform, and prunes stale tokens automatically.

<Note>
  **Requires the `push` bundle.** Install it from your project's **Database** page in the [dashboard](https://dash.sublay.io) before using any push feature. See [Bundles](/bundles) for details.
</Note>

## How It Works

1. **Configure credentials** in the dashboard — paste your APNs `.p8` key, FCM service account JSON, or enable Web Push (keypair generated server-side).
2. **Register devices** — your client app calls `register()` from `usePushRegistration` at a moment that makes sense for your UX (settings screen, first-run prompt, etc.). The SDK handles permission, token retrieval, and server registration.
3. **Send notifications** — your backend calls `sublay.push.send({ userIds, title, body, data })`. Sublay fans the message out to every registered device for those users across all platforms.

## Dashboard Setup

### Installing the bundle

Open **Database → Bundles** in the [dashboard](https://dash.sublay.io) and install `push`. Once provisioning completes, the **Push Notifications** settings page becomes active.

### Configuring providers

Go to **Settings → Push Notifications** in the dashboard. Configure each platform you want to support:

**APNs (iOS)**

| Field      | Description                                                             |
| ---------- | ----------------------------------------------------------------------- |
| Key ID     | The 10-character key identifier from your Apple Developer account       |
| Team ID    | Your Apple Developer team ID                                            |
| Bundle ID  | Your app's bundle identifier (e.g. `com.example.myapp`)                 |
| .p8 key    | Contents of the `.p8` private key file downloaded from Apple            |
| Production | Toggle for sandbox vs. production APNs gateway (defaults to production) |

**FCM (Android)**

Upload or paste your Firebase service account JSON. This is the file downloaded from the Firebase console under **Project Settings → Service Accounts → Generate new private key**.

**Web Push**

No credentials to paste — click **Enable**. Sublay generates a VAPID EC keypair server-side and stores the private key encrypted. Only the public key is returned and displayed; it is also available via the unauthenticated [`GET /vapid-public-key`](/api-reference/push-notifications/get-vapid-public-key) endpoint for use in your service worker.

### Automatic push settings

Sublay can fan a push out **automatically** whenever an in-app notification is created (a new comment, reply, mention, reaction, follow, connection, RSVP-event change, space-membership approval) or a chat message is sent — no `push.send()` call required. The **Settings → Push Notifications** page lets you tune what those automatic pushes look like per event type:

| Setting               | Scope          | Description                                                                                                                                                                    |
| --------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **Body template**     | Per event type | The notification body, with a `{variable}` palette specific to each event. Ships with sensible defaults.                                                                       |
| **Title template**    | Per event type | An optional title line, interpolated against the **same** `{variable}` palette as the body. **Ships empty** — an unset title renders as an empty string, so titles are opt-in. |
| **Default delivery**  | Project-wide   | A default `{ sound, channelId, priority }` applied to every automatic push.                                                                                                    |
| **Delivery override** | Per event type | Overrides the project default per field for a single event type. A present field wins; an absent one inherits the default.                                                     |

These settings affect only the **automatic** path. The manual [`push.send()`](#server-sdk--sending-notifications) call already accepts `title`, `sound`, `channelId`, `priority`, and everything else directly.

See [Automatic Pushes](#automatic-pushes) below for the full delivery-config, title-template, and tap-routing contract.

### Test-send UI

Each provider card in the dashboard includes a **Send Test Push** panel. Supply a platform, a device token or subscription, a title, and a body, then click **Send Test Push** to verify credentials before shipping.

## Client SDK — Registering Devices

Use `usePushRegistration` with the adapter for your platform. Call `register()` in response to a deliberate user action — not on mount — because requesting OS push permission is a one-shot prompt that users cannot undo.

```tsx theme={null}
// Expo
import { usePushRegistration } from "@sublay/core";
import { expoPushTokenAdapter } from "@sublay/expo";

function SettingsScreen() {
  const { register, registering } = usePushRegistration(expoPushTokenAdapter);
  return <Button onPress={register} loading={registering} title="Enable notifications" />;
}
```

```tsx theme={null}
// Bare React Native
import { usePushRegistration } from "@sublay/core";
import { reactNativePushTokenAdapter } from "@sublay/react-native";

const { register } = usePushRegistration(reactNativePushTokenAdapter);
```

```tsx theme={null}
// Web (browser)
import { usePushRegistration } from "@sublay/core";
import { webPushTokenAdapter } from "@sublay/react-js";

const { register, unregister } = usePushRegistration(webPushTokenAdapter);
```

The web adapter requires a service worker. Register one before calling `register()`. See [SDK Reference → Push Notifications](/sdk/push-notifications/overview) for per-platform setup and [`usePushRegistration`](/hooks/push/use-push-registration) for the full hook API.

## Server SDK — Sending Notifications

Call `client.push.send()` from your backend whenever you want to notify users:

```typescript theme={null}
import { SublayClient } from "@sublay/node";

const sublay = await SublayClient.init({
  projectId: process.env.SUBLAY_PROJECT_ID!,
  apiKey: process.env.SUBLAY_SERVICE_KEY!,
});

const result = await sublay.push.send({
  userIds: ["usr_abc123", "usr_def456"],
  title: "You have a new match!",
  body: "Tap to see who liked your post.",
  data: { screen: "matches" },
});

for (const [userId, devices] of Object.entries(result.results)) {
  if (devices.length === 0) {
    console.log(`${userId} has no registered devices`);
  } else {
    console.log(`${userId}:`, devices);
  }
}
```

A single call fans the notification out to all platforms. Capped at **100 user IDs per request**. See [Node SDK — Push Notifications](/node-sdk/push-notifications) for the full module reference.

<Tip>
  A common pattern is to bridge Sublay's in-app notifications to push: subscribe to the `notification.created` webhook and call `push.send()` when it fires. See [Webhooks → Push Notification Bridge](/webhooks#push-notification-bridge).
</Tip>

## Rich Notification Payloads

Beyond `title`, `body`, and `data`, `push.send()` accepts optional fields for sound, badges, images, grouping, priority, and lifetime. Each maps to the native APNs / FCM / Web Push capability and is **silently ignored on platforms that don't support it** — so you can set `subtitle` (iOS-only) and `channelId` (Android-only) in the same call without branching.

```typescript theme={null}
await sublay.push.send({
  userIds: ["usr_abc123"],
  title: "New message",
  body: "Alice sent you a message",
  data: { conversationId: "conv_xyz789" },
  sound: "notification.wav",   // custom sound
  channelId: "messages",       // Android channel (see below)
  badge: 3,                    // iOS app-icon badge
  tag: "conv_xyz789",          // collapse a conversation into one notification
  priority: "high",
});
```

| Field            | Platforms         | Notes                                                                                      |
| ---------------- | ----------------- | ------------------------------------------------------------------------------------------ |
| `sound`          | iOS, Android, Web | Filename of a bundled sound. On Android 8+ the **channel** owns the sound — see below.     |
| `badge`          | iOS               | App-icon badge count. Your backend supplies the number; Sublay tracks no unread state.     |
| `channelId`      | Android           | Notification channel id. Created client-side.                                              |
| `priority`       | iOS, Android      | `"high"` (default) wakes the device; `"normal"` is power-considerate.                      |
| `subtitle`       | iOS               | Line under the title.                                                                      |
| `imageUrl`       | iOS, Android, Web | Big-picture image. iOS needs a Notification Service Extension — see below.                 |
| `tag`            | Android, Web      | Display-replace key so notifications collapse instead of stacking.                         |
| `collapseId`     | iOS, Android      | Transport-level collapse — a newer push supersedes an undelivered one.                     |
| `threadId`       | iOS               | Notification grouping.                                                                     |
| `ttl`            | iOS, Android      | Time-to-live in **seconds** for offline devices.                                           |
| `mutableContent` | iOS               | Enables your Notification Service Extension. Set automatically when `imageUrl` is present. |

### Client-side requirements

Three of these need setup in **your app** — Sublay forwards the field but cannot do this part for you:

<AccordionGroup>
  <Accordion title="Android sound → notification channels">
    On Android 8+ the **notification channel** owns the sound, importance, and vibration — the payload can't override it. Create the channel once in your app (with the bundled sound) and pass its id as `channelId`:

    ```ts theme={null}
    import * as Notifications from "expo-notifications";

    await Notifications.setNotificationChannelAsync("messages", {
      name: "Messages",
      importance: Notifications.AndroidImportance.HIGH,
      sound: "notification.wav", // bundled in the app
    });
    ```

    Then `push.send({ ..., channelId: "messages", sound: "notification.wav" })`. The `sound` field alone is only a pre-Android-8 fallback. Sublay does **not** create channels for you.
  </Accordion>

  <Accordion title="iOS images → Notification Service Extension">
    iOS does not download remote images from the payload on its own. To show `imageUrl` on iOS, add a [Notification Service Extension](https://developer.apple.com/documentation/usernotifications/unnotificationserviceextension) to your app that reads the URL from the notification payload, downloads it, and attaches it. Sublay sets `mutable-content` automatically whenever `imageUrl` is present so the extension is allowed to run. Android and Web render `imageUrl` with no extra work.
  </Accordion>

  <Accordion title="Web rendering → your service worker">
    Sublay ships no service worker — your app's own SW renders web notifications. The server forwards the web-renderable fields (`sound`, `image`, `tag`) in the push JSON; your `push` event handler decides how to display them via `registration.showNotification(...)`.
  </Accordion>
</AccordionGroup>

## Automatic Pushes

Whenever Sublay writes an in-app notification (a new comment, reply, mention, reaction, milestone, follow, connection request/accept, RSVP-event invite/update/cancel, or space-membership approval) or a chat message is sent, it can fan a native push out to the recipient's registered devices **automatically** — you don't call `push.send()` for these. What each automatic push looks like is configured per project on the **Settings → Push Notifications** page.

<Note>
  Everything in this section applies to the **automatic** path only. The manual [`push.send()`](#server-sdk--sending-notifications) call takes `title`, `body`, `data`, `sound`, `channelId`, `priority`, and the [rich fields](#rich-notification-payloads) directly and is unchanged.
</Note>

### Title & body templates

Each event type has a **body template** (shipped with sensible defaults) and an optional **title template**. Both are interpolated against the same per-event `{variable}` palette shown next to each row in the dashboard.

The title template **ships empty** for every event type — an unset title renders as an empty string, which is the historical behavior. Set a title only for the events where you want one; leaving it blank is a no-op.

```
# Example (dashboard, entity-comment)
Title:  New comment
Body:   {initiatorUsername} commented on your post
```

### Delivery: sound, channel & priority

You can set a project-wide **default delivery** block and, optionally, a **per-event override**:

* **Default** — a single `{ sound, channelId, priority }` applied to every automatic push.
* **Override** — a per-event-type `{ sound, channelId, priority }`. Any field you set on the override wins for that event; any field you leave unset inherits the project default. Precedence is **per-event override → project default → none**.

| Field       | Platforms         | Notes                                                                                                                                                            |
| ----------- | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `sound`     | iOS, Android, Web | Filename of a bundled sound. On Android 8+ the notification **channel** owns the sound — see [Android sound → notification channels](#client-side-requirements). |
| `channelId` | Android           | Notification channel id. Created client-side; omitted from the web/iOS payloads.                                                                                 |
| `priority`  | iOS, Android      | `"high"` wakes the device; `"normal"` is power-considerate.                                                                                                      |

These are the same three fields `push.send()` accepts, wired to the automatic path. The other rich fields (`badge`, `imageUrl`, `tag`, etc.) are configurable on the manual path only.

### Tap-routing `data`

Every automatic push carries a flat `data` object of string identifiers so your app's tap handler can deep-link to the right screen. All `data` values are **flat top-level strings** (FCM coerces `data` values with `String()`, so nested objects are not used).

Two keys are on every automatic push:

| Key      | Value                                                                                                                                               |
| -------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| `type`   | The event type, e.g. `entity-comment`, `new-follow`, `event-invite`, `message`.                                                                     |
| `action` | A canonical routing hint — one of `open-comment`, `open-entity`, `open-profile`, `open-space`, `open-event`, or `open-conversation` (message path). |

Beyond `type` and `action`, each push forwards **every identifier form** for each object the notification references, so you can route on whichever id your app uses (UUID, short id, foreign id, slug, or username). Per referenced object:

| Object                      | Keys forwarded                                                                    |
| --------------------------- | --------------------------------------------------------------------------------- |
| Entity                      | `entityId`, `entityShortId`, `entityForeignId`                                    |
| Comment                     | `commentId`, `commentForeignId` (comments have no short id)                       |
| Space                       | `spaceId`, `spaceShortId`, `spaceSlug`                                            |
| RSVP event                  | `eventId`, `eventShortId`                                                         |
| Initiator (the acting user) | `initiatorId`, `initiatorUsername`, `initiatorForeignId` (users have no short id) |
| Connection                  | `connectionId`                                                                    |

Which of those apply depends on the event type:

| Event type(s)                                                                                                                                       | `action`            | `data` identifiers (beyond `type`/`action`)                       |
| --------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------- | ----------------------------------------------------------------- |
| `entity-comment`, `comment-reply`, `comment-mention`, `comment-reaction`, `comment-reaction-milestone-specific`, `comment-reaction-milestone-total` | `open-comment`      | Entity + Comment + Space keys (+ Initiator, except on milestones) |
| `entity-mention`, `entity-reaction`, `entity-reaction-milestone-specific`, `entity-reaction-milestone-total`                                        | `open-entity`       | Entity + Space keys (+ Initiator, except on milestones)           |
| `new-follow`                                                                                                                                        | `open-profile`      | Initiator keys                                                    |
| `connection-request`, `connection-accepted`                                                                                                         | `open-profile`      | `connectionId` + Initiator keys                                   |
| `space-membership-approved`                                                                                                                         | `open-space`        | Space keys                                                        |
| `event-invite`, `event-updated`, `event-cancelled`                                                                                                  | `open-event`        | RSVP-event + parent-Space keys + Initiator keys                   |
| `message` (chat)                                                                                                                                    | `open-conversation` | `conversationId`, `messageId`                                     |

<Note>
  **Content and RSVP-event notifications also carry the parent space.** A notification *about* an Entity or Comment (a "content notification") or an RSVP-event notification additionally forwards the parent **Space** id set (`spaceId`, `spaceShortId`, `spaceSlug`) when the subject lives in a space, so you can route within a space. Don't confuse the `message` **event type** or an RSVP **Event** (the data model) with the generic idea of a "notification event."
</Note>

<Note>
  **These `data` keys are additive and non-breaking.** Existing tap handlers that ignore unknown keys are unaffected — nothing that was present before was removed or renamed. A key whose underlying value is absent (e.g. a user with no `foreignId`, a space with no `slug`) is simply omitted rather than sent empty.
</Note>

The chat-message path builds its `data` from the send context rather than notification metadata, so it always carries exactly `{ type: "message", action: "open-conversation", conversationId, messageId }`.

## Device Lifecycle

* **Re-registration:** registering the same physical device again (same token or endpoint) updates the existing record instead of duplicating it.
* **Device reassignment:** if the same device is registered by a different user (e.g. a shared device after logout/login), the record is reassigned to the new user.
* **Stale token cleanup:** tokens or subscriptions permanently rejected by APNs, FCM, or Web Push during a send are automatically deleted — no separate cleanup pass is needed.
* **Explicit logout:** call `unregister()` in your logout flow so the device stops receiving notifications after sign-out.

## References

Everything related to push notifications across the docs:

<CardGroup cols={2}>
  <Card title="SDK Reference — Push Notifications" icon="mobile-screen-button" href="/sdk/push-notifications/overview">
    Per-platform client setup for Expo, React Native, and Web
  </Card>

  <Card title="usePushRegistration" icon="cube" href="/hooks/push/use-push-registration">
    Request permission, register, and unregister the current user's device
  </Card>

  <Card title="Node SDK — Push Notifications" icon="node-js" href="/node-sdk/push-notifications">
    The `push.send()` server module reference
  </Card>

  <Card title="Webhooks — Push Bridge" icon="webhook" href="/webhooks#push-notification-bridge">
    Forward `notification.created` events to push
  </Card>
</CardGroup>

### API Endpoints

<CardGroup cols={2}>
  <Card title="Register Device" href="/api-reference/push-notifications/register-device">
    `POST /push-notifications/devices`
  </Card>

  <Card title="Deregister Device" href="/api-reference/push-notifications/deregister-device">
    `DELETE /push-notifications/devices`
  </Card>

  <Card title="Send Push" href="/api-reference/push-notifications/send">
    `POST /push-notifications/send`
  </Card>

  <Card title="Get VAPID Public Key" href="/api-reference/push-notifications/get-vapid-public-key">
    `GET /push-notifications/vapid-public-key`
  </Card>
</CardGroup>
