Dilshat Rakhimov
July 2026 · 11 min read

When your web service opens inside a big SuperApp's WebView instead of Safari or Chrome (a Halyk-class app, say), the usual attribution model falls apart from two sides at once. On the product-analytics side, you can't tell where the session came from - the app menu, a storefront banner, or a genuine paid deep link. On the performance side, the browser Meta/Google pixel inside the WebView is often switched off by a kill-switch, so there's nobody left to carry the money event back to the ad account.

This is about capturing attribution inside a WebView so that three things hold: product analytics (Amplitude or Mixpanel) knows each session's layer and source; the paid source doesn't get mixed in with the SuperApp's own internal traffic; and ROAS is calculated server-side, where a dead pixel can't touch it. Think of it as the companion to the deep-link / WebView-attribution piece - that one's about keeping UTM and MMP data intact across the hop; this one's about getting those signals into product analytics and into revenue.

WebView attribution cover

Why a WebView breaks attribution from both sides

A WebView isn't "a slightly different browser." It's a separate layer inside the native app, with its own cookie handling, its own navigation, and its own limits. Two consequences matter here.

First, a WebView session is anonymous by default. Someone could reach the service three different ways - the SuperApp menu, a storefront banner, a paid deep link from an ad - and in all three the WebView opens the same page. With no explicit signal, product analytics can't tell a paid session apart from in-app browsing.

Second, the browser ad stack inside the WebView is often blocked outright. In the wild you'll find a guard along the lines of is_webview → kill the whole Meta pixel and part of Google, sometimes so thoroughly that typeof fbq === "undefined" and the base pixel never even loads. And the WebView is where most of the payments happen - on the order of 85%. So the browser pixel can't be trusted with the money event, full stop. Attribution has to move server-side.

How it works: launch-URL plus native getAttribution(), as an extension not a rebuild

The trick isn't to shove the user out into a real browser or re-glue the pixel. It's to let the native layer write parameters onto the launch URL when it opens the WebView.

For every session, always, native adds the layer and the entry point: layer, entry_point, launch_id. Then - only for ad-origin launches, meaning the WebView was opened from an ad deep link - it appends the attribution from the native MMP (Adjust or AppsFlyer, say) via getAttribution():

adj_network, adj_campaign, adj_adgroup, adj_creative,
adj_tracker_token, adj_deeplink_target, adj_is_deferred,
click ids (from native getAttribution())

A capture step at initialization - call it the "Click ID Locker" at GTM Init - reads those and writes them into Amplitude user properties, right next to layer. adj_is_deferred flags a deferred deep link (the "install now, open later" case) so you don't mistake it for a direct launch.

The important framing: this extends what's already there, it doesn't replace it. The product events stay exactly the same - they just pick up a layer and a source.

The router.replace() trap: read the params before they vanish

A SPA router rewrites the URL on the very first navigation and strips the query string (and on an OAuth hop it can wipe the url= handoff too). Read the launch params lazily and by the time you look, they're gone - the router already erased them. So you have to read the launch URL at the earliest possible init, before router.replace() runs, and lock the values right there. It's the same idea as first-touch click-id capture on the regular web: catch it and pin it down before anything can rewrite the URL. Miss that window and you lose the whole deep-link attribution before anyone even gets to read it.

The gate that matters: adj_* on ad-origin only

The most expensive mistake you can make here is stamping adj_* onto every launch. Don't. adj_* belongs only on ad deep links into the service. On internal moves - the SuperApp menu, a storefront banner - leave it off, or getAttribution() will hand back a stale value from some earlier attribution and you're straight into the familiar superapp-mixing problem.

Superapp-mixing plays out like this: already-logged-in SuperApp users who came in from the menu, not an ad, end up counted in the paid channel. Their conversion rate sits up near 80% because they're already customers, and that phantom-high rate dilutes the paid segment until the algorithm starts optimizing toward people advertising never brought in. The reporting lies, the bids drift, and budget bleeds out for nothing.

The ad-origin gate kills this at the source: only sessions actually opened from an ad deep link count as paid. On the tagging side it lines up with the planned utm_source=superapp&utm_medium=webview and a blocking trigger that mutes the paid tags on internal navigation.

Two layers you can't let blur together

Keeping the two layers separate is really the whole idea.

The product layer is launch-URL plus Amplitude. It answers "what layer and source is this session, and how does it move through the funnel." That's where layer, entry_point, and adj_* live as user properties. Real-time, for product analytics.

The financial (ROAS) layer is separate, and it's server-side. The adj_* don't just go into Amplitude - they also go into the body of a product API call that's already carrying identifiers (a quote/calculate request, say, whose body already has utm, a phone number, and a customer id). The backend joins that against the payment webhook in a dedicated attribution table, and from there it fans out to Meta CAPI, Google Enhanced Conversions, and the TikTok Events API. Dedup runs on transaction_id.

Why the money has to go server-side is the point from earlier: the browser Meta stack in the WebView is dead by kill-switch, and the payments mostly happen in the WebView. The server-side layer doesn't care - it needs no fbq, no cookie, no live pixel. Both layers feed off the same adj_* capture, but they run in different pipes and neither depends on the other.

How you know it's actually working

To sign this off as working rather than "seems fine," hold it to numbers:

  • layer / entry_point set on >= 99% of in-app sessions (init capture is firing almost every time);
  • adj_* at 0 on internal navigation (the ad-origin gate is holding and superapp-mixing isn't leaking in);
  • paid app sessions within <= 15% of the MMP console (the paid segment is being counted honestly);
  • adj_* reaching the calculate-join on >= 90% of ad-origin applications (the financial layer is actually getting the source);
  • attribution_channel populated 100% of the time (every application ends up tagged with a channel).

The takeaway

Attribution inside a SuperApp WebView doesn't come from a pixel - it comes from the native launch URL: layer and entry point on every session, ad attribution from getAttribution() only on ad-origin deep links, and all of it read at the earliest init before the router wipes the query. Product analytics gets layer and source as user properties; the money rides a separate server-side layer that a dead browser pixel can't break; and the ad-origin gate keeps the SuperApp's own 80%-conversion internal traffic from poisoning the paid channel. The three things people forget, every single time: read the launch params before router.replace(), keep adj_* strictly on ad-origin, and stop trying to count WebView revenue with a browser pixel that isn't even there.

FAQ

Why does a WebView break attribution from both sides?

Inbound, paid traffic arrives on ad-origin deep links and the launch URL is the only place the source exists. Outbound, the host SuperApp can switch off your browser pixel — and it is not your kill-switch.

What is the router.replace() trap?

The SPA router rewrites the URL and strips the query string before your code reads it. Nothing throws — the parameters simply are not there when you look. Read and stash them before the router runs.

Why gate adj_* parameters to ad-origin traffic only?

Because tagging organic sessions with ad parameters poisons the paid channel. The client-side product-analytics layer and the server-side ROAS layer have different identities and different failure modes, and they must not blur together.