Ultimate · notification spec

Every ping is a person, showing up

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.

Anatomy — the push on the lock screen

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.

iOS · collapsed
Maya Okonkwonow
Stepped with you by heart: “Trail run before work — up the ridge and back.”
The step photo rides the right slot. iOS keeps the app-icon position for Stepo's mark — the actor lives in the title.
iOS · long-press expanded
Marco Ferri2m
Finished Learn to play guitar — the finale is live
A Notification Service Extension fetches the photo and attaches it. The finale push is the highest-value tap in the app — it arrives while the Celebrator window is open.
Android · banner
Linh Trầnnow
Stepped with you: “so proud of this one”
Android's largeIcon slot takes the actor's face; the step photo expands below as BigPicture. The person is visible before a single word is read.
Grouped · thread per step
Kofi Asante5m
Stepped with you by share: “Trail run before work…”
Interactions on the same step share a thread — a busy window stacks into one card instead of wallpapering the lock screen. The badge number still counts every unseen row.

The matrix — every notification in the system

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.

NotificationFires whenWho gets itThe pushMediaTap opensStatus
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

One step, one push

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.

1

Achievement wins everything

If the step is final, the finale push goes out and suppresses the rest — it's the one moment worth everyone's lock screen.

2

First step beats new step

Day one of a journey outranks step twelve. Journey followers can't exist yet, so this one goes to the person's followers only.

3

New step is the default

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.

The badge tells the truth

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

3
A push lands. Badge = server count of rows with no SeenAt.

Arrive SeenAt = null

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

0
Open Activity — everything waiting is stamped read; both counts clear.

Look SeenAt + ReadAt stamp

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

Tap a row → you land on the exact content.

Act — the tap is travel

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.

Asking for the permission

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

SurfaceTriggerCadenceThe sheet saysStatus
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.htmlThe door that never asks.
Live

How the cadence holds

  • One anatomy, four arguments. Grabber, Archivo w800 headline, muted body, ink CTA, muted "Not now" — every sheet is the same object; only the argument changes.
  • A 5-minute cooldown binds them all, and a showing resets every counter. Counters are per-install and persisted.
  • Follow starts at the 1st press. The first follow is where push value becomes concrete; making someone follow twice before we mention it wastes the warmest beat.
  • Publish repeats and ignores the cooldown. An author going live unpermitted is the highest-stakes miss in the app, every time, and it self-selects to people who have published.
  • Hearts, comments and step-withs are one signal (supporting someone), so they share one counter.
  • A swipe-away re-arms the counters rather than closing a door forever. Only the OS closes doors.
  • The fired latch is a state input on Android. Android cannot tell "never asked" from "permanently denied", so whether our prompt has fired is the disambiguating bit. Without it, fresh installs read as blocked.
  • Every showing and outcome is capturednotif_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.

The blocked user, and the decay

  • The settings variant is the whole long tail on iOS. Once the alert is spent, a tap-through can only reach system settings, and excluding that would quietly end the cadence at the first soft "no".
  • One per session, first trigger wins. The same three triggers fire; they convert to the settings sheet instead of the primer.
  • It decays. A blocked user has already answered at the OS level, and every-session-forever is how an app earns "it won't stop nagging" reviews. After three consecutive dismissals the rate drops to one per seven days.
  • Any tap-through resets it — the counter and the rate both.
  • The resume repair is what makes it honest. A grant made in system settings returns to a running app on iOS, so subscription self-heals on resume rather than at the next process death. Without that, the sheet sends people on an errand that quietly does nothing.

The quiet rules

What keeps the lock screen worthy of trust.

Always enforced

  • Never your own actions. You don't get pinged for hearting your own step or stepping on your own journey.
  • Blocked is silent both ways. No notification crosses a block in either direction; banned and deactivated actors never ping.
  • Private content stays private. If the actor's account is private, the push masks to "@username interacted with your content" — no preview, no photo.
  • Deleted content degrades gracefully. The row stays, struck through; a tap says "This content is no longer available" and falls back to the actor's profile or the feed.
  • Publish-gated. Step pushes wait for media to finish processing — the tap always lands on the real thing.
  • A daily ceiling (100/day) backstops runaway fan-out.

Never sent — it fails the consent test

  • No countdown nags on ordinary steps. We announce that a window opened — an event someone caused. A ticking clock on every step is the app demanding attention. The one earned exception is below.
  • No streak or come-back guilt. "We miss you" is the app cheering for itself — the inversion of everything Stepo is. No streaks exist anywhere; the lock screen doesn't get one.
  • No milestone flattery. "You reached 100 followers" taps through to nothing but your own numbers. An in-app moment at most; never a push.
  • No suggested-content pushes. "Popular on Stepo" is content from strangers you never chose — the attention-economy play we are explicitly not running.
  • No re-pings. One push per step event, ever. Batching (V2) compresses; it never repeats.

Two exceptions to earn back (V2) — they pass the test the bans fail

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.

User controls

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.

All notificationsThe master switch — off means off (announcements included).
Your steps
Step-with alertsHearts, comments and shares inside your 24-hour windows.
Later engagementHearts, comments and shares after a window closes.
Replies & mentionsReplies to your comments, and @mentions anywhere.
Following
Journey starts & finishesFirst steps and achievements from people you follow.
Journeys you followEvery new step in a journey you follow — the loudest type gets its own switch.
People
New followersNew followers, and your follow requests being accepted.
Follow requestsRequests to follow you (private account).
Deltas this board creates (backlog, not silent changes): ① Copy — backend strings say "Step with you by replied:"; this board's voice ("Stepped with you: …", "Replied to you: …", the live suffix on first-step and achievement) needs a copy pass in 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.