Integrate
iOS SDK (Swift)
The reference for FlowAffiliate, the native Swift SDK that catches the invite an iOS app or App Clip was opened or installed with and hands it over in the shape POST /v1/attributions takes.
FlowAffiliate catches the invite your iOS app was installed or opened with, once per install. It can come from the link, from what your App Clip left, from your deep-link SDK's params, or, if you turn it on, from a probable match of the first launch to a recent click. What none of these catch is left to the code the person types into your code field, which normalizeCode reads. The SDK only reacts to Flow Affiliate invites, never to your app's own referral links. It holds no keys, and no money ever comes from the phone. It gives your app an affiliate. Your app sends it to your backend, which confirms the new user to Flow with POST /v1/attributions. Links into your app explains each way in and what to set up for it.
It needs iOS 14 or later and Xcode 16 or later. It depends on nothing but Foundation and Combine, so it also fits in an App Clip. It behaves like the Expo SDK, so an app that mixes the two works: for example an Expo app with a native App Clip.
Install
Swift Package Manager. In Xcode, choose File → Add Package Dependencies…, enter the URL below and add the FlowAffiliate library to your app target, and to your App Clip target if you have one. In a Package.swift:
.package(url: "https://github.com/Ebuno-AB/flow-affiliate-ios", from: "0.1.0")CocoaPods. In your Podfile, for the app target and the App Clip target:
pod 'FlowAffiliate', '~> 0.1'Configure and start
Configure once at launch, then call start(). In SwiftUI:
import FlowAffiliate
import SwiftUI
@main
struct YourApp: App {
init() {
FlowAffiliate.configure(
appId: "6f1c2b3a-4d5e-4f60-8a7b-9c0d1e2f3a4b", // your app's id in Flow: turns on install matching
linkHosts: ["go.yourapp.com"], // your own link domain, if you have one
appGroup: "group.com.yourapp" // only with an App Clip
)
}
var body: some Scene {
WindowGroup {
ContentView()
.onOpenURL { FlowAffiliate.handle(url: $0) }
.onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { FlowAffiliate.handle(userActivity: $0) }
.task { await FlowAffiliate.start() }
}
}
}configure
appIdString?Your app's id in Flow Affiliate, a UUID. It is not a secret, so it is safe to ship in the app. It turns on install matching. Without it, the SDK makes no requests.
apiURLURLlinkHosts[String]Your own link domains, added to Flow Affiliate's: go.affiliate.yflow.io and any subdomain of it, such as yourapp.go.affiliate.yflow.io, are always read. A link's /a/<code> is read only on these hosts; fg_click and fg_ref are read on any link. An entry such as *.yourapp.com covers every subdomain of that domain.
appGroupString?The App Group your App Clip shares with the app. On the first launch, what the clip left there is read. See App Clip.
It checks the channels in this order and keeps the first that names a code or a click:
- The link the app was opened with, handed over with
handle(url:)orhandle(userActivity:), on any launch until something is caught: universal links and custom schemes. A link handed over beforestart()comes first.sourceislink. - What your App Clip left, on the first launch, when you pass
appGroup.sourceisapp_clip. - Your deep-link SDK's params, when you hand them over with
ingest(params:).sourceisbranchfor Branch,otherfor anything else. - The install match, when you pass
appId, once per install: on the first launch, or on a later one within 72 hours if Flow Affiliate wasn't reached.sourceisinstall_matchandconfidenceisprobable.
Only Flow Affiliate invites count: fg_click and fg_ref, or /a/<code> on a link host. Your app's own referral links and keys, such as code, are never read. The first catch is kept for the life of the install, with a random installId, in UserDefaults, which is emptied when the app is deleted. Later catches are still answered, so you can send them, but affiliate doesn't change.
Call FlowAffiliate from the main thread, as SwiftUI and the app and scene delegates do. FlowAffiliateClip can be called from any thread.
FlowAffiliate
start()async → AffiliateStateChecks what this launch brought, once: the stored catch, the App Clip handoff, then the install match. Calling it again answers with the same check.
handle(url:)Affiliate?handle(userActivity:)Affiliate?NSUserActivity (NSUserActivityTypeBrowsingWeb).ingest(params:)Affiliate?affiliateAffiliate?stateAffiliateStateready (this launch's check is done, the install match included), affiliate and installId.publisherAnyPublisher<AffiliateState, Never>normalizeCode(_:)String?invite(code:)async → AffiliateInvite?reset()async → AffiliateStateUniversal links
An installed app opens straight from a publisher's link. In your program's setup, step 3 Your app in the stores, under Advanced, fill in your iOS app IDs (Team ID and bundle ID). Flow Affiliate then publishes /.well-known/apple-app-site-association on your app's link host, covering /a/*. Then add the Associated Domains capability to your app target with your link host, as Tracking host shows it, and your own domain if you have one:
applinks:yourapp.go.affiliate.yflow.io
applinks:go.yourapp.comSwiftUI. Hand every URL to the SDK, as in the example above. Universal links arrive at .onOpenURL, or at .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) when you have one. A link handed over twice still counts once. Route links the SDK answers nil for as usual.
UIKit with scenes. A cold start brings the link in the connection options; a running app gets it through the two callbacks:
import FlowAffiliate
import UIKit
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
connectionOptions.urlContexts.forEach { FlowAffiliate.handle(url: $0.url) }
connectionOptions.userActivities.forEach { FlowAffiliate.handle(userActivity: $0) }
Task { await FlowAffiliate.start() }
}
func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
URLContexts.forEach { FlowAffiliate.handle(url: $0.url) }
}
func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
FlowAffiliate.handle(userActivity: userActivity)
}
}Call FlowAffiliate.configure(…) in application(_:didFinishLaunchingWithOptions:). Without scenes, use application(_:open:options:) and application(_:continue:restorationHandler:) the same way.
The affiliate
Affiliate is Codable, and encodes to the body of POST /v1/attributions. Send it to your backend as it is; your backend forwards it, adding appUserId only for a user who already has an account.
{
"url": "https://yourapp.go.affiliate.yflow.io/a/k7qxa2mp",
"code": "k7qxa2mp",
"source": "link",
"confidence": "deterministic",
"platform": "ios",
"installId": "5b1d0c9e-2f4a-4b8c-9d7e-6f5a4b3c2d1e"
}Affiliate
codeString?clickIdString?urlString?params[String: String]?sourceAffiliateSourcerequiredlink, app_clip, branch, install_match or other.confidenceAffiliateConfidence?deterministic for a link or the App Clip, probable for an install match. For deep-link params, Branch's +match_guaranteed; otherwise nil.platformStringrequiredios.installIdStringrequiredpublisherNameString?Send it to your backend
Send the affiliate as soon as you have it, before sign-up if you like. Asking binds nothing. publisher gives you the stored catch at launch and a link that opens the app later:
.onReceive(FlowAffiliate.publisher.compactMap(\.affiliate).removeDuplicates()) { affiliate in
Task {
var request = URLRequest(url: URL(string: "https://api.yourapp.com/affiliate/attribution")!)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "content-type")
request.httpBody = try? JSONEncoder().encode(affiliate)
_ = try? await URLSession.shared.data(for: request)
}
}Your backend asks POST /v1/attributions with its own key, keeps the attribution key with the sign-up, and confirms the new user with user.registered. The phone never talks to Flow about attribution.
For a SwiftUI view, AffiliateModel is an ObservableObject with the same state, kept current:
struct SignUpView: View {
@StateObject private var invite = AffiliateModel()
var body: some View {
if let code = invite.affiliate?.code {
Text("Invited with code \(code)")
} else if invite.ready {
CodeField() // nothing was caught: let them type the code
}
}
}Deep-link SDKs
If you already use Branch, AppsFlyer or Adjust, hand their params over as they are. Flow Affiliate reads fg_ref and fg_click from their keys, and from a Flow Affiliate link among their values, such as Branch's ~referring_link. Your own keys are ignored.
// Branch
Branch.getInstance().initSession(launchOptions: launchOptions) { params, _ in
FlowAffiliate.ingest(params: params)
}
// AppsFlyer, in your DeepLinkDelegate
func didResolveDeepLink(_ result: DeepLinkResult) {
FlowAffiliate.ingest(params: result.deepLink?.clickEvent)
}
// Adjust, the deferred deep link
func adjustDeferredDeeplinkReceived(_ deeplink: URL?) -> Bool {
if let deeplink { FlowAffiliate.ingest(params: ["deeplink": deeplink]) }
return true
}App Clip
An App Clip catches the click on an iPhone that doesn't have your app yet. It is optional: see App Clip for when to use one. A phone without your app goes to Apple's App Clip link, https://appclip.apple.com/id?p=<App Clip bundle ID>, with fg_click and fg_ref added. The clip leaves them in the App Group it shares with your app, and your app's first launch reads them there.
- An App Clip target with a bundle ID such as
com.yourapp.Clip, withFlowAffiliateadded to it. - An App Group, such as
group.com.yourapp, in both targets' App Groups capability. - In the clip, save the invocation URL and, if you like, show who invited them:
import FlowAffiliate
import SwiftUI
@main
struct YourAppClip: App {
@State private var invite: AffiliateInvite?
var body: some Scene {
WindowGroup {
ClipView(invite: invite) // the greeting, the bonus and an "Install the app" button
.onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in
guard let url = activity.webpageURL else { return }
FlowAffiliateClip.saveHandoff(url: url, appGroup: "group.com.yourapp")
Task { invite = await FlowAffiliateClip.invite(url: url) }
}
}
}
}- In the app, pass the same group:
FlowAffiliate.configure(appGroup: "group.com.yourapp"). What the clip left comes out on the first launch withsource: app_clip, as sure as a link open. It is kept for 30 days.
saveHandoff(url:appGroup:) answers false when the URL carries no invite or the group can't be written. invite(url:) fetches GET /v1/share/landing/:code with a 2-second limit: greeting, bonus (such as "5 USD"), appName and appColor, or nil. It writes the same file as the Expo SDK's saveHandoff, flowaffiliate.handoff in the group's container, so a native clip and an Expo app, or the other way round, work together.
To test, set _XCAppClipURL in the clip scheme's environment to https://appclip.apple.com/id?p=com.yourapp.Clip&fg_click=<a click id>&fg_ref=<a code>. Run the clip, then install and open the app on the same simulator: FlowAffiliate.affiliate should have source: app_clip. Then, in your program's setup, step 3 Your app in the stores, under Advanced, enter the App Clip bundle ID and choose App Clip as the Route for iPhones. Do this only once the clip is live in the App Store.
Typed codes
Every app needs a code field: on iPhone nothing passes through the App Store, and an install match can miss. normalizeCode reads what someone typed or pasted. That can be the code itself, also typed in groups, or a Flow Affiliate link, or a message with one. It answers nil when there is no code of 3 to 24 letters and digits. It keeps the case as typed, since Flow Affiliate matches codes whatever their case. It is not part of the automatic catch: what it reads is yours to send, as code.
FlowAffiliate.normalizeCode(" K7QX A2MP ") // "K7QXA2MP"
FlowAffiliate.normalizeCode("https://go.affiliate.yflow.io/a/k7qxa2mp") // "k7qxa2mp"
FlowAffiliate.normalizeCode("Join me: https://yourapp.go.affiliate.yflow.io/a/k7qxa2mp") // "k7qxa2mp"
FlowAffiliate.normalizeCode("hello!") // nilA link's /a/<code> counts only on Flow Affiliate's hosts and the linkHosts you configured.
What it never does
- Touch the clipboard. It never reads or checks it.
- Match without your say. The install match runs only with
appId. It sends the OS version, the model identifier (iPhone15,2), the screen size in pixels, the language, the time zone and the install id. Flow Affiliate sees the IP address the request comes from and keeps only a keyed hash of it. See install matching. - Hold a key, or ask about attribution. Your backend asks, with its own signed key. The install match is a question about a click, not an attribution, and needs no key.