Skip to content

Notifications ​

Overview ​

Notifications are opt-in. The framework ships the runtime — notify(), the provider contract and the registry — but the backend module that stores notifications and exposes /api/notification/* is generated on demand:

bash
bun maker module:make-notification notification
bun maker module:migrate notification

Until those commands run, a project has no notifications table and no /api/notification route; notify() logs one warning and returns null. This keeps a notification table out of projects that never use notifications.

The generated module is standalone, src/modules/notification/. It is independent of auth: there is no foreign key, no import of @/modules/auth, and no users-model fallback. You can delete or replace auth and notifications keep working for numeric ids and string ids (UUID, ULID, ...) alike.

PieceWhen
APIGenerated by module:make-notification: /api/notification/* behind authMiddleware
StorageGenerated by the module: notifications table owned by src/modules/notification
ProviderGenerated by the module: NotificationProvider registered automatically
RealtimeSocket.IO event notification.created when broadcast: true
EmailQueued notification:mail job (or direct send when Redis is off)

UI integration is manual — see the Vue guide below or build your own components for React, Svelte, Solid, Astro, etc.

CLI Commands ​

Create ​

Run both commands to install the module. The first writes the backend, the second creates the notifications table:

bash
npm run maker module:make-notification notification
npm run maker module:migrate notification
bash
pnpm maker module:make-notification notification
pnpm maker module:migrate notification
bash
yarn maker module:make-notification notification
yarn maker module:migrate notification
bash
bun maker module:make-notification notification
bun maker module:migrate notification

module:make-notification always writes the HTTP surface (controller, schemas, routes). It writes the store (model, provider, mail job) the first time, and prints the module:migrate command to run:

FileWrittenPurpose
controllers/<leaf>.tsevery run5 handlers: list, unreadCount, markRead, markAllRead, remove
schemas/<leaf>.tsevery runZod/OpenAPI schemas
routes/index.tsevery run5 routes under authMiddleware
database/models/notification.tsfirst run (no store yet)Polymorphic notification table, no auth FK
providers/notification.tsfirst run (no store yet)Default Drizzle store, registers itself
jobs/mail.tsfirst run (no store yet)Queue handler for notification:mail

The store is a singleton. A second notification module (for example --path=admin) becomes another HTTP surface over the same table instead of creating a duplicate table and a second provider; in that case you do not need module:migrate again, because the table already exists.

Flags:

FlagDefaultMeaning
--path=<panel>—Nest the module under a panel folder, e.g. --path=admin
--notifiable=<type>userOwner type recorded by the generated controller
--force—Overwrite the files of a module that already exists

Delete ​

bash
npm run maker module:delete-notification notification --yes
bash
pnpm maker module:delete-notification notification --yes
bash
yarn maker module:delete-notification notification --yes
bash
bun maker module:delete-notification notification --yes

Moves the backend module to src/storage/trash/modules/. UI files (if any) are user-managed. The notifications table stays in the database; remove it with a migration if you no longer need the data.

API Routes ​

All routes are mounted at /api/notification behind authMiddleware:

MethodPathHandler
GET/Paginated list with unread count
GET/unread-count{ count }
PATCH/{id}/readMark single as read
PATCH/read-allMark all as read
DELETE/{id}Delete a notification

The handlers answer 401 when no auth context is present, and 503 when no notification provider can be resolved — the module was never generated, or it was deleted and nothing replaced it.

If you need a different owner type for a panel, generate it with --notifiable=<type> and the controller will read { type, id } from the auth context accordingly.

Database Model ​

The table is defined by the generated notification module itself, in src/modules/notification/database/models/notification.ts. It has no foreign key to users, so deleting auth cannot cascade-delete notifications.

ColumnTypeNotes
idTEXT PKUUID generated by the default provider
notifiable_typeTEXTOwner class, default user
notifiable_idTEXTOwner id, always stored as text
typeTEXTinfo, success, warning, error, ...
titleTEXTRequired
bodyTEXTNullable
dataTEXTJSON string — extra payload
linkTEXTClick-through URL
read_atTIMESTAMPNullable, set when read
created_atTIMESTAMPDefault now

Two indexes cover the read paths: (notifiable_type, notifiable_id, read_at) and (notifiable_type, notifiable_id, created_at).

Upgrading from 4.2.3 or earlier ​

Before 4.2.4 the table lived in the auth module (src/modules/auth/database/models/notification.ts) with an integer id and a required user_id FK. Updating to 4.2.4 removes that model; the generated module above creates the polymorphic table instead.

Order matters: after removing the old auth notification model and its export from src/database/schema.ts, let Drizzle generate a migration that drops the old table, then generate the new module. Diffing the old table straight into the new shape makes Drizzle ask about renamed columns.

If you do not need the existing rows:

bash
bun maker db:generate   # drops the old auth-owned notifications table
bun maker db:migrate
bun maker module:make-notification notification
bun maker module:migrate notification

If you want to keep them, copy the rows to an independent backup table first; Drizzle ignores tables that are not in the schema, so the backup survives the drop:

sql
-- run before the drop migration
CREATE TABLE notifications_backup AS SELECT * FROM notifications;
bash
bun maker db:generate
bun maker db:migrate
bun maker module:make-notification notification
bun maker module:migrate notification
sql
INSERT INTO notifications (id, notifiable_type, notifiable_id, type, title, body, data, link, read_at, created_at)
SELECT CAST(id AS TEXT), 'user', CAST(user_id AS TEXT), type, title, body, data, link, read_at, created_at
FROM notifications_backup;

DROP TABLE notifications_backup;

The old numeric id becomes the new text id, so links or cached ids stay valid. On MySQL/Postgres use the same statements with the matching cast syntax (CAST(id AS CHAR) / CAST(id AS TEXT)).

The notify() Function ​

Import from the facade:

ts
import { notify } from "@/framework/facade.js";

// Save to database only — numeric auth id
await notify(userId, {
  type: "info",
  title: "Profile Updated",
  body: "Your profile was saved.",
});

// String ids work the same way (UUID, ULID, ...)
await notify("9b2f6f2e-4a9c-4a0e-9f1d-3f6a1c2b3d4e", {
  title: "Welcome",
});

// Save + broadcast via Socket.IO in real-time
await notify(userId, {
  type: "success",
  title: "Payment Received",
  body: "$50.00 credited.",
  broadcast: true,
});

// Save + broadcast + send email (queued to "mail" queue)
await notify(userId, {
  type: "warning",
  title: "Password Expiring",
  body: "Change within 7 days.",
  broadcast: true,
  mail: {
    subject: "Password Expiry Notice",
    html: "<p>Your password expires soon.</p>",
  },
});

Target ​

The first argument is either an id or an explicit notifiable target:

ts
type NotificationTarget = number | string | { type?: string; id: number | string };

await notify(42, { title: "..." });                          // { type: "user", id: 42 }
await notify("9b2f…", { title: "..." });                     // { type: "user", id: "9b2f…" }
await notify({ type: "team", id: 7 }, { title: "..." });     // { type: "team", id: 7 }

type defaults to "user". The provider always receives the id as text, so the same call works whether auth uses auto-increment integers or UUIDs.

Options ​

OptionTypeDefaultDescription
typestring"info"Category: info, success, warning, error
titlestring—Required. Notification title
bodystring—Notification body text
linkstring—Click-through URL
dataobject—Arbitrary JSON stored in data column
broadcastbooleanfalseEmit "notification.created" via Socket.IO to user
mailobject—{ subject?, html?, to? } — sends email via mail queue. Recipient resolved via mail.to or the auth provider's getUserContact().

Recipient resolution ​

When mail is set, the recipient is resolved in this order:

  1. mail.to — an explicit address. No lookup happens.
  2. The auth provider — getUserContact(id) returns { email, name, phone }. See Providers.
  3. Nothing — the email is skipped instead of failing the notification. The framework does not read the users model directly.

That means notifications keep working after the default auth module is deleted, as long as a provider is registered — or without any auth at all, when mail.to is given.

ts
await notify(userId, {
  title: "Invoice ready",
  body: "Your invoice #123 is ready.",
  mail: {
    to: "someone@example.com", // explicit recipient, no lookup
    subject: "Invoice #123",
    html: "<p>Download it from your dashboard.</p>",
  },
});

Lifecycle ​

notify(target, { broadcast: true, mail: { ... } })
  │
  ├─ 1. resolveNotificationProvider()
  │     ├─ a registered provider wins
  │     └─ otherwise null (module not generated, or deleted)
  │
  ├─ 2. provider.create(target, input)
  │     └─ inserts a row into the notifications table
  │
  ├─ 3. If broadcast option:
  │     dispatchEvent("notification.created", normalized, {
  │       broadcast: { users: [target.id] }
  │     })
  │     └─ Socket.IO emits to room "user:<id>"
  │
  └─ 4. If mail option:
        └─ dispatchEvent("notification:mail", ..., { queue: "mail" })
            └─ BullMQ Worker sends email

If no provider is registered — the module was never generated, or it was deleted and nothing replaced it — notify() logs one warning naming the generator command and returns null instead of throwing.

Usage Example: Controller ​

ts
import type { Handler } from "hono";
import { notify } from "@/framework/facade.js";

export const createComment: Handler = async (c: any) => {
  const { postId, content } = c.req.valid("json");
  const post = await getPost(postId);

  // Notify the post author
  await notify(post.authorId, {
    type: "info",
    title: "New Comment",
    body: `${c.get("auth").name} commented on your post.`,
    link: `/posts/${postId}`,
    broadcast: true,
  });

  return c.json({ message: "Comment created" });
};
ts
import type { Request, Response } from "express";
import { notify } from "@/framework/facade.js";

export const createComment = async (req: Request, res: Response) => {
  const { postId, content } = req.body;
  const post = await getPost(postId);

  // Notify the post author
  await notify(post.authorId, {
    type: "info",
    title: "New Comment",
    body: `${res.locals.auth.name} commented on your post.`,
    link: `/posts/${postId}`,
    broadcast: true,
  });

  res.json({ message: "Comment created" });
};

dispatchEvent vs. notify ​

Both functions can broadcast data to users in real-time, but they serve different purposes.

dispatchEventnotify
PersistenceNo — fire-and-forget signalYes — stores through the notification provider
Return valuePromise<void>Returns the normalized notification object with id, type, title, body, readAt, etc.
Broadcast scopeFlexible — { all: true }, { auth: true }, { roles: [] }, { users: [] }, { rooms: [] }, or any combinationTargeted — always to a single user via broadcast: { users: [userId] }
Email deliveryNo — you would need to build the email dispatch yourselfBuilt-in — pass mail: { subject, html } to queue an email
Queue optionYes — { queue: "mail" } enqueues a background jobUses dispatchEvent internally when email is needed
Use caseCustom real-time updates (chat messages, live feeds, admin alerts)User-visible notifications with persistent history (bell icon, notification page)

Vue Integration Guide ​

TIP

The CLI only generates backend code. The Vue components below are provided as copy-paste reference. Adapt them to your layout, styling, or component library.

Step 1: Create the NotificationBell component ​

Create src/resources/src/components/NotificationBell.vue:

vue
<template>
  <div class="btn-group order-3" ref="dropdownRef">
    <button
      type="button"
      class="nav-btn position-relative"
      data-bs-toggle="dropdown"
      data-bs-display="static"
      aria-expanded="false"
      aria-label="notifications">
      <i class="bi bi-bell"></i>
      <span
        v-if="unread > 0"
        class="badge bg-danger notification-badge position-absolute"
        style="top:7.5%; right: 7.5%;"
        >{{ unread > 99 ? "99+" : unread }}</span
      >
    </button>
    <div class="dropdown-menu notification-dropdown p-0">
      <div
        class="dropdown-header d-flex align-items-center justify-content-between px-3 py-2">
        <span class="fw-semibold">Notifications</span>
        <button
          v-if="unread > 0"
          class="btn btn-sm btn-link p-0 text-decoration-none"
          @click="markAllRead">
          Mark all read
        </button>
      </div>
      <div class="notification-list">
        <div
          v-if="items.length === 0"
          class="text-center text-secondary py-4 px-3">
          No notifications
        </div>
        <div
          v-for="item in items"
          :key="item.id"
          class="notification-item"
          :class="{ unread: !item.readAt }"
          @click="handleClick(item)">
          <div class="d-flex align-items-start gap-2">
            <i :class="iconClass(item.type)" class="mt-1"></i>
            <div class="flex-grow-1 min-w-0">
              <div class="notification-title">{{ item.title }}</div>
              <div v-if="item.body" class="notification-body">
                {{ item.body }}
              </div>
              <div class="notification-time">{{ timeAgo(item.createdAt) }}</div>
            </div>
          </div>
        </div>
      </div>
      <router-link
        to="/notifications"
        class="dropdown-footer d-block text-center py-2 text-decoration-none">
        View all notifications
      </router-link>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
import axios from "@/plugins/axios";
import { pulse } from "@/plugins/pulse";
import { useAuth } from "@/composables/useAuth";

const { user } = useAuth();

const unread = ref(0);
const items = ref<
  Array<{
    id: string;
    type: string;
    title: string;
    body: string | null;
    link: string | null;
    readAt: string | null;
    createdAt: string;
  }>
>([]);

function iconClass(type: string) {
  const map: Record<string, string> = {
    info: "bi bi-info-circle text-primary",
    success: "bi bi-check-circle text-success",
    warning: "bi bi-exclamation-triangle text-warning",
    error: "bi bi-x-circle text-danger",
  };
  return map[type] || "bi bi-bell text-secondary";
}

function timeAgo(dateStr: string) {
  const diff = Date.now() - new Date(dateStr).getTime();
  const mins = Math.floor(diff / 60000);
  if (mins < 1) return "Just now";
  if (mins < 60) return `${mins}m ago`;
  const hours = Math.floor(mins / 60);
  if (hours < 24) return `${hours}h ago`;
  const days = Math.floor(hours / 24);
  return `${days}d ago`;
}

async function fetchUnread() {
  try {
    const res = await axios.get("/api/notification/unread-count");
    unread.value = res.data.count;
  } catch {}
}

async function fetchRecent() {
  try {
    const res = await axios.get("/api/notification?perPage=5");
    items.value = res.data.data;
    unread.value = res.data.unread;
  } catch {}
}

async function markAllRead() {
  try {
    await axios.patch("/api/notification/read-all");
    items.value.forEach((n) => (n.readAt = new Date().toISOString()));
    unread.value = 0;
  } catch {}
}

function handleClick(item: any) {
  if (!item.readAt) {
    axios.patch(`/api/notification/${item.id}/read`).catch(() => {});
    item.readAt = new Date().toISOString();
    unread.value = Math.max(0, unread.value - 1);
  }
}

let cleanup: (() => void) | null = null;

onMounted(async () => {
  await fetchRecent();

  if (user.value) {
    const channel = pulse.channel(`user:${user.value.id}`);
    channel.listen("notification.created", (payload: any) => {
      items.value.unshift(payload);
      unread.value++;
    });
    cleanup = () => channel.stopListening("notification.created");
  }
});

onUnmounted(() => {
  cleanup?.();
});
</script>

<style scoped>
.notification-badge {
  font-size: 0.6rem;
  padding: 0.15rem 0.35rem;
  position: absolute;
  top: 4px;
  right: 2px;
  min-width: 16px;
  border-radius: 8px;
}

.notification-dropdown {
  width: 360px;
  max-height: 480px;
}

@media (max-width: 575.98px) {
  .notification-dropdown {
    position: fixed !important;
    top: 56px;
    left: 50% !important;
    transform: translateX(-50%) !important;
    width: calc(100vw - 32px);
    max-width: 400px;
  }
}

@media (min-width: 576px) {
  .notification-dropdown {
    left: auto !important;
    right: 0 !important;
    transform: none !important;
  }
}

.notification-list {
  max-height: 360px;
  overflow-y: auto;
}

.notification-item {
  padding: 0.75rem 1rem;
  cursor: pointer;
  border-bottom: 1px solid var(--app-border);
  transition: background 0.15s;
}

.notification-item:hover {
  background: var(--app-surface);
}

.notification-item.unread {
  background: rgba(var(--bs-primary-rgb), 0.04);
}

.notification-title {
  font-size: 0.85rem;
  font-weight: 500;
  line-height: 1.3;
}

.notification-body {
  font-size: 0.8rem;
  color: var(--bs-secondary-color);
  margin-top: 0.15rem;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.notification-time {
  font-size: 0.7rem;
  color: var(--bs-secondary-color);
  margin-top: 0.2rem;
}

.dropdown-footer {
  font-size: 0.85rem;
  border-top: 1px solid var(--app-border);
}
</style>

Step 2: Add NotificationBell to the header ​

In src/resources/src/layouts/Layout/Header.vue, import the component and place it where you want the bell icon:

vue
<script setup lang="ts">
import { computed, h, inject } from "vue";
import { authUser } from "@/composables/useAuth";
import NotificationBell from "@/components/NotificationBell.vue";
// ... rest of your imports
</script>

Place <NotificationBell /> in the template before the user dropdown:

vue
<template>
  <!-- ... other header elements ... -->
  <NotificationBell class="order-3 order-sm-3" />
  <div class="btn-group order-4 order-sm-4">
    <!-- user dropdown -->
  </div>
</template>

Step 3: Create the notifications page ​

Create src/resources/src/pages/notifications/index.vue:

vue
<template>
  <Pagebar title="Notifications" />
  <Refresh @click="fetchList" />

  <div class="notifications-page">
    <div class="card">
      <div
        class="card-header d-flex align-items-center justify-content-between">
        <div class="d-flex align-items-center gap-2">
          <i class="bi bi-bell"></i>
          <span class="fw-semibold">All Notifications</span>
          <span v-if="unread > 0" class="badge bg-danger"
            >{{ unread }} unread</span
          >
        </div>
        <div class="d-flex gap-2">
          <button
            v-if="unread > 0"
            class="btn btn-sm btn-outline-primary"
            @click="markAllRead">
            <i class="bi bi-check-all"></i> Mark all read
          </button>
        </div>
      </div>
      <div class="card-body p-0">
        <div v-if="loading" class="text-center py-5">
          <div class="spinner-border" role="status"></div>
        </div>
        <div
          v-else-if="items.length === 0"
          class="text-center text-secondary py-5">
          <i class="bi bi-bell-slash fs-1 d-block mb-2"></i>
          No notifications yet
        </div>
        <div v-else>
          <div
            v-for="item in items"
            :key="item.id"
            class="notification-row"
            :class="{ unread: !item.readAt }">
            <div class="d-flex align-items-start gap-3 px-3 py-3">
              <i :class="iconClass(item.type)" class="fs-5 mt-1"></i>
              <div class="flex-grow-1 min-w-0">
                <div class="notification-title">{{ item.title }}</div>
                <div v-if="item.body" class="notification-body mt-1">
                  {{ item.body }}
                </div>
                <div class="notification-meta mt-1">
                  <span class="notification-time">{{
                    formatDate(item.createdAt)
                  }}</span>
                </div>
              </div>
              <div class="d-flex gap-1 flex-shrink-0">
                <button
                  v-if="!item.readAt"
                  class="btn btn-sm btn-outline-secondary"
                  title="Mark as read"
                  @click="markRead(item)">
                  <i class="bi bi-check"></i>
                </button>
                <button
                  class="btn btn-sm btn-outline-danger"
                  title="Delete"
                  @click="remove(item)">
                  <i class="bi bi-trash"></i>
                </button>
              </div>
            </div>
          </div>
        </div>
      </div>
      <div
        v-if="total > perPage"
        class="card-footer d-flex justify-content-center">
        <nav>
          <ul class="pagination pagination-sm mb-0">
            <li class="page-item" :class="{ disabled: page <= 1 }">
              <button class="page-link" @click="goPage(page - 1)">
                Previous
              </button>
            </li>
            <li class="page-item" :class="{ disabled: page >= maxPage }">
              <button class="page-link" @click="goPage(page + 1)">Next</button>
            </li>
          </ul>
        </nav>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from "vue";
import { useHead } from "@unhead/vue";
import axios from "@/plugins/axios";
import { pulse } from "@/plugins/pulse";
import { useAuth } from "@/composables/useAuth";
import Pagebar from "@/components/Pagebar.vue";
import Refresh from "@/components/Refresh.vue";

useHead({ title: "Notifications" });

const { user } = useAuth();

const items = ref<any[]>([]);
const loading = ref(true);
const page = ref(1);
const perPage = ref(20);
const total = ref(0);
const unread = ref(0);

const maxPage = computed(() =>
  Math.max(1, Math.ceil(total.value / perPage.value)),
);

function iconClass(type: string) {
  const map: Record<string, string> = {
    info: "bi bi-info-circle text-primary",
    success: "bi bi-check-circle text-success",
    warning: "bi bi-exclamation-triangle text-warning",
    error: "bi bi-x-circle text-danger",
  };
  return map[type] || "bi bi-bell text-secondary";
}

function formatDate(dateStr: string) {
  const d = new Date(dateStr);
  return d.toLocaleDateString() + " " + d.toLocaleTimeString();
}

async function fetchList() {
  loading.value = true;
  try {
    const res = await axios.get(
      `/api/notification?page=${page.value}&perPage=${perPage.value}`,
    );
    items.value = res.data.data;
    total.value = res.data.total;
    unread.value = res.data.unread;
  } catch {}
  loading.value = false;
}

async function markRead(item: any) {
  try {
    await axios.patch(`/api/notification/${item.id}/read`);
    item.readAt = new Date().toISOString();
    unread.value = Math.max(0, unread.value - 1);
  } catch {}
}

async function markAllRead() {
  try {
    await axios.patch("/api/notification/read-all");
    items.value.forEach((n) => (n.readAt = new Date().toISOString()));
    unread.value = 0;
  } catch {}
}

async function remove(item: any) {
  try {
    await axios.delete(`/api/notification/${item.id}`);
    items.value = items.value.filter((n) => n.id !== item.id);
    if (!item.readAt) unread.value = Math.max(0, unread.value - 1);
  } catch {}
}

function goPage(p: number) {
  page.value = Math.max(1, Math.min(p, maxPage.value));
  fetchList();
}

let cleanup: (() => void) | null = null;

onMounted(async () => {
  await fetchList();

  if (user.value) {
    const channel = pulse.channel(`user:${user.value.id}`);
    channel.listen("notification.created", (payload: any) => {
      items.value.unshift(payload);
      unread.value++;
    });
    cleanup = () => channel.stopListening("notification.created");
  }
});

onUnmounted(() => {
  cleanup?.();
});
</script>

<style scoped>
.notifications-page {
  padding: 0.5rem;
}

.notification-row {
  border-bottom: 1px solid var(--app-border);
  transition: background 0.15s;
}

.notification-row:hover {
  background: var(--app-surface);
}

.notification-row.unread {
  background: rgba(var(--bs-primary-rgb), 0.04);
}

.notification-title {
  font-weight: 500;
}

.notification-body {
  font-size: 0.9rem;
  color: var(--bs-secondary-color);
}

.notification-time {
  font-size: 0.8rem;
  color: var(--bs-secondary-color);
}

@media (max-width: 768px) {
  .notifications-page {
    padding: 0;
  }
}
</style>

Step 4: Add the route ​

In src/resources/src/router/index.ts, add a child route under the dashlayout children array:

ts
{
  path: "/",
  name: "dashlayout",
  component: () => import("@/layouts/Layout/index.vue"),
  redirect: { path: "/" },
  children: [
    {
      path: "/",
      name: "dashboard",
      component: () => import("@/pages/dashboard/index.vue"),
      meta: { requiresAuth: true }
    },
    {
      path: "/notifications",
      name: "notifications",
      component: () => import("@/pages/notifications/index.vue"),
      meta: { requiresAuth: true }
    }
  ]
}

Real-time updates ​

The NotificationBell.vue uses Pulse (Socket.IO) to listen for real-time notifications:

ts
import { pulse } from "@/plugins/pulse";

const channel = pulse.channel(`user:${user.value.id}`);
channel.listen("notification.created", (payload: any) => {
  items.value.unshift(payload); // prepend to dropdown
  unread.value++; // increment badge
});

When notify() is called with broadcast: true, the "notification.created" event fires via Socket.IO. The Pulse channel listener picks it up and updates the bell badge and dropdown in real-time — no polling, no page refresh.


Other Frontends ​

For React, Svelte, Solid, Astro, or any other frontend framework, create equivalent components using the same API endpoints (/api/notification/*). The backend is framework-agnostic — all you need is:

  • GET /api/notification — paginated list
  • GET /api/notification/unread-count — badge count
  • PATCH /api/notification/{id}/read — mark as read
  • PATCH /api/notification/read-all — mark all as read
  • DELETE /api/notification/{id} — delete

For real-time updates, connect to Socket.IO and listen for the "notification.created" event on the user:{id} room.

Released under the MIT License.