The complete notification system: every trigger, who receives it, what
it says, what it shows, and where a tap lands. The doctrine in one line: we announce
people, and we announce openings — never mechanics, never nags. Almost every push
title is a human name; the two exceptions are the app itself (rare announcements) and
a facepile (batching, V2). If a notification wouldn't make sense spoken aloud by that
person, the copy is wrong. In-app surface: notifications.html.
Title = the actor's name. Body = the action + a content preview
(≤150 chars, cut at a word, no emoji). Step and journey pushes carry the step photo
(contentImageUrl, 150px): a compact thumbnail collapsed, the full photo
expanded. Comment-family pushes stay text-only — the words are the content. People
pushes carry the face only.
largeIcon slot takes
the actor's face; the step photo expands below as BigPicture. The person is
visible before a single word is read.15 live types from the backend enum, two proposed recognition types, and the deferred batch pair. Push title is always the actor; the example column shows title + body verbatim. Tap destinations are contract — the row and the push land on the same screen.
| Notification | Fires when | Who gets it | The push | Media | Tap opens | Status |
|---|---|---|---|---|---|---|
| Inside the window · immediate — the whole product happens here | ||||||
| Stepped with · heart | A heart lands inside the step's 24h window | Step owner | Maya OkonkwoStepped with you by heart: Trail run before work — up the ridge and back. |
Face + step photo | Step detail | Live |
| Stepped with · comment | A comment lands inside the window (commenting is stepping with) | Step owner | Linh TrầnStepped with you: so proud of this one |
Face + step photo | Step detail, comments open | Live |
| Stepped with · share | A share inside the window | Step owner | Devin ColeStepped with you by share: Trail run before work… |
Face + step photo | Step detail | Live |
| After the window · normal — late love still counts, quietly | ||||||
| Heart | A heart after the window closed | Step owner | Priya NairHearted your step: First open-water session done. |
Face + step photo | Step detail | Live |
| Comment | A comment after the window closed | Step owner | Jules MoreauCommented: this is the kind of consistency I aspire to |
Face only | Step detail, comments open | Live |
| Share | A share after the window closed | Step owner | Theo SalasShared your step: First open-water session done. |
Face + step photo | Step detail | Live |
| Conversations · high — someone is talking to you, not about you | ||||||
| Reply | A reply to your comment, on anyone's step | Comment author | Hana WatanabeReplied to you: same happened to me week one |
Face only | Step detail, comment highlighted | Live |
| Mention | @username in any comment you're allowed to see | Mentioned user | Ray DelgadoMentioned you: @chaucao you have to try this route |
Face only | Step detail, comment highlighted | Live |
| People · immediate — the relationship events | ||||||
| New follower | Someone follows you (public account) | You | Kofi AsanteStarted following you |
Face only | Their profile · row offers Follow back, in the state you are already in | Live |
| Follow request | Someone asks to follow you (private account) | You | An NguyễnRequested to follow you |
Face only | Their profile · row holds inline Accept / Decline | Live |
| Request accepted | Your follow request is approved | The requester | Vy ĐặngAccepted your follow request |
Face only | Their profile | Live |
| Journeys · normal — one push per step, chosen by the ladder below | ||||||
| First step | Someone you follow starts a journey (fires at publish, when media finishes) | All their followers | Lena OrtizStarted a journey: Bake sourdough from scratch — day one is live |
Face + step photo | Step detail | Live |
| Achievement | The final step publishes — the journey is achieved | All their followers + journey followers, deduplicated | Marco FerriFinished Learn to play guitar — the finale is live |
Face + final-step photo | Journey | Live |
| New step | A regular step lands in a journey you follow | Journey followers only | Vy ĐặngNew step in Learning guitar at forty: barre chords finally ring clean |
Face + step photo | Step detail | Live |
| Recognition · high — the emotional peaks; spec + enum addition required | ||||||
| Recognition received | Someone becomes an Active Companion of your journey (living gain), or a distinction names at settlement — never on fade or regain | Journey owner | Priya NairBecame a Companion of your Learn to freedive |
Face · tier chip in-app | Journey, people panel | Proposed |
| Recognition earned | You cross a tier on someone's journey | The supporter (you) | Marco FerriYou're now a Celebrator on Learn to play guitar |
Face · tier chip in-app | Journey | Proposed |
| Stepo · the only non-person voice — used sparingly | ||||||
| Announcement | Platform news worth interrupting for | Everyone, or signed-in users | StepoShare your journey with a link — new in Stepo |
App mark | Whatever it announces | Live |
| Batched · 30-minute window, same action on the same step — schema designed, deferred | ||||||
| Batched step-withs | 2+ people do the same thing to one step within 30 minutes | Step owner | Jules and 6 othersStepped with you by heart |
Facepile (≤4) + step photo | Step detail, interactions list | V2 |
A published step could legally trigger three notifications; it sends exactly one, and never before its media finishes processing — nobody taps into a placeholder. Followers of both the person and the journey are deduplicated.
If the step is final, the finale push goes out and suppresses the rest — it's the one moment worth everyone's lock screen.
Day one of a journey outranks step twelve. Journey followers can't exist yet, so this one goes to the person's followers only.
Regular steps ping journey followers only — the everyday heartbeat, at normal priority. The owner's followers meet regular steps in the feed; what reaches them as pushes is first steps and finales.
Instagram's Seen / Read split, server-authoritative: the number on the
icon is always a fresh COUNT of unseen rows, pushed as an absolute value
with every notification — never a client-side +1, so it can't drift. The Activity tab
carries that same number, counting to 9 and then reading 9+.
SeenAt = nullEach new row is unseen. The Activity tab and the app icon both carry the count, so the number waiting is legible before anything is opened.
SeenAt + ReadAt stampArriving is the read. The "New" section is whatever arrived since your last visit — a warm tint and a dot — and it is frozen for the visit, so the rows you came to see stay where they were while you read them. What lands while you are standing there joins New and waits unread for the next visit.
A tap goes where the row points and nothing else; the reading already happened
at the door. Row-level ReadAt is what carries a row out of New on the
next visit, and every mutation returns both counts, so the client reconciles
without a second request.
The channel above only exists if the OS lets us use it, and Stepo's loop travels through it: a user without notifications cannot know a journey they follow is live, so they are not experiencing the product at all. The two failure modes are asymmetric — asking too often costs a sheet swipe, never converting costs the user, permanently and invisibly — so the cadence leans toward asking, bounded only by what the OS makes mechanically pointless. The gate is live OS state, never an app latch that stands in for it: a primer may show whenever the OS will still prompt and the cadence allows it. iOS self-latches after its one alert; Android grants a second prompt after a soft deny and the cadence spends it. The ruling lives in DESIGN.md "The one shot at the prompt".
| Surface | Trigger | Cadence | The sheet says | Status |
|---|---|---|---|---|
| Onboarding beat | The last onboarding screen | Once, routed | Only the moments that matter. That's the whole list. Only people. |
Live |
| Follow sheet | A follow lands | 1st follow, then every 2nd | You're following Priya. Know when their next step goes live. A step is live for 24 hours, and stepping with someone happens inside that window. |
Live |
| Interaction sheet | Heart, comment or step-with sent | Every 3rd | You showed up for someone. They step for 24 hours. Know when the people you support are live, so you can be there while it counts. |
Live |
| Publish sheet | A step goes live | Every publish, bypasses the cooldown | Your step is live. For the next 24 hours, people can step with you. We can tell you when they do: a heart, a comment, someone sharing your step. |
Live |
| Settings variant | Any of the three, once the prompt is spent | 1 per session, decaying to 1 per 7 days | Notifications are off for Stepo. Turn on notifications in Settings, it takes ten seconds. |
Live |
| Settings banner | Permission off | Passive, always present | settings-notifications.html The door that never asks. |
Live |
notif_ask_shown / _accepted / _deferred / _os_granted / _os_denied, carrying surface, variant, counter value and session number. The cadence was set on judgment; the first real cohort judges it back.What keeps the lock screen worthy of trust.
The metric guarding all of this: notification mute rate. Stepo's core loop — stepping with someone inside 24 hours — travels through this channel, so a muted Stepo is a dead Stepo. Threads can afford milestone and suggested-content pings because it monetizes the attention they harvest; we spend every push from a trust budget the product itself depends on.
Seven toggles plus the master switch, grouped by relationship
direction — the canonical layout is settings-notifications.html.
Toggles govern push delivery only; the Activity screen always shows every row.
Announcements ride the master switch. Muting on Stepo filters the feed only; it does
not filter notifications (blocking does). Per-person / per-journey overrides are V2.
NotificationJobHandler + the spec table. ② Recognition —
the spec's priority list says "recognition earned" but no recognition type exists
in the table or the enum; add RecognitionReceived /
RecognitionEarned (rows are already mocked in-app). ③ Media — iOS rich
push needs a Notification Service Extension in the Flutter build; Android's
largeIcon needs the actor's avatar URL in the FCM payload (verify the
serialized DTO carries it). ④ Grouping — set a per-step thread-id /
collapse key so one busy window stacks. ⑤ Batching stays V2; the
NotificationBatch schema in the README is the agreed design.