Skip to content

CyonCode/AttributionKit

Repository files navigation

AttributionKit

Lightweight iOS attribution SDK that resolves install source via a 3-step waterfall: Apple Search Ads → fingerprint match → cached UTM → organic fallback. Pairs with a Fastify backend that stores attribution and joins it to revenue events from a subscription provider's webhooks.

  • Platform: iOS 15+
  • Swift: 5.9
  • Dependencies: none

Install

// Package.swift
dependencies: [
    .package(url: "https://github.com/CyonCode/AttributionKit", from: "1.0.0"),
],
targets: [
    .target(name: "YourApp", dependencies: ["AttributionKit"]),
]

Quick start

import AttributionKit

// At launch (e.g. AppDelegate or @main App.init)
AttributionKit.shared.configure(
    apiKey:  "<product api_key from server /admin>",
    appId:   "<product app_id from server /admin>",
    baseURL: "https://attribution.your-domain.com"
)
AttributionKit.shared.performAttributionIfNeeded()

That's it for install attribution. The SDK handles ASA → fingerprint → UTM → organic resolution and reports to your backend, which stores it under (app_id, idfv).

UTM tracking

If you ship Universal Links from your landing pages, hand the URL to the SDK:

.onOpenURL { url in
    AttributionKit.shared.handleUniversalLink(url)
}

UTM params (utm_source, utm_medium, utm_campaign, utm_content) are cached locally and merged into attribution if ASA returns nothing.


Revenue tracking

Attribution alone tells you where users came from. To know what they spent, the backend ingests revenue webhooks. Three integration paths are supported: Qonversion, Adapty, or talking to Apple directly via App Store Server Notifications V2 (no third-party SDK). The SDK does not receive purchase events directly — by design, the account-of-record is the App Store.

Pick one provider. Running multiple paths (Qonversion + Adapty + AppStore-direct) against the same App Store account will double-count revenue. Dedup keys are scoped per-provider, so the server cannot collapse a duplicate purchase that arrives from two different sources.

This guide uses Qonversion as the reference integration. See Examples/RevenueIntegration.swift for the complete copy-pasteable file.

How attribution joins to revenue

The backend joins Revenue.external_user_id to Attribution.idfv. AttributionKit already uploads IDFV as its identifier, so the only thing you need to do app-side is make sure your subscription SDK reports the same IDFV as its user id.

[Your App]                                       [Attribution Server]

AttributionKit.performAttributionIfNeeded()  →   Attribution.idfv = <IDFV>
                                                          ↕   (join key)
Qonversion.setUserProperty(.userID, <IDFV>)  →   Revenue.external_user_id = <IDFV>

If these two values don't match, revenue events are still recorded but tagged attribution_source = 'unknown' and you lose LTV-by-source resolution.

Integration steps (Qonversion)

1 — App-side: identify the user with IDFV

import AttributionKit
import Qonversion
import UIKit

func setupAttributionAndPurchases() {
    // Attribution
    AttributionKit.shared.configure(
        apiKey:  "<api_key>",
        appId:   "<app_id>",
        baseURL: "https://attribution.your-domain.com"
    )
    AttributionKit.shared.performAttributionIfNeeded()

    // Qonversion
    let config = Qonversion.Configuration(
        projectKey: "<your-qonversion-project-key>",
        launchMode: .subscriptionManagement
    )
    Qonversion.initWithConfig(config)

    // ⚠️ The single most important line for revenue attribution:
    // align Qonversion's customUserId with AttributionKit's idfv.
    if let idfv = UIDevice.current.identifierForVendor?.uuidString {
        Qonversion.shared().setUserProperty(.userID, value: idfv)
    }
}

Call once on app launch, before any purchase flow runs.

2 — Server-side: configure webhook secret

# server/.env
QONVERSION_WEBHOOK_TOKEN=<long-random-string>

Restart the server. Hitting the webhook without this set returns 500 webhook_not_configured.

