Social
Notifications

Notifications

Superhero has two independent notification systems. The bell is a server-side feed (someone replied, someone sent you AE, a room let you in) delivered over REST plus a socket and optionally as an OS notification. The transaction banner is a purely local progress indicator for whatever you are signing right now. They share no code and no delivery path.

The bell

One signature, one session

The feature is gated on a single æternity message signature. The client asks for a challenge, signs it, and exchanges it for a bearer token that is reused for REST calls, the socket handshake and the push subscription. It never re-signs per request.

Superhero Notifications
Open notification feed session for <your ak_ address>
nonce: <nonce>
CallPurpose
POST /api/notifications/{address}/feed/challengeReturns a nonce
POST /api/notifications/{address}/feed/sessionSend nonce and signature, receive token and expiresAt
GET /api/notifications/{address}/feed?limit=50The feed page: items and nextCursor
GET /api/notifications/{address}/feed/unread-countThe badge value
POST /api/notifications/{address}/feed/readMark specific ids read, or all when omitted; returns the new unread total

The token is cached with its expiry, and concurrent callers collapse onto one bootstrap so the user sees at most one wallet prompt.

REST is the source of truth

The socket connects to the API origin's /notifications namespace and emits two events: notification with a new feed item, and unread-count with a new badge value. Live events are treated as optimistic and deduplicated by id.

On every socket connect and reconnect the client re-fetches the first feed page and the unread count and overwrites whatever the live layer accumulated. Without the socket, the bell is refresh-driven.

The in-memory list is capped at 200 items.

Feed item shape

FieldType
idNumber
typeThe notification type (see below)
titleDisplay title
bodyDisplay body
dataAn untyped bag of routing hints, or null
read_atTimestamp or null
created_atTimestamp

Everything in data is read defensively. A missing field makes a row non-navigable rather than throwing.

Types, channels, and where a click lands

The catalog has six types, and each declares which channels it goes out on. database is the channel that writes a bell row, so the web bell carries comments and invitation claims; the other four types reach mobile push only.

TypeChannelsTrigger
post-commentexpo, database, web-pushSomeone comments on a post you authored
invitation-claimedexpo, database, web-pushSomeone redeems an invitation you created
incoming-transferexpo onlyAny incoming SpendTx above NOTIF_MIN_AMOUNT_AETTOS
announcementexpo onlyAuthored by the Superhero team
room-membershipexpo onlyYou are added to or removed from a token-gated room
room-messagesexpo onlyNew messages in a room you belong to

incoming-transfer is kept off the web feed because it fires for every transfer on chain to the recipient. The client's routing table maps all six, because the same payload also feeds the mobile app:

TypeDestinationActor field
post-comment/post/ plus data.commentId, falling back to data.parentPostIddata.commenter
incoming-transfer/walletdata.sender
invitation-claimed/walletdata.claimer
room-membership/trends/tokens/ plus data.saleAddressNone
room-messages/trends/tokens/ plus data.saleAddressNone
announcementNone; the row renders as non-navigableNone

A reply opens the comment, not the parent: /post/<commentId> renders the comment with its ancestor chain above it. Post ids are stored as <id>_v3 and every in-app post link drops the suffix, with the resolver re-adding it. The backend ships routing hints (a type plus ids) rather than a URL, because the mobile app has different routes.

What suppresses a notification

Every send passes three gates before a channel sees it; post-comment shows all three:

  1. Self-action. You are never notified about your own reply.
  2. Preference. The recipient's opt-out is checked before the rate cap, so an opted-out author does not spend their budget silently.
  3. Per-recipient rate cap. A fixed Redis window allows NOTIF_POST_COMMENT_RATE_CAP comment notifications per NOTIF_POST_COMMENT_RATE_WINDOW_SEC, 20 per hour by default. Past that, further comments are dropped until the window resets. If Redis is unreachable the notification is attempted anyway.

incoming-transfer has its own floor, NOTIF_MIN_AMOUNT_AETTOS, an anti-dust threshold that defaults to 0 (off). A transfer event with no transaction hash is not dispatched.

Browser push

