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
// Package.swift
dependencies: [
.package(url: "https://github.com/CyonCode/AttributionKit", from: "1.0.0"),
],
targets: [
.target(name: "YourApp", dependencies: ["AttributionKit"]),
]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).
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.
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.
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.
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.
# server/.env
QONVERSION_WEBHOOK_TOKEN=<long-random-string>Restart the server. Hitting the webhook without this set returns 500 webhook_not_configured.
- Open Qonversion → Project Settings → Integrations → Webhooks
- URL:
https://attribution.your-domain.com/v1/webhook/qonversion/<your-appId> - Auth: Basic Auth, token = the
QONVERSION_WEBHOOK_TOKENvalue 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)
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 timeIf 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.
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 ofappAccountToken, so the revenue join cannot work. This path requires iOS 15+ and the StoreKit 2Product.purchase(options:)API.
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
}
}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.
- 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).
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' | ...- IDFV resets on app reinstall. Renewals after a reinstall produce a new
custom_user_idthat no longer matches the originalAttribution.idfv. Renewal events get taggedattribution_source = 'unknown'. If renewal-LTV matters, add a server-side fallback that looks up the originalattribution_*snapshot viaoriginal_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. distinctIdProviderinconfigure(...)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'sSKMutablePayment.applicationUsernameis 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.
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