3 — Qonversion dashboard: register webhook

  • Open Qonversion → Project Settings → Integrations → Webhooks
  • URL: https://attribution.your-domain.com/v1/webhook/qonversion/<your-appId>
  • Auth: Basic Auth, token = the QONVERSION_WEBHOOK_TOKEN value from step 2
  • Events: enable all subscription + in-app events (the server's normalizer maps 16 Qonversion event names into a unified schema; unsupported events are acked-and-ignored)

4 — Verify

Make a sandbox purchase. Within ~10 seconds, you should see a fresh document in MongoDB:

db.revenues.find().sort({ createdAt: -1 }).limit(1).pretty()

// → provider:           'qonversion'
//   external_user_id:   '<your IDFV>'
//   event_type:         'initial_purchase' | 'trial_started' | ...
//   amount_usd:          0.99
//   attribution_source: 'asa' | 'organic' | 'tiktok' | ...      ← if 'unknown', step 1 didn't fire in time

If attribution_source === 'unknown', the IDFV alignment didn't fire before purchase. Confirm Qonversion.shared().setUserProperty(.userID, …) runs at app launch, not lazily on the paywall screen.

Alternative: App Store Server Notifications V2 (no SDK)

If you'd rather skip Qonversion/Adapty and talk to Apple directly via StoreKit 2 + server-to-server notifications:

StoreKit 2 only. StoreKit 1 (SKMutablePayment) has no equivalent of appAccountToken, so the revenue join cannot work. This path requires iOS 15+ and the StoreKit 2 Product.purchase(options:) API.

1 — App-side: pass IDFV as appAccountToken on every purchase

This is the join key the server uses to attach Attribution → Revenue.

import StoreKit
import UIKit

@MainActor
func purchase(_ product: Product) async throws -> Transaction? {
    // ⚠️ The single most important line for AppStore webhook attribution:
    // pass IDFV as appAccountToken. identifierForVendor returns UUID?
    // directly — no string parsing needed.
    let result: Product.PurchaseResult
    if let idfv = UIDevice.current.identifierForVendor {
        result = try await product.purchase(options: [.appAccountToken(idfv)])
    } else {
        // No IDFV: revenue will be tagged attribution_source = 'unknown'.
        result = try await product.purchase()
    }

    switch result {
    case .success(let verification):
        if case .verified(let transaction) = verification {
            await transaction.finish()
            return transaction
        }
        return nil
    case .userCancelled, .pending: return nil
    @unknown default: return nil
    }
}

2 — Server-side: set app_store_id

The numeric App Store ID (the id{N} in your App Store URL — equivalent to Apple's appAppleId / Adam ID) is required for production JWS verification. In the admin dashboard, paste it into the product and click Refresh from App Store to auto-populate the rest. Without it, only sandbox notifications are accepted.

3 — App Store Connect: register the webhook URL

  • App Store Connect → My Apps → [your app] → App Information → App Store Server Notifications
  • Production Server URL: https://attribution.your-domain.com/v1/webhook/appstore/<your-appId>
  • Sandbox Server URL: same URL (the server distinguishes by JWS payload)
  • Version: Version 2

No shared secret. Apple signs every notification with a JWS chain; the server verifies against the bundled Apple Root CAs (G2 + G3).

4 — Verify

Make a sandbox purchase. Within ~10 seconds:

db.revenues.find({ provider: 'appstore' }).sort({ createdAt: -1 }).limit(1).pretty()

// → provider:           'appstore'
//   external_user_id:   '<your IDFV>'     ← if missing, step 1 didn't fire before purchase
//   event_type:         'initial_purchase' | 'renewal' | 'refund' | ...
//   environment:        'sandbox'
//   attribution_source: 'asa' | 'organic' | 'tiktok' | ...

Notes & gotchas

  • IDFV resets on app reinstall. Renewals after a reinstall produce a new custom_user_id that no longer matches the original Attribution.idfv. Renewal events get tagged attribution_source = 'unknown'. If renewal-LTV matters, add a server-side fallback that looks up the original attribution_* snapshot via original_transaction_id.
  • ATT prompt. IDFV is available without ATT consent and is stable per (vendor, device), so attribution does not require ATT.
  • Sandbox vs Production. Each Revenue document carries environment. Filter sandbox events out of LTV queries.
  • distinctIdProvider in configure(...) is only used to attach an analytics distinct_id (e.g. PostHog) to attribution requests for downstream identify. It does not affect the revenue join — that always uses IDFV.
  • StoreKit 2 only for AppStore-direct. The AppStore webhook path requires Product.purchase(options: [.appAccountToken(...)]) to forward IDFV. StoreKit 1's SKMutablePayment.applicationUsername is a different field and does not surface in ASSN V2 transactions, so the revenue join can't work on StoreKit 1.
  • No tests yet. Tests/AttributionKitTests/ is a placeholder.

File map

AttributionKit/
├── Sources/AttributionKit/         # SDK source
├── Examples/
│   └── RevenueIntegration.swift    # Copy-pasteable reference integration
├── Tests/AttributionKitTests/      # Placeholder
├── Package.swift
├── README.md                       # ← you are here
└── AGENTS.md                       # internal dev notes

About

AttributionKit: zero-dependency iOS attribution SDK (ASA, UTM, fingerprint matching)

Resources

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages