Connect a web funnel visitor to their native app install without AppsFlyer, Adjust, Branch, or other MMP — using the identify() method and device matching headers built into every web2wave SDK.
After a user completes a web2wave quiz or paywall in the browser and installs your app, you need their web2wave user_id inside the native app to unlock the subscription.
Most teams get that user_id through a mobile measurement partner (MMP) — AppsFlyer, Adjust, Branch, and similar tools pass it via an install deeplink. That works well if you already run an MMP stack.
If you do not use an MMP, web2wave provides deferred deeplinks as a built-in alternative. Every Mobile SDK sends device-matching headers on API requests and exposes an identify() method (GET /api/user/identify) that matches the installed device against recent web sessions on your project (last 48 hours) and returns the user_id.
No third-party attribution SDK required.
MMP vs web2wave deferred deeplinks
| MMP (AppsFlyer, Adjust, Branch, …) | web2wave deferred deeplinks | |
|---|---|---|
| Extra SDK | Yes — MMP SDK in the app | No — built into web2wave SDK |
How user_id arrives | Install / deferred deeplink from MMP | identify() on first app open |
| Setup | MMP dashboard + deeplink in project settings | Initialize web2wave SDK + call identify() |
| Best for | Teams already using MMP for ads & attribution | Simpler stacks, no MMP budget, web-to-app only |
These approaches are alternatives, not layers. Pick one attribution path for resolving
user_idon install. If you already use AppsFlyer or Adjust, follow the RevenueCat, Adapty, or direct API guides with your MMP deeplink handler.
You can also use web2wave's custom deeplink page without an MMP — but that still requires the user to open the install link again after downloading the app. Deferred deeplinks remove that extra step.
When to use deferred deeplinks
| Scenario | Recommended approach |
|---|---|
| You use AppsFlyer, Adjust, Branch, etc. | MMP deeplink — see integration guides above |
| You want web-to-app attribution without an MMP | Call identify() on first app launch |
User already known in app (stored user_id) | Skip attribution; use stored value |
Call identify() once per fresh install, typically on first app open and before linking Adapty / RevenueCat / Qonversion profiles. Matching only considers web sessions from the last 48 hours, so call it as soon as the user opens the app after install.
How deferred deeplinks work
The SDK automatically attaches these HTTP headers to every API call (including identify()):
| Header | Example | Purpose |
|---|---|---|
platform | iOS, Android | Device family |
device_model | Pixel 8, SM-S911B, iPhone15,2 | Hardware model — preferred match signal |
screen_size | 390x844 | Logical screen resolution — fallback / iOS Safari |
timezone | UTC+03:00 | Local timezone offset |
os_version | iOS 18.0, Android 14 | OS version string |
api-key | your project key | Project authentication |
Current SDKs send device_model automatically. Older SDK versions still work via screen_size. Country and city are resolved server-side from the request IP — you do not send them.
Matching uses two paths, in order:
- Same IP (last 48 hours) — look up recent quiz/paywall visits from this IP, then confirm among those candidates with the device fingerprint.
device_modelis preferred (stable between Chrome Client Hints on the web and the native app).screen_sizeis a tie-break, and the fallback when the web session has no stored model (typical for iOS Safari). - Global fingerprint — if the IP changed (VPN, Wi-Fi vs cellular), match without IP using the same signals plus country. The candidate must be unique among sessions in the last 48 hours. If several users share the fingerprint, identify returns no
user_idinstead of guessing.
Fingerprint matching requires platform, timezone, and at least one of device_model or screen_size. os_version tightens the match. On the IP path, Android N from the app also matches frozen Chrome "Android 10". On the global path, OS version must match exactly.
Response format
A successful match returns:
{
"success": 1,
"user_id": "identified_user_guid",
"match_method": "ip_device_fingerprint_with_model",
"platform": "iOS"
}match_method tells you how the user was resolved:
match_method | Meaning |
|---|---|
ip_device_fingerprint_with_model | Same IP + device model |
ip_device_fingerprint_with_os | Same IP + screen size / OS |
ip_device_fingerprint | Same IP + screen size (no OS sent) |
device_fingerprint_with_model | No IP match; unique device model |
device_fingerprint_with_os | No IP match; unique screen size / OS |
ambiguous_fingerprint | Several recent users share the fingerprint — no user_id |
no_match | No candidate in the last 48 hours |
If no unique match is found, success is 0 (or the call returns null / invokes the error callback depending on the SDK).
Integration flow
After you have a user_id, link your paywall SDK and verify entitlement:
setAdaptyProfileID/setRevenuecatProfileID/setQonversionProfileID- Wait 1–2 seconds, then restore purchases in the subscription SDK
hasActiveSubscription(user_id)to unlock content
Code examples
Web2Wave.shared.initialize(apiKey: 'your-api-key');
// No MMP — resolve user_id via deferred deeplink
final identificationData = await Web2Wave.shared.identify();
if (identificationData != null && identificationData['success'] == 1) {
final userId = identificationData['user_id'] as String?;
if (userId != null) {
await Web2Wave.shared.setAdaptyProfileID(
web2waveUserId: userId,
adaptyProfileId: adaptyProfile.profileId!,
);
await Future.delayed(const Duration(seconds: 2));
await Adapty().restorePurchases();
}
}Web2Wave.shared.apiKey = "your-api-key"
if let data = await Web2Wave.shared.identify(),
let success = data["success"] as? Int, success == 1,
let userId = data["user_id"] as? String {
let _ = await Web2Wave.shared.setAdaptyProfileID(
web2waveUserId: userId,
adaptyProfileID: adaptyProfileId
)
}Web2Wave.initWith("your-api-key")
val data = Web2Wave.identify()
if (data?.get("success") == 1) {
val userId = data["user_id"] as? String
if (userId != null) {
Web2Wave.setAdaptyProfileID(userId, adaptyProfileId)
}
}Web2Wave.getInstance().initWith("your-api-key");
Map<String, Object> data = Web2Wave.getInstance().identify();
if (data != null
&& Integer.valueOf(1).equals(data.get("success"))
&& data.get("user_id") instanceof String userId) {
Web2Wave.getInstance().setAdaptyProfileID(userId, adaptyProfileId);
}Web2Wave.shared.initialize('your-api-key');
const data = await Web2Wave.shared.identify();
if (data?.success === 1 && data.user_id) {
await Web2Wave.shared.setAdaptyProfileID(data.user_id, adaptyProfileId);
}Web2Wave.Shared.Initialize("your-api-key");
Web2Wave.Shared.Identify(
onSuccess: (data) => {
if (data.success == 1 && !string.IsNullOrEmpty(data.user_id)) {
Web2Wave.Shared.SetAdaptyProfileID(
data.user_id,
adaptyProfileId,
onComplete: (_) => { }
);
}
},
onError: (error) => Debug.LogError(error)
);Run network calls off the main thread on Android (Java/Kotlin). Use a background coroutine with
Dispatchers.IOor anExecutorService.
Direct API usage
If you are not using an SDK, send the same headers manually:
curl -X GET "https://api.web2wave.com/api/user/identify" \
-H "api-key: YOUR_API_KEY" \
-H "User-Agent: MyApp/1.0 (iPhone; iOS 18.0)" \
-H "platform: iOS" \
-H "device_model: iPhone15,2" \
-H "screen_size: 390x844" \
-H "timezone: UTC+03:00" \
-H "os_version: iOS 18.0"User-Agent is required. Send device_model whenever the OS exposes it (Build.MODEL on Android, utsname.machine on iOS). screen_size remains useful as a fallback, especially for iOS web sessions.
See also: Direct web2wave API integration and GET /user/identify.