Fitnit · retention
iOS gives you exactly two moments to speak to someone who is leaving, and neither one is the delete gesture itself. One is a shortcut you park in the Home Screen long-press menu, directly above Remove App. The other is Apple’s Retention Messaging API, which fires only in Settings → Subscriptions. Fitnit uses neither today, and one of the two can reach roughly two people.
Six reasons, then a second step that acts on the one you picked: a price answer opens the plans, “not using it enough” opens reminders, a bug opens the bug report, and the open-ended ones get a text box. The answer is recorded whichever way you leave, so “Just delete it” still tells us something — which is the point, and the reason this was worth building before knowing whether it saves anyone.
Not a retention wall, deliberately. Apple keeps Remove App visible and red underneath, and Expo’s own note on this pattern is that the destination has to genuinely help. A screen that only stalls is a dark pattern and reads as one.
It is not a delete interceptor. There is no such API on iOS — deletion
happens on the Home Screen with your app not running, and nothing notifies you. What Cal AI
added is an ordinary UIApplicationShortcutItem: the same mechanism behind
“Edit Home Screen” and “Share App”. It sits in that menu permanently,
on every long-press, whether or not anyone is deleting. The entire trick is writing copy that
assumes why you opened the menu, and letting iOS place it just above the red row.
Apple caps it at four app-provided items and always keeps Remove App last and red. You cannot reorder around it, disable it, or learn that it was tapped. The shortcut is a nudge shown to everyone — its whole leverage is adjacency.
They are unrelated systems, they reach different people, and only one of them can carry a price.
| Home Screen quick action | Retention Messaging API | |
|---|---|---|
| Fires when | Any long-press of the icon. Always present. | Only on Settings → Subscriptions → Cancel. |
| Who it reaches | Everyone with the app installed — free, trial, lapsed, paying. | Active paying subscribers only. |
| What you can show | Title (66 char) + one subtitle line + an SF Symbol. That is the whole surface. | A card: title, subtitle, optional 3840×2160 PNG, a switch-plan option, or a signed promotional offer. |
| Discount possible? | Yes — but after the tap, on a screen you fully control. | Yes — inline, in Apple’s own sheet. |
| Apple review | None beyond normal app review. | Per message, per locale. |
| Backend | None. Info.plist entry + a route. | A live endpoint answering in <700ms. RevenueCat hosts it. |
| Status | Not built | Approved by Apple, not configured |
Real figures, 4 September 2026. Accounts and sign-ins from Supabase
auth.users; subscription and active-user counts from RevenueCat over the
trailing 28 days.
last_sign_in_at as an activity metric. Supabase bumps it
on an actual sign-in event only — a returning user whose refresh token is still valid
opens the app, uses it, and never touches the column. It reads 72 for the last 30 days, of
which 67 are brand-new accounts and 5 are re-authentications. It is a signup counter
wearing an activity label. The honest activity numbers above come from
history_entries, food_entries and workouts: 39
distinct people logged something in 30 days, 16 in 7.
Retention Messaging — the one Apple made us apply for, the one with the signed offers and the 700ms budget — is addressable to two people. It is worth switching on because it costs nothing to run once RevenueCat holds the endpoint, and because the subscriber count is supposed to grow. It is not this quarter’s lever.
The quick action reaches the other 5,029. That is the asymmetry worth acting on: 98 people opened Fitnit for the first time in the last 28 days, and the whole app has 2 active subscriptions. Almost everyone who leaves Fitnit leaves without a subscription to cancel, so the cancel sheet never fires for them and we never hear a word about why.
The shortcut launches the app with a payload; from there it is an ordinary route. Three screens, branching on whether there is money involved.
Before you go
One tap. It goes straight to Caden, who builds this app, and it changes what gets built next.
1 · Reason
the only churn data that exists
Paying · $9.99/mo
2a · SubscriberRETAINM499 — 3×$4.99, already live in ASC
Free · 12 days in
Deleting the app deletes the graph. Here is the year, at the price we give people who nearly left.
2b · Free or lapsed
where 5,029 of 5,031 accounts sit
RETAIN59 is an App Store
promotional offer, and those only redeem for someone with a current or expired
subscription. Checked in ASC: all a never-subscriber can be given is the
introductory offer, which is a 1-week free trial on annual and monthly —
exactly what the normal paywall already hands them. So there is no extra discount for the
other 5,029 accounts without creating a new, cheaper App Store product: a new SKU, priced
across 175 territories, through review. That is a pricing decision, not a wiring job, and
it is where this stops.
What shipped instead, in v1.138.0. A RevenueCat offering called
retention — annual first, monthly second, weekly deliberately excluded
— and the leaving screen’s price answer routes to the existing paywall with
?offering=retention. At checkout the paywall tries the promotional offer named
in that offering’s own metadata. Eligibility is not computed here: StoreKit only
populates product.discounts for someone who qualifies, so a never-subscriber
finds no match and pays list price. That fallback is the common path, not the error
path.
Reusing the paywall rather than building a second purchase surface was the call: it already carries the auth gate, restore, error handling, the AppsFlyer revenue event and the funnel capture. A save-offer screen would have had to duplicate all of it, and then drift from it.
Reason 1 — “too expensive” — routes to price. Everything else should not. “Missing something I need” wants a text field and a reply; “too buggy” wants a bug report with the version attached; “not using it enough” wants a reminder schedule, not a discount. Discounting a usage problem buys a cheaper churn three months later.
Almost never — and the one mechanism that could tell us throws the answer away.
supabase/functions/send-push/index.ts already read Expo’s
push tickets and looked for DeviceNotRegistered, which is exactly the uninstall
signal. It then deleted the row:
// Prune tokens that Expo says are dead (DeviceNotRegistered = uninstalled).
if (ticket.details?.error === 'DeviceNotRegistered') {
tokensToDelete.push(devices[i].expo_push_token);
}
…
await supabase.from('user_devices').delete() ← the churn event, discarded
.eq('user_id', payload.recipient_user_id)
.in('expo_push_token', tokensToDelete);
No timestamp, no reason. The comment even named it correctly and the code still dropped it on the floor.
Fixed in v1.136.0. The row is now retired rather than deleted
— deactivated_at and deactivation_reason get stamped, the
device lookup filters to live tokens so a dead one is never pushed to again, and
re-registering the same token clears the stamp when someone reinstalls. The reason column
records the ticket error verbatim rather than the word “uninstalled”, because
DeviceNotRegistered also covers notifications being switched off and a token
rotated by a restore.
Proved against production rather than assumed: a seeded bogus token, pushed
through the real pg_net trigger, returned
{"delivered":1,"retired":1}, came back stamped instead of removed, and left the
live token beside it untouched. That second half matters — a push path that swallows
its own errors cannot tell you it is broken.
The harder problem is coverage. The signal only exists for a device that holds a push token, and only fires when we actually send that person a notification:
user_devices, against 5,031 accounts. Whatever we
do with the DeviceNotRegistered signal, it can currently observe six
people.
Three different things share the word “notifications”, and only one of them needs a token:
| Layer | What it is | Needs a token? | Used by |
|---|---|---|---|
| Authorization | The one iOS alert grant, via requestPermissionsAsync(). Gates both rows below. |
No — it is the prerequisite | — |
| Local notifications | Scheduled on the device with scheduleNotificationAsync. No server, no
network, no token. |
No | Workout reminders, daily-challenge 9am |
| Remote push | APNs registration → Expo push token → a user_devices row →
the send-push edge function. |
Yes — this is “register for push” | Messages, friend requests, social |
registerDeviceForPush(user.id) does run on every launch for a
signed-in user (app/_layout.tsx:314). But the first thing it does is ask for a
token, and getExpoPushToken() only checks authorization:
// src/lib/notifications/push.ts:39
const { status } = await Notifications.getPermissionsAsync();
if (status !== 'granted') {
return null; ← silent, every launch
}
It never requests. The only call to
requestPermissionsAsync() in the entire app is
askForPermission in app/settings/notifications.tsx:186 —
reachable by opening Settings → Notifications and tapping a toggle. The ten onboarding
screens never ask. So for all but a handful of people iOS has never shown the permission
dialog at all, no token is ever issued, and no row is ever written. Six is exactly what you
would predict.
A second, smaller bug sat behind it: askForPermission rescheduled
local reminders on success and never called
registerDeviceForPush. The registration effect is keyed on
user?.id, which had already fired at launch. So even the user who did find the
toggle got no push token until the next cold start.
Both fixed in v1.136.0. There is now an onboarding step — step 10
of 11 — that names the three things Fitnit sends before spending the one-shot system
dialog, with a “Not now” that advances without spending it.
ensurePushPermission() only prompts while the status is still undetermined, and
enablePushNotifications() requests and registers in one call, so the Settings
toggle takes effect immediately instead of at the next cold start.
One trap worth recording: the screen was first written as
app/(onboarding)/notifications.tsx. A route group adds no URL segment, so that
resolves to /notifications — the same path as
app/notifications/index.tsx, the inbox. The onboarding push would have landed on
the wrong screen, and nothing in a typecheck can see it. Renamed to alerts.
ios/Fitnit/FitnitRelease.entitlements — the Release
configuration’s entitlements, per project.pbxproj — sets
aps-environment to development. Read on its own that says
TestFlight and App Store builds register against the APNs sandbox, every production
push returns DeviceNotRegistered, and the prune above deletes the tokens of live
users, making the six rows self-inflicted.
It is not what happens. Checking the actual shipped
build/export/Fitnit.ipa (v1.97.0, build 281) rather than the source file:
| Release entitlements file | Shipped IPA | |
|---|---|---|
aps-environment | development | production |
get-task-allow | true | false |
| Profile | Apple Development | iOS Team Store |
Xcode’s Distribute step re-signs with the distribution profile and rewrites the entitlement, which is why the local archive is development-signed and the upload is not. Editing the file would have broken the archive, since a development provisioning profile cannot carry a production APNs entitlement. Left alone deliberately.
The rule this is an instance of: read what actually shipped, not the config that describes what should ship. A source file one step upstream of a re-signing pipeline is not evidence about the artefact.
| Option | Cost | What it gets you | What it cannot do |
|---|---|---|---|
| Stop deleting the row ✓ done retire, don't delete — v1.136.0 |
~30 lines +1 migration |
A real churn cohort keyed to the Supabase user id, which already lines up with PostHog and RevenueCat. | Still limited to people we push to. |
| Ask for permission at all ✓ done onboarding step 10 — v1.136.0 |
1 screen +2 helpers |
The actual blocker. Everything below scales with it: no prompt meant no token meant no row. | Nothing yet — it has to reach real installs before the number moves. |
| Silent-push sweep scheduled, content-available |
1 cron fn | Turns a signal that only fires on real sends into weekly coverage of the whole token base. | Multiplies six by one. Worthless until push adoption is fixed. |
| AppsFlyer Uninstall Measurement | APNs .p8 + 1 call |
Industry-standard uninstall reporting attributed to media source. Good for ad ROI. | Needs the raw APNs token, not the Expo one. Same permission dependency. |
| The quick action ✓ done v1.137.0 |
Info.plist + 1 screen |
The only thing on this list that captures a reason, and the only one that works with zero notification permission. | Cannot tell you a deletion happened, only that someone opened the menu. |
Worth stating plainly: iOS will never tell you a deletion happened at the moment it happens, and will never tell you why. Every row above is an inference from silence. The quick action is the only surface in the operating system where a leaving user can type a sentence, which is why it is worth building even though its retention effect is unproven.
Nothing is wired — no UIApplicationShortcutItems in
ios/Fitnit/Info.plist, no quick-actions package in
package.json.
expo prebuild to get there — per CLAUDE.md it
regenerates ios/ and deletes 24 hand-maintained native files.expo-quick-actions and npx pod-install. It is a
native module, so it needs npx expo run:ios, not a reload.useQuickActionRouting() in the root layout, which reads
the href out of the item’s user info.RETAINM499, everyone else → a discounted offering that does not depend on
promotional-offer eligibility.<key>UIApplicationShortcutItems</key>
<array>
<dict>
<key>UIApplicationShortcutItemType</key>
<string>leaving-feedback</string>
<key>UIApplicationShortcutItemTitle</key>
<string>Deleting? Tell us why.</string>
<key>UIApplicationShortcutItemSubtitle</key>
<string>Two taps, and we'll make it right.</string>
<key>UIApplicationShortcutItemIconType</key>
<string>UIApplicationShortcutIconTypeCompose</string>
<key>UIApplicationShortcutItemUserInfo</key>
<dict><key>href</key><string>/feedback/leaving</string></dict>
</dict>
</array>
Expo’s own note on this pattern, and the sane reading of App Store review: the destination has to genuinely help. A screen that only stalls is a dark pattern and reads as one. The reason picker earns its place because the answers are useful to us whether or not the person stays.
| Thing | State | Note |
|---|---|---|
| Push infrastructure | Working | pg_net, vault secret, two enabled triggers, send-push deployed. create_notification pushes too, so the whole inbox does. |
| Production APNs entitlement | Correct | Verified in the shipped IPA, not the source file. |
| Permission prompt | Fixed v1.136.0 | Onboarding step 10, plus the Settings toggle registering immediately. |
| Uninstall signal recorded | Fixed v1.136.0 | Retired, not deleted. Proved against production. |
| Token coverage | 6 of 5,031 | Only moves once the fix ships and installs land. |
| Quick action | Shipped v1.137.0 | The reason capture, and the only thing here that works with zero notification permission. |
| Reasons collected | 0 so far | The table is empty until the build reaches real installs. That is the number to watch. |
| Retention offering + save offer | Shipped v1.138.0 | RC offering retention; promo offer applied at checkout when the store says the customer qualifies. |
| A discount for non-subscribers | Not possible today | Needs a new, cheaper App Store product. A pricing decision, not a wiring job. |
| Retention Messaging | Approved, not configured | Addressable to 2 people. Free to run once RevenueCat holds the endpoint. |
Board written 4 September 2026 · mockups are CSS at 1px = 1pt, not screenshots
Counts: Supabase auth.users, user_devices · revenue: RevenueCat proj226cc964, trailing 28d
Nothing on this board is implemented — see ios/Fitnit/Info.plist, supabase/functions/send-push/index.ts