Notifications
The notification feed is flat: new followers, reactions on your posts, and comments, in
one reverse-chronological list. There is no aggregation (“5 people liked your post”
grouping) — each event is its own row, with independent seen_at/read_at timestamps
and running unseen/unread counts.
Three things create a notification: someone follows you, someone reacts to your activity, and someone comments. A comment notifies the activity author. A reply notifies the activity author and the parent comment’s author. One row when those are the same person. The writer is never notified. Posting an activity notifies nobody, and neither does anything imported through the batch API, which is silent by design.
What will never create a second notification
Section titled “What will never create a second notification”A notification is unique per (owner, verb, actor, object) — one row, forever. Two
consequences worth knowing before you go looking for a bug:
- A re-follow is silent. Follow, unfollow, follow again, and the recipient still has exactly one “B started following you” — the original, at its original timestamp, and their unread count does not move. This is deliberate: without it, unfollow/re-follow is a one-click way to ping someone repeatedly, and a notification feed that can be used that way is a notification feed people turn off.
- The webhook and the notification disagree here, on purpose.
follow.addedfires on every genuine re-follow (its idempotency key folds in the edge’s creation time, so only a job retry is deduped). The notification does not. If you are reconciling your own records against ours, use the webhook as the event log and notifications as the recipient’s inbox — they answer different questions.
Removing a reaction leaves its notification in place too, and deleting a comment also leaves the bell, for the same reason: an inbox that rewrites its own history is worse than one that is occasionally out of date.
A user token always acts for itself. A server token has no identity, so every
server-side notifications call names the user it acts for via owner — that’s what
makes sending push notifications or digest emails from your backend possible. Omitting
owner on a server token is FORBIDDEN rather than a cross-user read. See
Authentication & tokens for the two token kinds.
Read notifications
Section titled “Read notifications”// NotificationPage: { results, unseen, unread, next }const page = await client.notifications.get({ limit: 20 })page.results // Notification[]page.unseen // count of not-yet-seen notificationspage.unread // count of not-yet-read notificationsimport { useNotifications } from '@dropinnodex/react'
function Bell() { const { notifications, unseen, unread, isLoading, loadNext, hasNext } = useNotifications() return <span>{unseen} new</span>}// `owner` is required on every server-token call — it names the user you act for.const page = await server.notifications.list({ owner: 'maya', limit: 20 })page.unseen // e.g. drive a push notification when this crosses zeroWhat a notification contains
Section titled “What a notification contains”Each row carries everything needed to render it and to make it clickable — there is no second request to resolve who did what to which thing.
| Field | Type | What it holds |
|---|---|---|
id |
string | The notification’s own id — pass it to markSeen/markRead. |
verb |
'follow' | 'react' | 'comment' |
Which event this is. |
actor |
string | Who caused it, as a feed ref: "user:bob". |
object |
string | The thing it points at — see below. |
reaction_kind |
string | null | For react, the kind ("like"); null for follow and comment. |
activity_id |
string | null | Set on comment: the activity to open. Null for follow and react. |
actor_user |
object | null | The actor’s profile, pre-enriched from users.custom — name, avatar, whatever you stored. Null if actor isn’t a user: ref. |
created_at |
string | ISO timestamp. |
seen_at / read_at |
string | null | Null until marked. |
object is your deep-link target, and what it means depends on verb:
verb: 'react'→objectis the activity id that was reacted to.verb: 'follow'→objectis the followed feed ref (e.g."user:maya").verb: 'comment'→objectis the comment id. Open the post withactivity_id.
So a clickable row is just a switch:
function href(n: Notification): string { if (n.verb === 'comment') return `/activity/${n.activity_id}` // the post they commented on return n.verb === 'react' ? `/activity/${n.object}` // the post they reacted to : `/profile/${n.actor.replace('user:', '')}` // the person who followed you}
<a href={href(n)} onClick={() => markRead([n.id])}> {n.actor_user?.custom.name ?? n.actor} {n.verb === 'react' ? `reacted ${n.reaction_kind}` : n.verb === 'comment' ? 'commented' : 'followed you'}</a>Marking a single row read on click is what drives the bold/unbold state per row;
unread on the page response updates accordingly.
Grouping (“3 people liked your post”)
Section titled “Grouping (“3 people liked your post”)”Notifications are flat — ten likes on one activity are ten rows. There is no
server-side aggregation (spec §1). Group them in your UI on (verb, object): every
reaction on the same activity shares an object, so a groupBy over the page you already
fetched gives you the “3 people liked your achievement” row.
const groups = new Map<string, Notification[]>()for (const n of page.results) { const key = `${n.verb}:${n.object}` groups.set(key, [...(groups.get(key) ?? []), n])}// each group: same target, N distinct actors → render one row with a countThis is safe to do because the same actor cannot produce two rows for the same target —
(owner, verb, actor, object) is unique server-side, so a double-like can’t inflate a
group.
Two limits worth knowing: a group can straddle a page boundary, and the count you can show is bounded by the page you loaded. For a 20-row bell neither usually matters — render “20+” if you hit the ceiling.
Mark seen
Section titled “Mark seen”Pass specific ids, or omit/pass an empty array to mark every notification seen.
await client.notifications.markSeen(['notif_1', 'notif_2']) // specific idsawait client.notifications.markSeen() // mark allconst { markSeen } = useNotifications()await markSeen() // optimistically zeroes `unseen`, rolls back on errorawait server.notifications.markSeen({ owner: 'maya', ids: ['notif_1'] })await server.notifications.markSeen({ owner: 'maya' }) // mark allUseful after delivering a push notification, so the bell doesn’t re-announce what the user already saw on their lock screen.
Mark read
Section titled “Mark read”Same id-list-or-all contract as markSeen.
await client.notifications.markRead(['notif_1'])await client.notifications.markRead() // mark allconst { markRead } = useNotifications()await markRead()await server.notifications.markRead({ owner: 'maya', ids: ['notif_1'] })await server.notifications.markRead({ owner: 'maya' }) // mark allRead unseen/unread counts
Section titled “Read unseen/unread counts”Counts come back on every notifications.get() response, and are kept live by
useNotifications (updated by refresh(), and optimistically by markSeen/markRead).
const page = await client.notifications.get()page.unseenpage.unreadconst { unseen, unread } = useNotifications()Keep notifications fresh
Section titled “Keep notifications fresh”Use live: true in React to replace a timer with a cheap change signal. The list is
refetched only when something actually changed, and nothing runs while the tab is hidden:
const { unseen, unread } = useNotifications({ live: true })Alternatively, poll manually using head() to detect changes without fetching the full list:
// Get a cheap change signal (Redis only)let lastSeen = nullconst { latest } = await client.notifications.head()
// Only fetch if the head changedif (latest !== lastSeen) { lastSeen = latest const page = await client.notifications.get({ limit: 20 }) // Use page.results, page.unseen, page.unread}// Or call refresh() manually without liveconst { unseen, unread, refresh } = useNotifications()// Call refresh() on visibility change, button click, etc.Related
Section titled “Related”- Follow — the follow that produces a new-follower notification, and why the batch import path produces none.
- Reactions — a notification trigger, alongside follows.
- Comments — the third trigger.
- Webhooks — deliver the same events to your backend instead of polling, e.g. to send a push notification.
- Pagination — how
nextworks on the notification list.