Skip to content

Notifications ​

Arkstack notifications provide framework-neutral delivery for mail, SMS, and database-backed in-app notifications.

Install ​

Full app templates include the notifications package and a src/config/notifications.ts file. If you are adding it manually, install:

sh
npm i @arkstack/notifications
sh
pnpm add @arkstack/notifications
sh
yarn add @arkstack/notifications

Configuration ​

Notification configuration is split into a default notification driver, driver-level options, and reusable transports.

ts
// src/config/notifications.ts
import { env } from '@arkstack/common';

export default () => ({
  default_driver: env('NOTIFICATION_DRIVER', 'mail'),
  drivers: {
    mail: {
      transport: 'smtp',
      from: env('MAIL_FROM_ADDRESS', '[email protected]'),
      test_address: env('MAIL_TEST_ADDRESS'),
    },
    sms: {
      transport: env('SMS_TRANSPORT', 'africastalking'),
      from: env('SMS_FROM'),
    },
    db: {
      table: 'user_notifications',
    },
  },
  transports: {
    smtp: {
      host: env('MAIL_HOST', 'localhost'),
      port: env('MAIL_PORT', 1025),
      secure: env('MAIL_SECURE', false),
      auth: {
        user: env('SMTP_USER'),
        pass: env('SMTP_PASS'),
      },
    },
    africastalking: {
      username: env('AFRICASTALKING_USERNAME', 'sandbox'),
      apiKey: env('AFRICASTALKING_API_KEY'),
      senderId: env('AFRICASTALKING_SENDER_ID'),
    },
    twilio: {
      accountSid: env('TWILIO_ACCOUNT_SID'),
      authToken: env('TWILIO_AUTH_TOKEN'),
      from: env('TWILIO_FROM'),
    },
  },
});

default_driver selects the notification channel used by Notification.channel(). drivers.sms.transport selects the SMS transport provider, and starter config reads that value from SMS_TRANSPORT.

Mail ​

Mail uses the configured SMTP transport. Recipients can be a string, an array of strings, a named address object, or an array of named address objects.

ts
import { Notification } from '@arkstack/notifications';

await Notification.mail()
  .recipient({ '[email protected]': 'Ada Lovelace' })
  .subject('Welcome, {name}')
  .data({ name: 'Ada' })
  .send('Hello {name}, thanks for joining.');

await Notification.mail()
  .recipient([
    { '[email protected]': 'Grace Hopper' },
    { '[email protected]': 'Katherine Johnson' },
  ])
  .send('The report is ready.');

SMS ​

SMS supports africastalking and twilio transports.

ts
import { Notification } from '@arkstack/notifications';

await Notification.sms()
  .recipient('+2348012345678')
  .send('Your login code is {code}', undefined, undefined, {
    code: '123456',
  });

await Notification.sms({ transport: 'twilio' })
  .recipient('+15551234567')
  .send('Your login code is {code}', undefined, undefined, {
    code: '123456',
  });

Database Notifications ​

The db driver stores in-app notifications through the UserNotification model. Full templates include the model and migration for a user_notifications table.

ts
await Notification.db()
  .recipient(user)
  .type('security')
  .action('Review login', '/account/security')
  .meta({ device: 'Chrome on macOS' })
  .send('A new login was detected.', 'Security alert');

You can also broacast the new database notifcation using the configured realtime driver by channing the broadcast method.

ts
await Notification.db()
  .broadcast()
  .recipient(user)
  .type('security')
  .action('Review login', '/account/security')
  .meta({ device: 'Chrome on macOS' })
  .send('A new login was detected.', 'Security alert');

For this to work, your User model needs to have a pushTokens property/column, this can be a string and array of string[] or and object with a token property (pushTokens.token), an array of pushTokens object should also work ({ pushTokens: { token: string } }[])

The default behaviour for a missing pushTokens on the User model is no-op, so you don't have to worry about your app breaking.

Use UserNotificationCenter when you need to list, mark, or delete stored notifications:

ts
import { UserNotificationCenter } from '@arkstack/notifications';

const unread = await UserNotificationCenter.unreadForUser(user);
await UserNotificationCenter.markRead(unread[0]);
await UserNotificationCenter.delete(unread[0]);

Realtime Notifications ​

The realtime driver broadcasts a notification to connected clients over Pusher Channels or Firebase Cloud Messaging. Each user has their own channel (${channel_prefix}${user.id}, e.g. user.7).

ts
await Notification.realtime()
  .recipient(user)
  .type('transaction')
  .action('View', '/wallet')
  .store() // also persist so the client can load history
  .send('You received $20.00', 'Payment received');

.store() is opt-in: when enabled the notification is written via UserNotificationCenter (giving it a real id and timestamps) and broadcast; otherwise it is broadcast only. Broadcast on an explicit channel with .channel('team.updates'), or change the client event name with .event('alert').

.channel() (and .recipient()) also accept an array. For Pusher it fans out to multiple channels; for Firebase it is treated as a list of device registration tokens and delivered via a chunked multicast (500 tokens per call). The Firebase multicast returns { successCount, failureCount, invalidTokens } — delete invalidTokens from your store, since FCM reports which are unregistered:

ts
await Notification.realtime({ transport: 'firebase' })
  .channel(user.deviceTokens) // string[] of FCM registration tokens
  .send('You have a new message');

