A phone and a browser window feeding the same affiliate commission ledger

One Affiliate Attribution Engine for Mobile and Web

Cross-platform affiliate attribution means one click id, one attribution window, one hold period and one commission ledger covering a mobile app billed through RevenueCat and a web product billed through Stripe. The billing platform does not change how attribution works. It changes which webhook fires and which field carries your user id.

What follows is what the two surfaces share, the six places they genuinely differ, which of our six SDKs belongs where, and the two calls that finish the integration on either side. Written for a team that ships an app and a web product and has one creator asking why the two dashboards disagree.

#Two attribution tools produce two numbers and a creator who trusts neither

A creator posts one link. Part of the audience taps it on a phone and subscribes inside the app. The rest open it on a laptop and subscribe through a web checkout. Split that across two attribution tools and the creator receives two dashboards, two windows, two hold periods, and no total.

The arithmetic is what breaks the relationship. Say a creator drove 60 paid conversions in a month, 38 in the app and 22 on the web. The mobile tool reports 38 and the web tool reports 22, and both are correct. The creator has to add them, believe that neither double counted the person who trialled on web and resubscribed in the app, and accept two different hold periods before either number becomes payable. Most creators do not audit this. They simply stop believing the smaller number, and the next negotiation starts from mistrust.

It fails in a subtler way too. A team runs the web program on a generic affiliate tool and the mobile side on nothing, because no web affiliate tool reads App Store receipts. Mobile revenue is then treated as organic, so the creators driving installs get paid for the fraction of their audience that happened to be at a desk.

Our opinion, and we will defend it: the split is not a reporting problem to reconcile later. It is an architecture problem, and the fix is to stop having two attribution systems.

#Cross-platform affiliate attribution is one click id, one window, one hold, one ledger

One model covers both surfaces. A click creates a click row with an id. A first open or a signup binds that click to a device. identify(userId) binds the device to your user id. Every revenue event afterwards is matched to that user id, inside the window, priced with one rate and held for one hold period.

Nothing in that chain mentions a billing platform. The join key is your user id, and the only requirement placed on RevenueCat, Stripe, Adapty, Superwall or Paddle is that the id they report back is the string you identified with. Our Billing and Webhooks reference names the field per provider, and calls a mismatch between the two "the single most common integration failure". Read it before you write code, because a mismatch fails silently: the funnel looks healthy and no commission is ever created.

Selection is last touch across every surface. The engine gathers all attributions carrying that user id, keeps the ones whose click predates the event and whose window has not expired, and picks the most recent firstOpenAt, breaking ties by id so the outcome is deterministic. A person with a web attribution from March and an app attribution from May credits the May creator, not both.

Worked example. One app, a 25 percent rate, a 30 day window and a 14 day hold. On 3 June a visitor clicks a creator link on a laptop and buys a $60.00 annual web plan: 6000 × 2500 / 10000 is 1500 cents, so $15.00 pending, maturing 17 June. On 9 June the same person installs the iOS app from a second link by a different creator and their $10.00 monthly renewal fires on 9 July: 1000 × 2500 / 10000 is 250 cents, so $2.50 to the second creator, whose 9 June click is the most recent one still inside its window. Two creators, two commissions, one ledger, $17.50 total.

#The two paths differ in six places, and share everything else

Verdict: everything from the commission row onward is identical, and every difference sits upstream of it, in how the click is captured and what the billing provider calls its events.

The two capture paths converge at identify(userId) and never diverge again.
StageMobile pathWeb path
Click captureA Universal Link or App Link carrying a claim token, or a creator code typed into a field`?via=`, `?ref=`, `?code=` or `?claim_token=` read off the URL, on load and on every client-side route change
Survival of the gapThe App Store drops the token, so a fresh install falls back to a hashed IP match inside the deferred window (60 minutes by default)A first-party `localStorage` entry (`maa_affiliate_id`) survives navigation, a closed tab, and the days between landing and signup
Identity storageA generated device id in the Keychain, which survives reinstallsA generated device id in `localStorage`, or a deterministic `srv_<userId>` when the server SDK records the signup
Billing eventRevenueCat `INITIAL_PURCHASE` and `RENEWAL`, joined on `app_user_id`Stripe `invoice.paid`, joined on `metadata.customer_user_id`, then `client_reference_id`, then the Stripe customer id
Refund signal`CANCELLATION` carrying `cancel_reason` of `CUSTOMER_SUPPORT`, because RevenueCat sends no event named `REFUND``charge.refunded`, which Stripe documents as occurring "whenever a charge is refunded, including partial refunds"
Amount on the payload`price_in_purchased_currency`, falling back to `price`, with `commission_percentage`, `takehome_percentage` and `tax_percentage` alongside it`invoice.amount_paid`. Stripe's processing fee is not in the event at all

