Skip to content

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.added fires 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.

// NotificationPage: { results, unseen, unread, next }
const page = await client.notifications.get({ limit: 20 })
page.results // Notification[]
page.unseen // count of not-yet-seen notifications
page.unread // count of not-yet-read notifications

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' → object is the activity id that was reacted to.
  • verb: 'follow' → object is the followed feed ref (e.g. "user:maya").
  • verb: 'comment' → object is the comment id. Open the post with activity_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.

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 count

This 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.

Pass specific ids, or omit/pass an empty array to mark every notification seen.

await client.notifications.markSeen(['notif_1', 'notif_2']) // specific ids
await client.notifications.markSeen() // mark all

Same id-list-or-all contract as markSeen.

await client.notifications.markRead(['notif_1'])
await client.notifications.markRead() // mark all

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.unseen
page.unread

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 = null
const { latest } = await client.notifications.head()
// Only fetch if the head changed
if (latest !== lastSeen) {
lastSeen = latest
const page = await client.notifications.get({ limit: 20 })
// Use page.results, page.unseen, page.unread
}
  • 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 next works on the notification list.