← Lab

Fitnit · retention

Deleting?
Tell us why.

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.

Built and shipped in v1.137.0. The two photographs below are real screenshots off a simulator running the real build. The drawings further down are the mockups this board started as, kept because the comparison is the useful part — they are CSS at 1 pixel per iOS point, not screenshots, and the third one is still unbuilt. Every number is real, from Supabase and RevenueCat on 4 September 2026.

What shipped

Shipped · the long-press menu
Fitnit's Home Screen long-press menu showing a Deleting? Tell us why. shortcut above Edit Home Screen and Remove App
Shipped · where it lands
The Before you go screen, asking what went wrong with six reasons to choose from

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.

What Cal AI actually shipped

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.

The two interception points

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

Which one can Fitnit actually use?

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.

5,031Accounts, all time
1,669Ever logged anything
122Opened the app, 28d
39Logged anything, 30d
16Logged anything, 7d
2Active subscriptions
Do not use 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.

What happens after the tap

The shortcut launches the app with a payload; from there it is an ordinary route. Three screens, branching on whether there is money involved.

long-press quick action /feedback/leaving reason offer or fix
9:41●●●●

Before you go

What went
wrong?

One tap. It goes straight to Caden, who builds this app, and it changes what gets built next.

Too expensive
Not using it enough
Missing something I need
Too buggy or slow
Found a better app
Continue
Just delete it

1 · Reason
the only churn data that exists

9:41●●●●

Paying · $9.99/mo

Stay three
months on us.

Save 50%
$4.99$9.99
a month for 3 months, then $9.99
Everything you have now — nothing gets locked
Cancel any time, from Settings
31
Workouts
148
Meals
9
PRs
Keep Fitnit for $4.99
No thanks

2a · Subscriber
RETAINM499 — 3×$4.99, already live in ASC

9:41●●●●

Free · 12 days in

You logged
31 workouts.

Deleting the app deletes the graph. Here is the year, at the price we give people who nearly left.

First year
$59.99$79.99
$5.00 a month, billed once
Camera rep counting & the AI coach
Your history stays, on every device
Get a year for $59.99
Just delete it
No charge until you confirm with Face ID.

2b · Free or lapsed
where 5,029 of 5,031 accounts sit

2b is the one that cannot be built. 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.

Do we know when someone deletes today?

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:

5,031Accounts
6Have a push token
3Token seen in 30d
0.12%Uninstall coverage
Six. Six rows in user_devices, against 5,031 accounts. Whatever we do with the DeviceNotRegistered signal, it can currently observe six people.

Why six — the prompt is never shown

Three different things share the word “notifications”, and only one of them needs a token:

LayerWhat it isNeeds 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.

One scare that turned out to be nothing

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 fileShipped IPA
aps-environmentdevelopmentproduction
get-task-allowtruefalse
ProfileApple DevelopmentiOS 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.

OptionCostWhat it gets youWhat 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.

Building it here

Nothing is wired — no UIApplicationShortcutItems in ios/Fitnit/Info.plist, no quick-actions package in package.json.

  1. Hand-edit the Info.plist. A static item exists from install, before first launch. Do not run expo prebuild to get there — per CLAUDE.md it regenerates ios/ and deletes 24 hand-maintained native files.
  2. Install expo-quick-actions and npx pod-install. It is a native module, so it needs npx expo run:ios, not a reload.
  3. Route it with useQuickActionRouting() in the root layout, which reads the href out of the item’s user info.
  4. Branch the CTA on RevenueCat entitlement state: subscriber → 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&apos;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.

What it would take to know if it worked

Where this stands

ThingStateNote
Push infrastructureWorkingpg_net, vault secret, two enabled triggers, send-push deployed. create_notification pushes too, so the whole inbox does.
Production APNs entitlementCorrectVerified in the shipped IPA, not the source file.
Permission promptFixed v1.136.0Onboarding step 10, plus the Settings toggle registering immediately.
Uninstall signal recordedFixed v1.136.0Retired, not deleted. Proved against production.
Token coverage6 of 5,031Only moves once the fix ships and installs land.
Quick actionShipped v1.137.0The reason capture, and the only thing here that works with zero notification permission.
Reasons collected0 so farThe table is empty until the build reaches real installs. That is the number to watch.
Retention offering + save offerShipped v1.138.0RC offering retention; promo offer applied at checkout when the store says the customer qualifies.
A discount for non-subscribersNot possible todayNeeds a new, cheaper App Store product. A pricing decision, not a wiring job.
Retention MessagingApproved, not configuredAddressable 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