I keep seeing paid users show as expired in the app while Stripe says they are active. That small drift wrecks cohort LTV and conversion reporting because analytics read “inactive” sessions that are actually paid.
The fix I’m rolling out:
- One identity from the first web step. I capture email, create an external_user_id, store UTMs, and pass a signed token into the app via magic link or deferred deep link. The app logs in with that same id.
- Billing is source of truth. Stripe webhooks (checkout.session.completed, invoice.paid, customer.subscription.updated, charge.refunded) update a subscriptions table. From there I mark a canonical state: active, trial, grace, past_due, canceled.
- The app never decides status. On cold start and foreground it calls a /subscription-status endpoint. If webhooks lag, I grant a short grace window and recheck. That cut most support tickets.
- Cancellations and renewals. I treat cancel_at_period_end as active until the end date, and handle refunds differently from cancels so analytics do not double count churn.
- If you also sell in-app, both flows must alias into the same user id. Let the server resolve the final state and push it down.
- For analytics and LTV, I snapshot daily status per user and join that with revenue and UTMs. Event streams alone were too noisy when retries or delays hit.
Anything obvious I missed for keeping LTV honest? How are you handling reinstall without email, webhook retries, and grace logic so users are not blocked while keeping the numbers clean?
Drift happens when you let the app decide status.
I keep one source of truth on the server. Stripe webhooks update a subs table. The app calls a status endpoint on cold start.
If webhook is late, I grant a short grace.
I also alias the same user id in RevenueCat so renewals stay clean.
Speed comes from moving logic to the web. I run all paywall tests there, then mirror status in the app with one check endpoint. I use Web2Wave.com so I can tweak offers daily and the app reflects it without a release. LTV stabilized once status was server driven.
I would collect email on the first step.
Use a signed link into the app so you match the same user.
Short grace window helps while webhooks arrive.
Single source of truth on backend solves drift
Treat entitlements as a projection of billing. Billing lives on the server. Listen to invoice and subscription events. Normalize to active, trial, grace, past_due, canceled. Expose one endpoint the app queries on launch and foreground. Push the same state into RevenueCat or Adapty if you use them. Log state changes with timestamps and latency. Add monitors for webhook lag and retries.
Tried batching entitlement updates. It inflated churn because delays hit active users.
We switched to immediate updates on paid and refund events. LTV got cleaner and support tickets dropped.
We store a daily status snapshot with plan price per user.
Cohort LTV reads snapshots, not raw events, so late webhooks and retries stop bending the curve.
We just ping a status API on app open. No local logic.
Grace period of 15 minutes fixed most angry emails.