Event names and field names verified against RevenueCat's event types and fields reference and Stripe's list of event types on 2026-09-13.

The store cut is the difference people expect to matter most, and it matters less than they think. Apple's Small Business Program advertises "a reduced commission rate of 15% on paid apps and In-App Purchases" for developers under one million USD in prior-year proceeds, verified on 2026-09-13.

We compute commission on the gross amount on both sides anyway. A creator quoted 25 percent of revenue should not earn a different rate depending on whether the subscriber was on iOS or in a browser. RevenueCat gives you takehome_percentage for a net policy. Stripe gives you nothing equivalent on the event, which is a good reason to pick gross and say so in the affiliate terms.

Click capture differs because the platforms differ, and both vendors document why. Apple's universal links documentation states that "If the person hasn't installed your app, the system opens the URL in their default web browser, allowing your website to handle it", verified on 2026-09-13. Android App Links are the verified equivalent, described by Google as an "enhanced deep linking capability that verifies deep links to your own website by establishing a trusted association between your app and your website", verified the same day. The web path needs none of this, which is why it goes first.

#Which of the six SDKs do you reach for?

Pick by surface, not by preference. Two of the six are web, four are mobile, and every one exposes the same pair of calls, so moving between them is a rename.

The six packages and the surface each one covers.
SDKPackage or coordinateReach for it when
iOS`MyAppAffiliate` via Swift Package Manager, iOS 15+You ship a native Swift app. The SwiftUI modifier also forwards incoming links
Android`com.myappaffiliate:sdk:0.2.0`, minSdk 24You ship native Kotlin. The manifest meta-data key means zero lines of startup code
React Native`@myappaffiliate/sdk-react-native`One codebase covers both stores and you want the `linking` option to wire deep links
Flutter`myappaffiliate_flutter: ^0.2.0`One Dart codebase covers both stores
Web`@myappaffiliate/sdk-web`A landing page, a marketing site, or a SaaS frontend. A script tag works with no build step
Node`@myappaffiliate/sdk-node`Your signup is server side, or the landing page and the signup request are different sessions

The Web and Node SDKs are not alternatives to each other in the way people assume. The Web SDK captures the referral, which only a browser can do, and the Node SDK records the binding, which is the half you want to be authoritative. Running both is the setup we would choose for a SaaS with a real backend: start in the app shell, trackSignup in the signup handler.

A hybrid team usually needs two of the six and occasionally three. Native iOS plus a Next.js dashboard is MyAppAffiliate and @myappaffiliate/sdk-web. React Native plus a marketing site is @myappaffiliate/sdk-react-native and the script tag. There is no SDK to add for the billing side, because that half is a webhook rather than code in your app.

#Capturing the click and calling identify() on both surfaces

Two calls per surface, and one of them is the same string on both. Everything below is copied from the SDK reference.

On iOS the SwiftUI modifier starts the SDK and forwards every incoming Universal Link to it, so the app writes no onOpenURL handler of its own.

swift
import MyAppAffiliate

@main
struct LumiApp: App {
  var body: some Scene {
    WindowGroup {
      ContentView().myAppAffiliate(apiKey: "pk_live_…")
    }
  }
}

// Once the account exists. Pass the same id you give Purchases.logIn(...),
// because RevenueCat reports it back to us as app_user_id.
MyAppAffiliate.identify(userId: user.id)

start runs inside the modifier, generates a Keychain device id, claims the referral from the link, retries anything a previous offline launch failed to send, and asks the API for a deferred match on a fresh install with no link to go on.

On the web the same two calls appear, with capture handled by the URL rather than by a link handler.

typescript
import { myAppAffiliate } from "@myappaffiliate/sdk-web";

myAppAffiliate.start("pk_live_…");

// At signup. Pass the same id you stamp into Stripe's
// subscription_data.metadata.customer_user_id.
await myAppAffiliate.identify(user.id);

start captures ?via= on the current URL and again on every client-side route change, persists the winner in localStorage before the network call, and returns a no-op when window is undefined, so a server-rendered layout needs no guard around it.

#Ten steps that wire both surfaces to one appId

Order matters. The web surface is faster to verify, and verifying it proves the ledger before Apple's setup gets involved.

  1. Onboard the app once and keep the output. It prints the appId, one SDK key, and a webhook URL plus secret for every supported provider.
  2. Start the Web SDK in your root layout with that key.
  3. Identify at signup, client side with identify(user.id) or server side with trackSignup({ userId, from: req.url }).
  4. Stamp that same id into subscription_data.metadata.customer_user_id when you create the Stripe Checkout Session, and into client_reference_id, which Stripe's Checkout Session reference describes as "a unique string to reference the Checkout Session", verified on 2026-09-13.
  5. Register the Stripe endpoint under your appId and select invoice.paid, charge.refunded, customer.subscription.deleted and checkout.session.completed.
  6. Verify the web loop end to end with a test-mode subscription before touching the app.
  7. Add the mobile SDK for your platform and start it with the same key.
  8. Call your mobile billing provider's own login with the same user id, then call identify(userId).
  9. Register the RevenueCat webhook under the same appId and paste its secret.
  10. Set the commission rate, attribution window and hold period once, in app settings, so both surfaces inherit them.

