TL;DR: Web to app attribution is an identity problem, not a pixel problem. Carry campaign context through the install with a deferred deep link, join the app's anonymous ID to the billing subscriber at sign-up, and report unmatched journeys as their own number.
A lifecycle or subscription-ops lead running paid campaigns hits the same wall: the ad platform reports installs, the store or RevenueCat reports revenue, and nothing connects the two. The job is to join web acquisition to an app subscription — follow one person from a landing-page click, through an app-store redirect and first open, to a paid activation in the billing system. That takes a three-step pipeline: carry the context you are allowed to carry, join the app and billing identities at sign-up, and report what did not match instead of forcing it into a campaign. The measurement discipline is the same one the business process automation ROI framework applies to any funnel — fix the units and the join keys before trusting the numbers.
Why the join breaks
The join breaks because every hop swaps the identifier. On the web, the person is a browser session with a click ID. In the app store, they are a privacy-filtered install. In the billing layer, they are an app_user_id. Three systems of record, three IDs, and no automatic bridge between them.
Platform privacy makes it worse by design. Apple's App Tracking Transparency limits cross-app identifiers, and Apple's AdAttributionKit — launched in iOS 17.4 and extended to re-engagement in iOS 18 — reports installs in aggregate rather than per user (Kratly's web-to-app attribution overview). The result is familiar: the ad network sees a click and maybe an install, the billing system sees a subscriber, and the report that matters — which campaign produced that subscriber — exists nowhere.
Step 1: Carry permitted context through deferred deep linking
Carry only what survives the store redirect, and put it where the platform guarantees delivery. The mechanism for a not-yet-installed user is a deferred deep link: the link stashes the destination and campaign context server-side, sends the user to the store, and hands the context back on first open. For users who already have the app, a regular deep link opens it directly — web-to-app journeys split cleanly by install state (Linkrunner web-to-app journeys).
The catch is what the link actually delivers. Under AppsFlyer's Unified Deep Linking privacy protection, a new user's deferred-link callback returns only deep_link_value and deep_link_sub1-10 — media_source, campaign, and af_sub1-5 come back null, while an already-installed user gets all link parameters (AppsFlyer UDL docs). Design rule: encode the campaign, ad set, and creative IDs you need inside the deep_link_sub parameters, because those are the fields that survive for the user you care most about — the new install. Do the same through your own first-party path where you can: keep the click ID and campaign on your landing page and your server rather than in the browser, the same principle behind server-side tracking for web funnels. That click-to-first-open matching is what app install attribution does; the deferred deep link is its transport.
Three platform details to plan around:
- Android: the Play Install Referrer API passes the referrer URL plus click and install timestamps to the app (Kratly).
- Google Ads on iOS:
gbraidandwbraidclick IDs are used wheregclidis not passed (Kratly). - Provider status: Firebase Dynamic Links shut down on August 25, 2025, so deferred deep linking now comes from an MMP or a lighter provider such as Apphud or Linkrunner (Kratly).
The deep-link resolution itself is a signal, not just plumbing: AppsFlyer's DeepLinkResult reports a status of found, not found, or failure, plus an isDeferred flag — log all of it, because step 3 needs to know why context was absent.
Step 2: Join app and billing identities
Join the anonymous app identity to your known identity at the first trustworthy moment — usually account creation — and let the billing layer carry it from there. If you supply no App User ID, RevenueCat generates an anonymous one prefixed $RCAnonymousID: and caches it on the device; a delete-and-reinstall produces a new anonymous ID, and custom App User IDs are case-sensitive (RevenueCat identifying customers).
The join sequence:
- At install: the deep-link callback returns the context payload; the billing SDK creates an anonymous subscriber ID.
- At sign-up or first identified event: call
logInwith your own user ID. The anonymous and known IDs merge — every RevenueCat webhook then carriesapp_user_id,original_app_user_id, and the fullaliasesarray, and lookups must search bothoriginal_app_user_idandaliases(RevenueCat webhook event types and fields). - Stamp the attribution: write the deep-link context into the reserved subscriber attributes —
$mediaSource,$campaign,$adGroup,$ad,$keyword,$creative. These are write-once: a placeholder such asUnknowncan permanently block the real value (RevenueCat customer attributes; attribution integration guide). If the deferred link resolved nothing, write nothing. - Timing gate: attributes must exist before the first purchase event, because RevenueCat includes only what it already knows when it processes the purchase (attribution integration guide).
From there, INITIAL_PURCHASE, RENEWAL, CANCELLATION, EXPIRATION, and BILLING_ISSUE webhook events — plus TRANSFER when identities merge — give the warehouse the paid-activation end of the join. A synthetic walk-through: a user clicks a Meta ad on the pricing page, installs via a deferred link carrying deep_link_sub1=meta&deep_link_sub2=camp_447, signs up with an email on day two, and the INITIAL_PURCHASE webhook arrives with your user ID plus those attributes — the warehouse now joins camp_447 to a paying subscriber. Identity stitching like this fails in predictable ways; the conversion tracking and attribution QA worksheet catalogues the failure modes worth testing before launch.
Step 3: Report unmatched journeys explicitly
Report a match rate and a named list of unmatched buckets — never silently force-match. The headline metric is installs resolved to a click divided by total installs; everything below 100% needs a named bucket, not a fudge factor. This is also where acquisition budget quietly leaks — spend on campaigns whose installs never resolve to a subscriber — and the ROAS Leak Calculator sizes that leak before you fix it:
- No prior click: genuine organic installs; these were never supposed to match.
- Deep link returned not found or failure: the
DeepLinkResultstatus logged in step 1. - Match window expired: probabilistic matchers work inside short windows — Apphud, for example, offers accurate (3-minute), optimal (5-minute default), and aggressive (10-minute) settings (Apphud Deeplinks).
- Identity reset: reinstalls generate a fresh
$RCAnonymousID:and break the chain until the nextlogIn. - Privacy-limited: ATT opt-outs and SKAdNetwork/AdAttributionKit paths report aggregate installs, not user-level joins.
Match windows deserve the same audit rigor as any attribution window — the attribution window settings audit covers the review pattern. And when the matched number disagrees with billing reality, reconcile counts across the ad platform, the MMP, and the store instead of picking the biggest number; the marketing attribution reconciliation worksheet is the template for that check.
The web-to-app identity map
The whole pipeline compresses into one spec — the web-to-app identity map — that an ops lead can paste into a tracking plan or hand to engineering. Each row is a journey state; the columns force the three decisions the brief demands: what context is carried, which identities join, and who owns the failure.
| Journey state | System of record | Identifier available | Typical failure | Owner |
|---|---|---|---|---|
| Ad click / landing view | Web analytics + click log | Click ID (gclid, gbraid, ttclid), UTM set |
Client-side blockers drop the click | Marketing ops |
| Store redirect | MMP / deep-link provider | Stored link params (deep_link_value, deep_link_sub*) |
Link misconfigured; params missing | Marketing ops |
| Install + first open | App SDK (MMP, billing SDK) | Deferred-link payload; $RCAnonymousID: |
Not-found result; privacy-limited fields return null | App engineering |
| Sign-up / login | App backend | Your user ID; app_user_id via logIn |
logIn never called; ID casing mismatch |
App engineering |
| Trial / purchase | Billing (RevenueCat, stores) | app_user_id, original_app_user_id, aliases, subscriber attributes |
Attributes set after purchase; placeholder values written | Subscription ops |
| Renewal / refund | Billing webhooks + warehouse | Same IDs on RENEWAL, CANCELLATION, EXPIRATION, TRANSFER |
Transfer not reconciled; duplicate identity | Data/ops |
| Reporting | Warehouse / dashboard | Matched journeys + named unmatched buckets | Unmatched rows forced into campaigns | Analytics |
The example rows above are a synthetic illustration of the map's shape, not a measured benchmark. Fill in your own providers and owners; the columns stay the same.
Where this breaks
Two boundaries deserve honest exits. Web-checkout purchases (Stripe or RevenueCat Billing on your own site) take a different path — the subscriber is created on the web, and the app join happens at first login rather than at install. And consent-sensitive flows — health, finance, minors — constrain which identifiers you may carry at all; that is a legal and platform-policy question your counsel and each platform's current rules must answer, not this article.
If the map shows three owners and zero shared IDs, the problem is scoping, not tooling. An automation audit can bound the identity join before the next campaign review.
FAQ
What is deferred deep linking? A deferred deep link stores the destination and campaign context server-side when a user without the app clicks it, routes them through the app store, and resolves that context on first open. It is how web to app attribution survives the install gap for new users.
How does deferred deep linking work technically?
The click registers against the link provider, the store redirect happens, and on first open the provider's SDK queries its servers for a match — AppsFlyer's UDL returns a DeepLinkResult with status found, not found, or failure and an isDeferred flag. New users receive only deep_link_value and deep_link_sub1-10 under privacy protection; installed users receive all link parameters.
Can you do web to app attribution without an MMP? Partially. The Play Install Referrer covers Android referrer data, platform deep links cover installed users, and your own login gives the identity join — but deferred matching for new iOS users effectively requires a provider, especially since Firebase Dynamic Links shut down on August 25, 2025. Lighter options like Apphud or Linkrunner exist below full MMP suites.
How do you test a deferred deep link before launch?
Test against a device or simulator with the app uninstalled: click the link, install a build, open, and assert the SDK returns status found with isDeferred true and the expected deep_link_sub values. Repeat for the installed-user path, a not-found case, and an expired-window case.
Why do install counts and subscriber counts never match? Installs count devices; subscribers count billing identities. Between them sit unmatched buckets — organic installs, failed deep-link resolution, expired match windows, reinstalls that reset the anonymous ID, and privacy-aggregated installs. Report the buckets instead of forcing the totals to agree.
Answer clarity notes
- Dates: Firebase Dynamic Links' August 25, 2025 shutdown, AdAttributionKit's iOS 17.4 launch and iOS 18 re-engagement, and the AppsFlyer doc's July 27, 2026 update reflect the linked sources; vendor behavior and platform policies change, so check current docs before building.
- Evidence: the AppsFlyer, RevenueCat, Linkrunner, and Apphud references are public vendor documentation or blogs describing their own mechanisms; the Kratly overview is a secondary source used for platform facts it cites from Firebase, Android, and Apple documentation.
- Synthetic example: the
camp_447walk-through and the identity-map rows are invented illustrations, not a client result or a benchmark. - Scope: written for lifecycle, product, and subscription-ops leads at mobile or consumer subscription businesses, mainly iOS/Android with store or RevenueCat-style billing. It is not legal, privacy, or platform-policy advice — consent requirements differ by jurisdiction and vertical.
- Do not infer: match-rate expectations, provider capabilities, and window settings are planning guidance on stated assumptions, not guarantees.
Sources
- Event Types and Fields — RevenueCat Webhooks
- iOS Unified Deep Linking — AppsFlyer
- Customer Attributes — RevenueCat
- Identifying Customers — RevenueCat
- Getting Started with Attribution Integrations — RevenueCat
- Web to app journeys — Linkrunner
- Web to App Attribution: One Funnel, Ad to Purchase — Kratly
- Apphud Deeplinks: Web-to-App Attribution Without an MMP — Apphud