
Utkarsh ShrivastavaI built this crazy seamless system (Not sure, how its done usually in the industry), Where we are...
I built this crazy seamless system (Not sure, how its done usually in the industry), Where we are acquiring users on the web funnel, making them pay on the web, and after purchase they land on the mobile app which is already authenticated even when it is not installed (thats the main sauce btw), hence reducing the friction of logging them again, getting rid of the apple tax, getting the retention of a native mobile app, bypassing the bitchy apple again with its ATT permission.
Read that again. Apple takes Up to a third of your revenue, for a payment sheet.
So we just moved the money to the web. Apple only gets its cut when the purchase happens inside the app.
Imagine someone is scrolling and they see your ad. And you want them to leave, open the App Store, download 80MB, open the app, get through onboarding, and then decide if they want to pay.
A web funnel asks for only one thing: a tap.
From Ad -> quiz flow -> paywall -> paid. They never leave the browser, and the money is in before they've installed anything.
Since the purchase is happening outside of the mobile app, apple cant do anything, you can apply whatever black marketing tips and tricks you have learnt.
First, you need to understand one thing that the App Store is a wall.
Nothing can cross it, whether its cookies, session, or local storage. Your user was logged in on the web two minutes ago, and your app opens with zero idea who they are.
But there's one thing, one thing that really does cross the wall, and you're probably already paying for it: your attribution tool, i.e. Appsflyer.
They have this thing called deferred deep linking.
It works like this:
So we put a one time login token on the link, and thats the whole trick.
So the flow is : pay and sign up on the web, tap Get the app, install, and the token rides the link to the app's first launch
When the logged in user taps Get the app, your backend creates a one time token for them:
const token = randomBytes(32).toString("base64url");
await db.query(
`insert into handoff_tokens (token_hash, user_id, expires_at)
values ($1, $2, now() + interval '1 hour')`,
[sha256(token), session.userId],
);
Few things to take care of during this process :
My first version put the token on the link like a normal person
https://yourapp.onelink.me/AbCd/handoff?deep_link_value=app_handoff&token=<token>
On Android it worked like magic. Tap, install, open, logged in.
But, On iOS it opened to the normal splash screen, and nothing happened.
I logged every SDK callback, and every fresh iOS install said the same thing:
status=FOUND is_deferred=true deep_link_value=app_handoff has_token=false
What I found was that the app knew the install came from our link. It even gave us deep_link_value, But the main token was gone.
Then I found this one line in the AppsFlyer docs:
▎ For new users, the UDL method only returns parameters relevant to deferred deep linking: deep_link_value and deep_link_sub1-10.
Since iOS 14.5, a fresh install only gets a handful of "safe" parameters. Your custom token= gets wiped out silently.
Apple again being a bitch obviously.
The fix is simple and stupid, We have to Put the token in one of the allowed slots:
https://yourapp.onelink.me/AbCd/handoff?deep_link_value=app_handoff&deep_link_sub1=<token>
sdk.onDeepLinking((res) async {
if (res.status != Status.FOUND) return;
final ev = res.deepLink?.clickEvent ?? {};
if (ev['deep_link_value'] != 'app_handoff') return;
final token = ev['deep_link_sub1']?.toString();
if (token == null || token.isEmpty) return;
final session = await api.exchangeHandoff(token);
await auth.save(session);
navigator.pushAndRemoveUntil(homeRoute(), (_) => false);
});
The app sends the token to your backend, and the backend burns it and logs the user in:
const { rows } = await db.query(
`update handoff_tokens set used_at = now()
where token_hash = $1 and used_at is null and expires_at > now()
returning user_id`,
[sha256(token)],
);
if (!rows.length) throw new Unauthorized();
return issueSession(rows[0].user_id);
We have to check the token and burns it in a single shot, so it can never be used twice.
issueSession is whatever auth you already have. Your own JWTs, a Firebase, Supabase custom token, anything. This whole thing doesn't care.
On Supabase you can skip the table entirely. generateLink({ type: "magiclink" }) gives you a one-time token and the app calls verifyOTP. That's what we run.
AppsFlyer matches the tap to the first launch. It doesn't care where the install came from.
Through this whole flow we are reducing friction at two point:
Which guarantees you the best of both worlds, Acquisition of web and Retention of mobile app.
But everything has a tradeoff.
If your user stays on the web forever, chances are they’ll forget about you and just keep paying.
But once they’re on the mobile app, and it’s a low-intent user, the constant app icon and notifications can also piss them off and increase the cancellation rate.