We moved first-touch onboarding and checkout to the web and left the app as the product surface. The tricky bit was keeping subscription state perfectly in sync once people bounce between web and iOS/Android. What worked (and what bit us):
-
Pick one canonical user id and use it everywhere
- We generate account_id server-side.
- On web checkout, store account_id in the payment provider’s customer metadata.
- In the app, we set the SDK’s user id (RevenueCat appUserID / Adapty customerUserId) to the same account_id on login.
-
Drive entitlements from your backend, not the client
- On webhooks like checkout completed, renewal, cancel, refund, call your backend.
- Backend grants or revokes access via provider API (e.g., promotional entitlements / access levels), and also writes to our own subscriptions table.
- Make webhook processing idempotent (keyed by event id). Retries with backoff.
-
App behavior that avoided user pain
- On launch and foreground, refresh entitlements. Don’t cache too long.
- If a user just paid on web and opens the app before webhooks finish, show a short “restoring…” state and poll the backend for ~10–20 seconds before showing failure.
- Block initiating another in-app purchase when an active web subscription exists. Show a message with a “manage on web” link.
-
Edge cases we hit
- Apple private relay email: user pays with one email, signs in with Sign in with Apple in the app. We fixed it by linking accounts via magic link and storing aliases; never match on email alone.
- Clock skew: entitlement end times off by minutes caused false expirations. Standardized to UTC and second precision everywhere.
- Refunds and chargebacks: if you only listen for cancellations you’ll miss these. We explicitly handle refund events and shorten access immediately.
- Trial duplication: a user had an App Store trial then tried a web trial. We enforce one trial per account_id across channels.
- Multi-device: stale cache on device B showed expired while device A was fine. We lowered cache TTL and force-refetch after login.
- Currency/region: mismatched plan ids led to wrong price displays in app. We mapped web plan → app product alias and always display the active plan from the backend.
- Sandbox vs production: mixed keys caused phantom entitlements in staging. Separate environments and keys end-to-end.
-
Testing that paid off
- Use payment provider test clocks to simulate renewals, dunning, grace periods.
- Chaos test delayed webhooks and verify the app’s “restoring” experience.
- QA account linking flows: install app first vs pay on web first vs switching emails.
Curious what I’m missing: which edge cases broke your sync, and did you grant access through your provider’s API or build your own entitlement service on top? Also, what webhook retry policy and SLA kept your churn-induced flakiness low?