Step 10 is the one teams skip, and it is the one that keeps the numbers honest. Our defaults are a 30 day attribution window and a 14 day hold, both editable per app. If you are choosing a window rather than accepting ours, how attribution windows work covers why creator traffic converts later than paid traffic does.

#Where cross-device matching still breaks

Honestly: we do not solve cross-device. Attribution is anchored to a device id and joined to a person only at identify(userId). A laptop click followed by a phone install shares nothing deterministic, so there is a real class of conversion we cannot credit.

The deferred path is the only bridge, and it is a guess. When a fresh install arrives with no claim token, we look for an unattributed click from the same hashed IP inside the deferred window, 60 minutes by default. That match is stored as deferred_ip rather than claim_token, and it is labelled probabilistic everywhere it surfaces, including the dashboard. When two creators are plausible inside that window, the API answers 409 and creates nothing, because paying the wrong creator is worse than paying no one and saying why.

Two gaps stay open, and we would rather name them than let you find them in a payout dispute. Desktop-to-mobile crosses a network boundary the IP heuristic usually cannot span. A person who subscribes on the web, then installs the app and identifies with the same user id, gets one ledger only because your database already knew they were the same person. Nothing we run builds an identity graph, and nothing we run will: that is the step that turns a first-party attribution product into a tracking product.

What we would do instead, and what we tell teams to do, is make the creator code visible. A code typed into a field is deterministic across every device and every store, needs no domain setup, and converts the desktop-to-mobile case from a guess into a fact. It costs one text field in onboarding. Some teams find it earns more accurate attribution than deep links do, because the audience that types a code is the audience that actually remembered the creator.

#One appId covers both surfaces, so wire the web side first

MyAppAffiliate is a good fit when a single subscription product bills through more than one platform and you want one number per creator. Pricing is a flat subscription rather than a percentage of your revenue, attribution is first-party and deterministic with no IDFA and no fingerprinting, and RevenueCat and Stripe are handled natively rather than through a generic tracking pixel. It is a poor fit if you need paid-media measurement: this is a partner attribution engine, not an MMP, and the mobile affiliate tool comparison is more useful if that is your question.

Start with step 2 above. Drop the Web SDK into your root layout, run one test-mode Stripe subscription through it, and confirm a commission appears against the right creator. That takes an afternoon and proves the ledger before you touch Associated Domains. The mobile half is then the same two calls against the same appId, with the event mapping documented in which RevenueCat events move money and the full mobile setup in the RevenueCat attribution guide. Flat pricing is on our pricing page.

Can one affiliate program cover a mobile app and a web product?

Yes, under one appId. Both surfaces use the same SDK key, and each billing provider posts to its own endpoint under that same app, so RevenueCat subscriptions and Stripe invoices land in one commission ledger with one rate, one attribution window and one hold period.

Do I need separate SDK keys for the app and the website?

No. Onboarding prints one appId and one SDK key, and every SDK for that app uses it. The web key is public by design and the Node key belongs in server environment variables, but they are the same string, so nothing has to be kept in sync between the two surfaces.

Which user id should I identify with when RevenueCat and Stripe both bill me?

The one your own database already owns. RevenueCat carries it in `app_user_id` via `Purchases.logIn`, and Stripe carries it in `metadata.customer_user_id` on the subscription. Using your id on both sides is what lets a person who subscribed on web and reinstalled the app stay one row.

What happens when someone clicks on a laptop and subscribes in the app?

Nothing joins those two devices deterministically. A hashed IP match inside the deferred window can catch it when both sit behind the same network, and a creator code typed in the app catches it reliably. Otherwise that install is organic and no commission is created.

Do Apple's commission and Stripe's fees change what a creator earns?

Not by default. Commission runs on the gross amount on both sides: `price_in_purchased_currency` from RevenueCat and `invoice.amount_paid` from Stripe. RevenueCat does send `takehome_percentage` and `tax_percentage` on the payload, so a net-of-store deal is computable, but it is a deliberate policy choice rather than the default.

Can a creator see mobile and web earnings as one number?

Yes. The creator view is scoped to an affiliate inside an app and sums commissions regardless of which provider produced the event. Pending, matured and paid stay separate columns, because the question creators ask is why a number moved. More in [paying commissions on subscriptions](/blog/affiliate-commissions-payouts-subscriptions) and [RevenueCat subscriber attributes](/blog/revenuecat-subscriber-attributes-affiliate-id).