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>| Call | Purpose |
|---|---|
POST /api/notifications/{address}/feed/challenge | Returns a nonce |
POST /api/notifications/{address}/feed/session | Send nonce and signature, receive token and expiresAt |
GET /api/notifications/{address}/feed?limit=50 | The feed page: items and nextCursor |
GET /api/notifications/{address}/feed/unread-count | The badge value |
POST /api/notifications/{address}/feed/read | Mark 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
| Field | Type |
|---|---|
id | Number |
type | The notification type (see below) |
title | Display title |
body | Display body |
data | An untyped bag of routing hints, or null |
read_at | Timestamp or null |
created_at | Timestamp |
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.
| Type | Channels | Trigger |
|---|---|---|
post-comment | expo, database, web-push | Someone comments on a post you authored |
invitation-claimed | expo, database, web-push | Someone redeems an invitation you created |
incoming-transfer | expo only | Any incoming SpendTx above NOTIF_MIN_AMOUNT_AETTOS |
announcement | expo only | Authored by the Superhero team |
room-membership | expo only | You are added to or removed from a token-gated room |
room-messages | expo only | New 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:
| Type | Destination | Actor field |
|---|---|---|
post-comment | /post/ plus data.commentId, falling back to data.parentPostId | data.commenter |
incoming-transfer | /wallet | data.sender |
invitation-claimed | /wallet | data.claimer |
room-membership | /trends/tokens/ plus data.saleAddress | None |
room-messages | /trends/tokens/ plus data.saleAddress | None |
announcement | None; the row renders as non-navigable | None |
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:
- Self-action. You are never notified about your own reply.
- Preference. The recipient's opt-out is checked before the rate cap, so an opted-out author does not spend their budget silently.
- Per-recipient rate cap. A fixed Redis window allows
NOTIF_POST_COMMENT_RATE_CAPcomment notifications perNOTIF_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.
| Call | Purpose |
|---|---|
GET /api/notifications/web-push/vapid-public-key | The server's public key |
POST /api/notifications/{address}/web-push/subscription | Register endpoint, keys and the user agent |
DELETE /api/notifications/{address}/web-push/subscription | Deregister by endpoint |
The client reports one of five states:
| State | Meaning |
|---|---|
unsupported | The browser has no service worker, push manager or notification API |
unconfigured | The backend has no VAPID key |
denied | The user refused the OS permission |
default | Not asked yet, or not subscribed |
subscribed | Registered |
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
| Endpoint | Purpose |
|---|---|
POST /api/notifications/preferences/challenge | Challenge for a preference write |
GET /api/notifications/{address}/preferences | Read the account's preferences |
POST /api/notifications/{address}/preferences | Write them |
POST /api/notifications/devices/challenge | Challenge for a device registration |
POST /api/notifications/devices | Register a device |
DELETE /api/notifications/devices | Deregister 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 walletOthers 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-commenttype is the most common notification, and the_v3suffix rule explains its deep link. - Chat:
room-membershipandroom-messages, and the per-room mute control. - Tipping: an incoming tip arrives as a plain transfer.
- Invite links: what produces an
invitation-claimednotification. - WebSocket API: the app's other socket, for token activity.