Cross-Device Attribution for SaaS: Tracking iOS App Discovery → Desktop Stripe Conversions Without SKAdNetwork
Cross-Device Attribution for SaaS: Tracking iOS App Discovery → Desktop Stripe Conversions Without SKAdNetwork
Most "iOS attribution" problems in SaaS are not actually iOS attribution problems. They are cross-device attribution problems wearing an iOS costume.
The pattern repeats across nearly every SaaS app TagSpecialist audits: a user discovers the product on iPhone — through a Meta ad, a TikTok promo, an App Store search — installs the app, takes a quick look around, and then later that day or that week, opens their laptop, navigates back to the product, and signs up or pays through Stripe Checkout in a desktop browser. The conversion happens. The revenue lands. Stripe sends the receipt.
But the ad platform that drove the original install on iOS sees nothing. Meta sees the iOS install (sometimes, depending on ATT consent), but it never connects that install to the desktop Stripe checkout three days later. Google Ads doesn't know the iOS click drove the desktop purchase. The ROAS report shows iOS spend with near-zero conversions and desktop "direct" traffic with disproportionately high revenue. The growth team interprets the data as "iOS doesn't work" and shifts spend to channels they can measure — leaving real ROAS on the table.
The good news: for SaaS where the conversion happens on desktop via Stripe (which describes the majority of B2B and prosumer SaaS), the fix is not exotic. It does not require SKAdNetwork instrumentation, postback wiring, or the kind of mobile attribution gymnastics that consume engineering quarters. Stripe ships a built-in field — client_reference_id on Checkout Sessions — that exists precisely for this cross-device handoff. Combined with Universal Links and an email-based fallback, the architecture is straightforward, deterministic, and gets you accurate cross-device attribution with maybe one sprint of engineering work.
This post lays out the architecture, the implementation steps, and the common mistakes TagSpecialist sees in audits.
The Problem: Cross-Device Funnels Are Treated as Two Disconnected Events
Look at the typical SaaS funnel anatomy:
[iPhone] User sees Meta ad → clicks → installs app
[iPhone] User opens app → browses features → closes app
[Days later, Desktop] User reopens product on laptop → signs up → pays via Stripe Checkout
[Stripe webhook] checkout.session.completed fires → product grants access
[Email] Welcome email sent
Most stacks track each surface independently:
- Meta SDK in the iOS app captures install + in-app events.
- A web Pixel or GA4 tag fires on the desktop signup page.
- Stripe records the payment and emits the webhook.
- The internal product database creates the user record.
These four data sources are never joined. The iOS app event sits in Meta's event log without a link to the eventual purchase. The Stripe payment lands without context for which marketing surface drove it. GA4 sees a "direct" or "google / cpc" desktop session, depending on what the user clicked through. The result is a funnel that looks fragmented even though the underlying user journey is coherent.
The standard responses we see teams attempt:
- SKAdNetwork instrumentation. This is what the iOS-attribution discussion usually centers on. SKAdNetwork is Apple's privacy-preserving postback system for app installs and post-install events. It works, but it has limited conversion-value granularity (technically improved in 4.0, still constrained), 24-48 hour delayed postbacks, and it does not solve the cross-device problem at all — it tracks an iOS install attributed to an iOS ad click. If the conversion happens on desktop, SKAdNetwork can't see it.
- Mobile measurement partner (MMP) like AppsFlyer or Adjust. These can do cross-device matching probabilistically (device graph) or deterministically (login-based). Probabilistic matching has degraded since iOS 14.5. Deterministic matching requires the user to log in on both surfaces — which most SaaS prospects don't do until after they've paid.
- Brute-force UTM stitching. Capture UTM parameters in the iOS app's deep links, store them in a backend session, and somehow correlate to the desktop signup. Possible but fragile — if the user takes 3 days to complete the conversion, sessions expire, cookies clear, and the UTM is gone.
None of these are wrong, but for SaaS specifically — where Stripe is the payment processor and a Stripe Checkout Session is the conversion event — there is a much simpler architecture that achieves deterministic cross-device attribution without any of the complexity above.
The Impact: ROAS on iOS Channels Is Routinely Undercounted by 50-80%
For a SaaS app where a meaningful share of paying users discover via iOS but pay on desktop, the under-attribution is severe. Without cross-device stitching:
- iOS Meta campaigns report ROAS based on in-app purchases only. If most paying users complete checkout on desktop, the campaign's ROAS in Meta reports may be 20-30% of actual ROAS. Smart Bidding optimizes against the reported signal — meaning it will systematically deprioritize iOS audiences who actually convert at high rates on desktop.
- TikTok and Reddit ads, which lean mobile-heavy, see similar distortion. Their reported conversions exclude the desktop Stripe checkout, so reported CPAs look 3-5x worse than reality.
- Google Ads that drove the iOS install through Search or Performance Max likewise lose attribution because the Stripe checkout event isn't tied back to the ad click.
The growth team then reallocates budget away from these underperforming channels — which look underperforming only because the measurement is broken. Real ROAS goes down. Reported ROAS on the remaining channels goes up. Everyone is confused about why blended CAC is rising.
In TagSpecialist client engagements, fixing cross-device Stripe attribution typically recovers 40-70% more attributed revenue to mobile-discovered campaigns. The campaigns weren't broken. The measurement was.
The Solution: client_reference_id + Universal Links + Email Fallback
Stripe ships a field on every Checkout Session called client_reference_id. It is a string of up to 200 characters that you set when creating the checkout session, and Stripe stores it on the Session object and includes it in the checkout.session.completed webhook payload. It does nothing on Stripe's side beyond pass-through storage — its entire purpose is to let you reconcile a checkout with whatever business identifier is meaningful to your stack.
The architecture:
graph TD
A[iPhone: User clicks Meta ad] --> B[App Store install]
B --> C[App opens with deferred deep link]
C --> D[App generates internal user_id<br/>+ stores attribution context<br/>Meta fbclid, fbp, etc.]
D --> E[User browses, closes app]
E --> F[Days later: User opens email or visits site on desktop]
F --> G{Did they get a Universal Link<br/>that carries user_id?}
G -->|Yes| H[Desktop browser receives user_id<br/>via Universal Link query param]
G -->|No, fallback| I[User logs in or signs up on desktop<br/>using same email as iOS app]
H --> J[Desktop checkout: user_id passed<br/>to Stripe as client_reference_id]
I --> J
J --> K[Stripe Checkout completes]
K --> L[Webhook checkout.session.completed<br/>arrives with client_reference_id]
L --> M[Backend joins user_id to original<br/>iOS attribution context]
M --> N[Push conversion to Meta CAPI / Google Ads / GA4<br/>with full attribution]
Three components:
- Stable user identity that's set in the iOS app and survives to the desktop checkout. This is what
client_reference_idcarries. - A handoff mechanism from iOS to desktop. Universal Links are the deterministic option; email-based matching is the probabilistic fallback for users who don't pass through a link.
- A backend that joins the Stripe webhook to the iOS-side attribution context and pushes the conversion to ad platforms.
Implementation: Step-by-Step
Step 1: Generate a Persistent user_id in the iOS App on First Launch
When the iOS app launches for the first time, generate a UUID that represents this user and store it in the app's keychain. At the same time, capture whatever attribution context exists — Meta's fbclid, the iOS Identifier for Vendor (idfv), any UTM parameters from a deferred deep link, and the install timestamp.
import Foundation
import KeychainAccess
struct UserAttribution {
let userId: String
let installTimestamp: Date
let fbclid: String?
let utmSource: String?
let utmCampaign: String?
}
class UserIdentityService {
private let keychain = Keychain(service: "com.yourapp.identity")
func bootstrapUser(deepLinkParams: [String: String]) -> UserAttribution {
if let existingId = try? keychain.get("user_id") {
return loadExistingAttribution(userId: existingId)
}
let newUserId = UUID().uuidString
try? keychain.set(newUserId, key: "user_id")
let attribution = UserAttribution(
userId: newUserId,
installTimestamp: Date(),
fbclid: deepLinkParams["fbclid"],
utmSource: deepLinkParams["utm_source"],
utmCampaign: deepLinkParams["utm_campaign"]
)
sendToBackend(attribution)
return attribution
}
}
The backend stores this attribution context against the user_id. The user has not paid yet, has not given an email yet — but you have a stable identity tied to the original ad click, sitting in your database waiting for the desktop checkout to arrive.
Step 2: Universal Links That Carry user_id to Desktop
When the iOS app needs to send the user to desktop — for instance, in an in-app email capture flow, a "continue on desktop" button, or a verification email triggered from the app — generate a link that includes the user_id as a query parameter and is configured as a Universal Link on your domain.
iOS Universal Link configuration requires an apple-app-site-association file at https://yourdomain.com/.well-known/apple-app-site-association:
{
"applinks": {
"apps": [],
"details": [
{
"appID": "TEAMID.com.yourapp.bundle",
"paths": ["/continue", "/checkout", "/verify"]
}
]
}
}
Then, from the app, generate a continuation link:
func desktopContinuationURL(userId: String, email: String?) -> URL {
var components = URLComponents(string: "https://yourdomain.com/continue")!
components.queryItems = [
URLQueryItem(name: "uid", value: userId)
]
if let email = email {
components.queryItems?.append(URLQueryItem(name: "em", value: email))
}
return components.url!
}
When the user opens this link on iOS, Universal Links route them to the app. When opened on desktop (or shared to desktop via email, AirDrop, etc.), the link opens in a desktop browser with ?uid=<user_id> in the query string. The desktop site reads this on page load and writes it into a first-party cookie:
// On the desktop landing page handler
const params = new URLSearchParams(window.location.search);
const uid = params.get('uid');
if (uid) {
document.cookie = `app_user_id=${uid}; path=/; max-age=${60 * 60 * 24 * 30}; SameSite=Lax; Secure`;
}
Step 3: Pass user_id to Stripe as client_reference_id
When the user reaches Stripe Checkout — whether immediately or days later — read the cookie and pass it to the Checkout Session creation:
// Backend (Node.js / Next.js API route example)
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
export async function createCheckoutSession(req, res) {
const userId = req.cookies['app_user_id']; // From the Universal Link handoff
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
line_items: [{ price: 'price_XXX', quantity: 1 }],
customer_email: req.body.email,
client_reference_id: userId, // <-- The cross-device attribution key
success_url: `${process.env.SITE_URL}/welcome?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.SITE_URL}/pricing`,
});
res.json({ url: session.url });
}
Stripe takes the client_reference_id, stores it on the Session object, and returns it in every subsequent Session retrieval and webhook event.
Step 4: Handle the checkout.session.completed Webhook to Reconcile Attribution
// Webhook handler
export async function handleStripeWebhook(req, res) {
const event = stripe.webhooks.constructEvent(
req.rawBody,
req.headers['stripe-signature'],
process.env.STRIPE_WEBHOOK_SECRET
);
if (event.type === 'checkout.session.completed') {
const session = event.data.object;
const userId = session.client_reference_id;
const email = session.customer_details?.email;
const value = session.amount_total / 100;
const currency = session.currency.toUpperCase();
// Look up the original iOS attribution context
const attribution = await db.userAttribution.findOne({ userId });
// Push to ad platforms with full attribution
await Promise.all([
sendMetaCAPIConversion({
eventName: 'Subscribe',
eventId: session.id,
userData: { email, externalId: userId },
customData: { value, currency },
fbclid: attribution?.fbclid,
}),
sendGoogleAdsEnhancedConversion({
gclid: attribution?.gclid,
email,
conversionValue: value,
conversionDateTime: new Date(session.created * 1000).toISOString(),
}),
sendGA4MeasurementProtocol({
clientId: attribution?.gaClientId,
eventName: 'purchase',
params: { transaction_id: session.id, value, currency },
}),
]);
}
res.json({ received: true });
}
This single webhook handler is where cross-device attribution becomes complete. The Stripe webhook arrives with client_reference_id, you look up the original iOS-side attribution, and you fire enriched conversion events to every ad platform with the appropriate identifiers. Meta receives the conversion with fbclid. Google Ads receives the conversion with the gclid and a hashed email for Enhanced Conversions for Leads (covered in Google Ads Enhanced Conversions Unification (June 2026)). GA4 receives the event via Measurement Protocol with the original client ID.
Step 5: The Email Fallback for Users Who Don't Pass Through a Universal Link
Some users will sign up on desktop without ever clicking a Universal Link from the iOS app. They'll just open Safari on the laptop and search for your product directly. For these users, the fallback is email-based matching:
When the user enters their email at desktop checkout, your backend checks whether that email already exists in the iOS app's user records (the user gave it to the app at some earlier point, perhaps in a settings screen or a preview flow). If it matches, you treat the desktop checkout as the same user_id and proceed identically to Step 4.
async function resolveUserIdFromCheckout(session) {
// First preference: client_reference_id from Universal Link handoff
if (session.client_reference_id) return session.client_reference_id;
// Fallback: match by email against iOS app users
const email = session.customer_details?.email?.toLowerCase().trim();
if (!email) return null;
const existingUser = await db.userAttribution.findOne({ email });
return existingUser?.userId || null;
}
This fallback recovers a meaningful share of the cross-device conversions that Universal Links miss. It is not perfect — users who use a different email on desktop than they registered with on iOS will not match — but it captures roughly 70-85% of the otherwise-untracked conversions in our experience.
Common Pitfalls We See in TagSpecialist Audits
client_reference_idset to a non-stable value. Teams sometimes use a session-scoped value (cart ID, browser session ID) instead of the persistent user identity. The webhook arrives with a value that doesn't reconcile to anything in the database. Always use a stable identity that maps back to the original ad attribution.- Universal Links not actually configured. The
apple-app-site-associationfile has typos, theapplinks:yourdomain.comentitlement is missing from the iOS app, or the file is served with the wrong Content-Type. The link opens a browser instead of routing to the app — and the browser-side?uid=capture works fine, so testing on desktop hides the iOS routing failure. Test on a real iPhone. - Email mismatch between iOS app and desktop checkout. The user used
[email protected]on iOS and[email protected]on desktop. The fallback email match fails. The fix is to capture every email the user provides on either surface and check all of them at checkout time. - Webhook fires before the database knows about the user. If your backend creates the user record only on
checkout.session.completed, the attribution lookup fails because the iOS-side record was never persisted. Persist iOS attribution context to the backend at app launch, not at checkout. - Stripe Test Mode
client_reference_idnot propagating. Stripe Test Mode and Live Mode are fully isolated — a test webhook will not have a Live Modeclient_reference_idand vice versa. End-to-end testing requires either fully-live test transactions or both modes configured. - GDPR exposure on attribution context storage. The
user_idand email stored in the iOS-side attribution table is PII. Apply the same retention, encryption, and consent rules you apply to your main user database. We've seen audits where attribution data lived in an unsecured analytics-only table.
How TagSpecialist Can Help
If you run a SaaS with iOS app discovery and desktop Stripe checkout, the cross-device attribution gap is almost certainly costing you double-digit percentages of measured ROAS. The architecture above is well-trodden — TagSpecialist has implemented variants of it for B2B SaaS, prosumer SaaS, edtech, and creator-economy platforms — and the implementation is typically 2-3 weeks of engineering across mobile, backend, and tagging surfaces.
A typical TagSpecialist cross-device attribution engagement includes:
- Funnel audit — mapping out where users actually move between iOS and desktop, with quantification of how much revenue is currently slipping through the attribution gap.
- iOS app identity layer — implementation of stable user IDs in keychain, attribution context capture at install, and Universal Link generation for desktop continuation.
- Universal Link infrastructure —
apple-app-site-associationconfiguration, domain entitlement setup, and end-to-end testing on real devices. - Stripe Checkout integration —
client_reference_idplumbing, webhook handler with attribution reconciliation, and ad-platform event dispatch. - Email-fallback matching — backend logic to match desktop checkouts to iOS app users by email when the Universal Link path doesn't apply.
- Validation and reporting — comparison of pre-implementation and post-implementation attribution coverage, including ad-platform reported ROAS deltas.
For SaaS apps spending $20K+/month on Meta and Google Ads with mobile-discovered audiences, the implementation typically pays back in the first 30-45 days through better-attributed campaigns and reallocated spend.
If you want to know how much attribution you're currently leaving on the table, book a 15-minute audit call — we'll look at your funnel and quantify the gap before any commitment.
For the broader 2026 tracking architecture, see Server-Side Tagging Best Practices 2026. For ad-platform-specific work, see Google Ads Enhanced Conversions Unification (June 2026) and Meta CAPI in 2026: Why Pixel-Only Tracking Costs You 20-40% of Conversions. If you are still deciding where to host your server-side container, see Hosted vs. Self-Hosted Server-Side GTM in 2026.
The framing that matters: most "iOS attribution" problems are actually cross-device handoff problems, and most cross-device handoff problems for SaaS have a clean deterministic solution that does not require SKAdNetwork, MMPs, or probabilistic matching. Stripe shipped the field for this purpose. Universal Links have shipped on iOS for ten years. The work is in connecting them — and that work pays back in attributable revenue almost immediately.
Need Expert Help With Your Tracking Setup?
Our specialists handle complex tracking implementations so you can focus on growing your business.