Push is the same feed delivered by the OS when no tab is open. It uses VAPID and the same bearer session.

CallPurpose
GET /api/notifications/web-push/vapid-public-keyThe server's public key
POST /api/notifications/{address}/web-push/subscriptionRegister endpoint, keys and the user agent
DELETE /api/notifications/{address}/web-push/subscriptionDeregister by endpoint

The client reports one of five states:

StateMeaning
unsupportedThe browser has no service worker, push manager or notification API
unconfiguredThe backend has no VAPID key
deniedThe user refused the OS permission
defaultNot asked yet, or not subscribed
subscribedRegistered

One push endpoint belongs to one address. If a second account in the same browser enables push, the server re-points the endpoint at it; the client records the owner locally and re-registers on start when needed.

The service worker

Push is handled by a dedicated worker at /notifications-sw.js, registered on demand with an origin-wide scope. It handles exactly three events (push, notificationclick and pushsubscriptionchange) and registers no fetch handler, so it cannot intercept requests on the origin it shares with the inline wallet.

On push it reads a JSON payload of title, body and data, accepting the type from either the top level or inside data, and shows a notification with the 192px PNG app icon as both icon and badge (Android Chrome drops SVG notification icons). A tag in the payload coalesces repeats of the same notification.

On notificationclick it resolves the same routes as the in-app bell. A data.url in the payload wins over the mapping after an origin check.

Preferences and devices

EndpointPurpose
POST /api/notifications/preferences/challengeChallenge for a preference write
GET /api/notifications/{address}/preferencesRead the account's preferences
POST /api/notifications/{address}/preferencesWrite them
POST /api/notifications/devices/challengeChallenge for a device registration
POST /api/notifications/devicesRegister a device
DELETE /api/notifications/devicesDeregister it

The preference keys

A preference key is a catalog type, and the six keys are exactly the types in the table above: incoming-transfer, announcement, invitation-claimed, post-comment, room-membership, room-messages. The catalog is ordered, and the mobile preferences screen renders its rows in that order.

The write is a partial upsert behind a signed challenge:

POST /api/notifications/{address}/preferences
{ "nonce": "…", "signature": "sg_… or hex",
  "preferences": [ { "type": "post-comment", "enabled": false } ] }

Only the types you list are changed; the rest keep their state. Unknown type strings are rejected against the catalog. The model is opt-out: a type with no stored row is enabled.

Devices are the mobile half

/notifications/devices registers an Expo push token, for the native apps; the web-push endpoints register a VAPID browser subscription. An expo-only type reaches a registered device, and nothing if the account has none. Both registration paths are behind their own signed challenge, and deregistration binds the signature to both the address and the token.

The per-room mute control lives with chat: it writes muted and a tri-state account-wide mute_all through POST /api/rooms/{saleAddress}/mute behind its own signed challenge. The account-wide half of that switch turns room-messages notifications off everywhere. See Chat.

The transaction banner

A distinct, entirely local system. It narrates whatever you are currently signing and confirming ("Publishing post", "Publishing reply", "Confirm in your wallet", "Confirming on blockchain") and runs multi-step flows with explicit step counts. The sponsored .chain claim is the longest:

Step 1 of 4 — reserving the name on-chain
Step 2 of 4 — submitting the sponsored claim
Step 3 of 4 — updating the name pointers
Step 4 of 4 — transferring the name to your wallet

Others cover token creation, swaps, wrapping and unwrapping AE, liquidity changes, and signing a profile update. None of these are recorded in the notification feed.

No tip notification

There is no tip type in the catalog. incoming-transfer does fire for a tip, since a tip is a plain SpendTx, but it is expo-only and never reaches the bell. An incoming tip produces a mobile push if you have a registered device, and nothing in the web app. See Tipping.

Related

  • Comments: the post-comment type is the most common notification, and the _v3 suffix rule explains its deep link.
  • Chat: room-membership and room-messages, and the per-room mute control.
  • Tipping: an incoming tip arrives as a plain transfer.
  • Invite links: what produces an invitation-claimed notification.
  • WebSocket API: the app's other socket, for token activity.