Delivery options ​

FCM sends a data message at normal priority, and Android's Doze and App Standby may hold it until the next maintenance window. .priority('high') exempts it, and pairs naturally with a short TTL — FCM's own default is four weeks:

ts
await Notification.realtime({ transport: 'firebase' })
  .channel(user.deviceTokens)
  .priority('high')
  .ttl(60)
  .collapseKey(`signin:${attemptId}`) // a retry replaces the prompt it repeats
  .send('Approve sign-in from Lagos?');

Pass TTL in seconds; each platform's unit is converted for you. Set defaults under drivers.realtime.delivery, override per send, and reach anything unmapped through .delivery({ android, apns, webpush }). Pusher ignores all of it — it pushes over a connection the client already holds open.

On iOS this maps to APNs only for a visible push (apns-push-type: alert), since Apple requires background pushes to be low priority. Ringing or full-screen alerts need PushKit, which FCM cannot address at all.

Superseding a notification ​

.tag() gives a notification a stable identity, so a later one with the same tag replaces it on the client instead of stacking beside it:

ts
// asked
await Notification.realtime({ transport: 'firebase' })
  .channel(tokens)
  .tag(`signin:${attemptId}`)
  .collapseKey(`signin:${attemptId}`)
  .priority('high')
  .ttl(60)
  .send('Approve sign-in from Lagos?');

// answered elsewhere
await Notification.realtime({ transport: 'firebase' })
  .channel(tokens)
  .tag(`signin:${attemptId}`)
  .collapseKey(`signin:${attemptId}`)
  .send('Sign-in approved');

Tag by the thing it is about (order:42), not the message. tag supersedes what was displayed; collapseKey supersedes what is still queued — set both when you want both. They stay separate because FCM allows only four collapse keys per device, so deriving one per tag would starve that budget.

.retract() removes a notification with nothing in its place. Prefer a replacement where the outcome has content of its own: a retraction must travel as a silent push, which platforms throttle hardest. Retractions are never stored, and retracting does not touch a row already written by .store().

The React and Vue bindings apply this for you; for your own list, use the same reducer:

ts
import { supersede } from '@arkstack/realtime';

client.subscribe(channel, (n) => setItems((items) => supersede(items, n, 50)));

Configure the transport in src/config/notifications.ts (drivers.realtime) and its credentials under transports.pusher / transports.firebase. The pusher / firebase-admin SDKs are optional, install only the one you use:

sh
pnpm add pusher          # Pusher transport
pnpm add firebase-admin  # Firebase transport

Bringing your own transport. driverFactory replaces the built-ins with anything implementing RealtimeDriver — a backend this package does not ship, or a fake in a test. Set it per send, or under drivers.realtime for the whole app; it takes precedence over transport. Do connection setup lazily inside the driver, as the bundled ones do.

ts
Notification.realtime({ driverFactory: () => new MyApnsDriver() });

broadcast() also accepts payloads that are not notifications, so an application can push its own event shapes over the same channel and pick them up with the client's listen().

Firebase credentials can be provided two ways. Point admin_sdk_path (FIREBASE_ADMINSDK, default firebase-adminsdk.json, resolved from the project root) at a downloaded service-account JSON file; if that file is absent, the driver falls back to the inline project_id / client_email / private_key values (FIREBASE_PROJECT_ID / FIREBASE_CLIENT_EMAIL / FIREBASE_PRIVATE_KEY). app_name (FIREBASE_APP_NAME, default your APP_NAME) names the Firebase Admin app instance so repeated broadcasts reuse it.

Consuming on the client ​

Install @arkstack/realtime in your front-end and the matching client SDK (pusher-js or firebase):

ts
import { createRealtime } from '@arkstack/realtime';

const realtime = createRealtime({
  transport: 'pusher',
  pusher: {
    key: import.meta.env.VITE_PUSHER_KEY,
    cluster: 'mt1',
    authEndpoint: '/broadcasting/auth',
  },
});

const unsubscribe = await realtime.forUser(user.id, (notification) => {
  console.log(notification.title, notification.description);
});

React and Vue bindings accumulate notifications for you (newest first):

tsx
import { useNotifications } from '@arkstack/realtime/react';

function Bell({ realtime, userId }) {
  const { notifications, latest, clear } = useNotifications(
    realtime,
    realtime.channelFor(userId),
    { limit: 20 },
  );

  return (
    <span>
      {notifications.length} · {latest?.title}
    </span>
  );
}
vue
<script setup>
import { useNotifications } from '@arkstack/realtime/vue';

const props = defineProps(['realtime', 'userId']);
const { notifications, latest } = useNotifications(
  props.realtime,
  props.realtime.channelFor(props.userId),
  { limit: 20 },
);
</script>

<template>
  <span>{{ notifications.length }} · {{ latest?.title }}</span>
</template>

Prepared Recipients ​

Notification.prepare() can derive the recipient from a user-like object:

ts
await Notification.channel('mail')
  .prepare(user, { name: user.name })
  .send('Hello {name}');

await Notification.channel('sms')
  .prepare(user)
  .send('Your verification code is {code}', undefined, undefined, {
    code,
  });

await Notification.channel('db')
  .prepare(user)
  .send('Your payout has settled.', 'Payout settled');