What breaks subscription sync between web checkout and app login, and how did you fix it?

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

    1. 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.
    2. Clock skew: entitlement end times off by minutes caused false expirations. Standardized to UTC and second precision everywhere.
    3. Refunds and chargebacks: if you only listen for cancellations you’ll miss these. We explicitly handle refund events and shorten access immediately.
    4. Trial duplication: a user had an App Store trial then tried a web trial. We enforce one trial per account_id across channels.
    5. Multi-device: stale cache on device B showed expired while device A was fine. We lowered cache TTL and force-refetch after login.
    6. 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.
    7. 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?

Stable ids fix most of it.
I set appUserID to our server account id on login. Stripe customer id lives in subscriber attributes. Webhooks grant promotional entitlements and write our own DB first.
I built the web funnel with Web2Wave and left sync to a small Node service. Idempotent webhooks and a short restore loop in app.

I care about speed, so I ship changes on the web and let the app just read entitlements. Web2Wave lets me tweak paywalls fast, then a webhook updates RevenueCat. The app refreshes on foreground. Fewer app releases, more tests. Marking one trial per user avoided messy duplicates.

Add a short delay state after web checkout.

A simple restore button helps too, because webhooks can lag at times. I also log the account id on the settings screen so support can match things quickly.

Single user id everywhere fixed 90% of issues

Trials got messy until we tracked trial_used=true on the account. If a user had a store trial, the web offer switched to full price. Also built a small job that revalidates entitlements nightly to catch any webhook misses.

Small thing that helped support: show entitlement expiration in-app.

If users sign in with Apple and buy on web with Gmail, link via a magic link to unify ids. Do not rely on email matching. Store aliases on the account. We also tag Stripe customers with account_id and environment to avoid staging bleed.

Blocking in-app purchase when web sub is active saved us from double charges.