Skip to content

Commit 7e09816

Browse files
Show AA-targeted paywall
1 parent 675f2b1 commit 7e09816

2 files changed

Lines changed: 161 additions & 0 deletions

File tree

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
---
2+
title: "Show an AA-targeted paywall on first launch"
3+
description: "Wait for Apple Ads attribution before requesting the paywall on iOS using AdaptyProfile.appliedAttributionSources."
4+
metadataTitle: "Show AA-targeted paywall on first launch | iOS SDK | Adapty Docs"
5+
---
6+
7+
Apple Ads (AA) attribution arrives asynchronously after `Adapty.activate()`. If you call `getPaywall` early, attribution often hasn't landed yet and Adapty resolves the placement against the default audience — bypassing your ASA-segmented paywalls. `AdaptyProfile.appliedAttributionSources` lets the app detect when ASA attribution has been applied to the profile, so the paywall request can wait until ASA segmentation will resolve correctly.
8+
9+
## Before you start
10+
11+
You need:
12+
- Adapty iOS SDK **3.17.1** or later.
13+
- Apple Ads configured for the app in Adapty. See [Apple Ads](apple-search-ads).
14+
15+
## How it works
16+
17+
After `Adapty.activate()`, the SDK requests Apple Ads attribution from Apple in the background and forwards the result to Adapty's backend. When ASA becomes the active attribution source for the profile, the SDK delivers an updated `AdaptyProfile` whose `appliedAttributionSources` array contains `.appleAds`.
18+
19+
An empty array can mean any of:
20+
21+
- Apple Ads attribution hasn't been processed yet for this profile.
22+
- No attribution has arrived at all.
23+
24+
Even with an empty array, `getPaywall` is still safe to call — Adapty resolves the request against whichever audience matches the current profile state, typically the default audience.
25+
26+
:::important
27+
The wait only applies to **first launch**. Once Apple Ads attribution has been recorded, it's stored on the profile permanently. On every subsequent launch, the cached profile already carries `.appleAds` in `appliedAttributionSources`, `didLoadLatestProfile` fires with that value immediately, and `getPaywall` returns the Apple-Ads-segmented paywall without any delay.
28+
:::
29+
30+
## Implementation
31+
32+
On first launch, watch for `.appleAds` in the profile and apply a hard timeout — if the Apple Ads attribution never arrives, those users still need to see a paywall.
33+
34+
1. **Activate the SDK.** See [Install & configure the iOS SDK](sdk-installation-ios).
35+
2. **Subscribe to profile updates** by conforming to `AdaptyDelegate` and implementing `didLoadLatestProfile`. If you haven't set up the delegate yet, see [Listen to subscription updates](ios-check-subscription-status#listen-to-subscription-updates).
36+
3. **Watch for `.appleAds` in `appliedAttributionSources`.** When it appears, request the paywall — Adapty will return the ASA-segmented variant:
37+
38+
```swift
39+
extension <YourAdaptyDelegateImpl>: AdaptyDelegate {
40+
nonisolated func didLoadLatestProfile(_ profile: AdaptyProfile) {
41+
if profile.appliedAttributionSources.contains(where: { $0 == .appleAds }) {
42+
// load paywall via Adapty.getPaywall(placementId:)
43+
}
44+
}
45+
}
46+
```
47+
48+
4. **Start a 3–5 second timer in parallel with the subscription.** If the timer fires before `.appleAds` appears, request the paywall anyway:
49+
50+
Whichever path fires first should load the paywall; the other path should be skipped. Use a single state flag (for example, `hasLoadedPaywall`) to deduplicate so the paywall isn't fetched twice. Configure a [fallback paywall](fallback-paywalls) for the placement so the user is never stuck if the network request fails.
51+
52+
## Complete example
53+
54+
The implementation below races attribution against a timeout, prefetches the default-audience paywall in parallel, and returns whichever paywall is appropriate. The caller awaits a single async function — no delegates or state flags to manage at the call site.
55+
56+
`ProfileObserver` is a reusable singleton that publishes profile updates from `AdaptyDelegate`. `PaywallLoader.getPaywallOrDefault` runs the race using a structured-concurrency `TaskGroup`:
57+
58+
- If attribution lands within `timeout`, it returns the segmented paywall via `getPaywall(placementId:)`.
59+
- If `timeout` elapses first, it returns the prefetched default-audience paywall via `getPaywallForDefaultAudience(placementId:)`.
60+
61+
```swift title="PaywallLoader.swift"
62+
import Adapty
63+
import Combine
64+
65+
/// Demonstrates how to fetch a paywall that depends on attribution being applied,
66+
/// falling back to the default-audience paywall if attribution doesn't arrive in time.
67+
///
68+
/// Stateless and self-contained: every call kicks off its own default-audience
69+
/// prefetch and races it against attribution + segmented fetch.
70+
enum PaywallLoader {
71+
static func getPaywallOrDefault(
72+
placementId: String,
73+
timeout: TimeInterval
74+
) async throws -> AdaptyPaywall {
75+
struct TimedOut: Error {}
76+
77+
// Kick off the default-audience request immediately so it has the full
78+
// `timeout` window to load. We'll either cancel it on success or await
79+
// its result on timeout — never a duplicate network call.
80+
let defaultPaywallTask = Task {
81+
try await Adapty.getPaywallForDefaultAudience(placementId: placementId)
82+
}
83+
84+
do {
85+
// Race two child tasks: whichever finishes first wins.
86+
let result = try await withThrowingTaskGroup(of: AdaptyPaywall.self) { group in
87+
// 1. Wait for attribution, then ask Adapty for the segmented paywall.
88+
group.addTask {
89+
await waitForAttribution()
90+
return try await Adapty.getPaywall(placementId: placementId)
91+
}
92+
// 2. Time-bomb: throws `TimedOut` after `timeout` seconds.
93+
group.addTask {
94+
try await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000))
95+
throw TimedOut()
96+
}
97+
guard let value = try await group.next() else { throw CancellationError() }
98+
group.cancelAll() // stop the loser (sleeper or the attribution wait).
99+
return value
100+
}
101+
// Segmented paywall won — we no longer need the default-audience prefetch.
102+
defaultPaywallTask.cancel()
103+
return result
104+
} catch is TimedOut {
105+
// Attribution didn't apply in time — return the prefetched default
106+
// (instant if already done, otherwise we await the in-flight request).
107+
return try await defaultPaywallTask.value
108+
}
109+
}
110+
111+
/// Suspends until a profile with the desired attribution source is observed.
112+
/// `@Published.values` emits the current profile immediately on subscription,
113+
/// so this returns on the first iteration if attribution is already applied.
114+
@MainActor
115+
private static func waitForAttribution() async {
116+
for await profile in ProfileObserver.shared.$profile.values {
117+
if profile?.appliedAttributionSources.contains(.appleAds) == true { return }
118+
}
119+
}
120+
}
121+
122+
@MainActor
123+
final class ProfileObserver: AdaptyDelegate {
124+
static let shared = ProfileObserver()
125+
126+
@Published private(set) var profile: AdaptyProfile?
127+
128+
nonisolated func didLoadLatestProfile(_ profile: AdaptyProfile) {
129+
Task { @MainActor [weak self] in
130+
self?.profile = profile
131+
}
132+
}
133+
}
134+
```
135+
136+
Wire `ProfileObserver` into `AdaptyDelegate` once, after `Adapty.activate()` completes:
137+
138+
```swift
139+
Adapty.delegate = ProfileObserver.shared
140+
```
141+
142+
Call from the splash screen:
143+
144+
```swift
145+
do {
146+
let paywall = try await PaywallLoader.getPaywallOrDefault(
147+
placementId: "YOUR_PLACEMENT_ID",
148+
timeout: 5
149+
)
150+
// present the paywall
151+
} catch {
152+
// handle the error or show a fallback paywall
153+
}
154+
```
155+
156+
If your app already uses an `AdaptyDelegate` for other purposes (for example, [listening to subscription updates](ios-check-subscription-status#listen-to-subscription-updates)), forward `didLoadLatestProfile` to `ProfileObserver.shared` from your existing delegate instead of setting `Adapty.delegate = ProfileObserver.shared`.

src/data/sidebars/ios.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,11 @@
213213
"type": "doc",
214214
"label": "Optimize paywall fetching",
215215
"id": "ios-optimize-paywall-fetching"
216+
},
217+
{
218+
"type": "doc",
219+
"label": "Show an ASA-targeted paywall on first launch",
220+
"id": "ios-show-asa-targeted-paywall"
216221
}
217222
],
218223
"id": "ios-best-practices"

0 commit comments

Comments
 (0)