Developer Docs / iOS SDK / Ads
GrowthCat iOS Ads SDK
In-app advertising built into the same Swift package as referral redemption. Banners, interstitials, rewarded ads, and App Store install ads — all with server-side config, offline queuing, and reward validation.
Ad formats
Banner, interstitial image, interstitial video, rewarded image, rewarded video, and App Store install.
Built-in guarantees
Server-side format control, offline event queuing, server-validated rewards, and App Store-compliant disclosure labels.
Not included
Ad creation, campaign management, creative uploads, or advertiser billing. Those live in the GrowthCat dashboard.
1. Overview
The GrowthCat Ads system handles the full ad lifecycle automatically. The same GrowthCat.initialize call that sets up referral also bootstraps the ads engine — no extra configuration step is required.
Full lifecycle at a glance
GrowthCat.initialize()→ fetches bootstrap config including ads settings.loadAd(placementKey:)→ fetches catalog, caches it, pre-downloads R2 creative assets to disk.- Render ad via
GrowthCatAdBannerVieworGrowthCatAdView. - SDK fires impression, click, and video events — batched and persisted offline automatically.
- For rewarded ads:
POST /v1/ads/reward/validatereturns server confirmation before any reward is granted.
Built-in guarantees
- Ads are only shown when
ads_enabled: truefrom the server. - Only formats listed in the server
formatsarray are served. - Creative assets are served from R2 and pre-fetched so ads render without a network delay.
- Every ad format shows a visible "Sponsored" label — required by App Store policy.
- Rewards are only granted after server-side validation succeeds.
- Stable
sdk_event_idvalues prevent double-counting on retry.
Not handled by the SDK
- Creating or managing ad campaigns or creatives.
- Advertiser billing and payout reconciliation.
- Analytics dashboards or reporting UI.
- Granting rewards before
reward_validated: true.
2. Requirements
- iOS 16+
- GrowthCat SDK installed and initialized (same Swift Package as referral)
- GrowthCat app SDK key (
gc_live_...) - Ads feature enabled in your GrowthCat dashboard
- At least one placement key configured in your dashboard
RevenueCat is not required for ads. You only need RevenueCat if you are also using the referral redemption features in the same app.
3. Production readiness checklist
GrowthCat.initializeis called before any ad is shown.ads_enabled: trueis confirmed before showing ad entry points.appUserIDis passed to all rewarded ad views andvalidateAdRewardcalls.- No reward is granted unless
response.rewardValidated == true. prefetchAdCatalogsis called on app foreground.flushAdEventsis called when the app enters the background.- No-fill (
loadAdreturnsnil) is handled gracefully — no empty containers left in layout. - Every rendered ad shows a "Sponsored" disclosure label.
close_policy.skippable_after_secondsis enforced — close button hidden until timer elapses.- For rewarded formats, close button is hidden until the reward condition is met.
- Both banner and interstitial formats tested, including rewarded and App Store install creatives.
- Offline scenario tested: events queue locally and flush on reconnect.
4. Ad formats
The SDK exposes two public formats. The server decides the exact creative type within each format — you never need to branch on image vs. video vs. StoreKit in your app code.
| AdFormat | Description | Renders as |
|---|---|---|
| .banner | Fixed-height strip, full width | Inline SwiftUI view |
| .interstitial | Full-screen modal — image, video, rewarded, or App Store install (server decides) | Full-screen overlay |
The server picks the creative type
.interstitial, the SDK reads creative_type from the server response and automatically renders the correct view (image, video, or StoreKit). Whether a reward is involved is determined by placement.rewardEnabled — not the format. You use the same onReward callback regardless.public enum AdFormat: String {
case banner // inline strip
case interstitial // full-screen (image / video / rewarded / app store — server decides)
public var isFullScreen: Bool { self == .interstitial }
}The SDK will not show any ad if adsEnabled is false in the bootstrap config.
5. Initialization
No additional setup is needed beyond the standard GrowthCat.initialize call. The ads engine starts automatically as part of the bootstrap.
import SwiftUI
import GrowthCat
@main
struct MyApp: App {
init() {
GrowthCat.initialize(
apiKey: "gc_live_your_key_here",
logsEnabled: true // set false in production
)
}
var body: some Scene {
WindowGroup { ContentView() }
}
}What happens on initialize
- SDK stores your API key and resolves the workspace (
sandboxin DEBUG,livein release). - A background task calls
GET /v1/sdk/configto fetch the bootstrap. - Bootstrap includes:
ads_enabled, allowedformats, cache TTL, and offline queue cap. - The AdService is created and configured with the returned ads settings.
- All subsequent calls to
GrowthCat.sharedare ready.
6. SDK config flags
The bootstrap from GET /v1/sdk/config exposes ads-specific feature flags your app can read to decide whether to show ad-related UI before attempting to load any ads.
// Check if ads are enabled at all
if GrowthCat.shared.sdkBootstrap?.adsConfig.adsEnabled == true {
prefetchAdCatalogs()
showAdEntryPoints()
}
// Read the full ads config
if let adsConfig = GrowthCat.shared.sdkBootstrap?.adsConfig {
print("Ads enabled: \(adsConfig.adsEnabled)")
print("Cache TTL: \(adsConfig.adCacheTTLSeconds)s")
print("Max offline events: \(adsConfig.maxOfflineAdEvents)")
}To refresh manually, for example after the app returns to the foreground:
Task {
let config = try await GrowthCat.shared.refreshSDKConfig()
print("Ads enabled: \(GrowthCat.shared.sdkBootstrap?.adsConfig.adsEnabled ?? false)")
}GrowthCatSDKAdsConfig fields
adsEnabled— master switch.falsemeans no ads are shown.adCacheTTLSeconds— default catalog TTL, overridden per-response when the server sends a tighter TTL.maxOfflineAdEvents— max events queued offline (default 500). Oldest events are dropped when the cap is exceeded.
8. Interstitial ads
Interstitials are presented modally. Bind a Bool state variable and set it to true when you want the ad to appear — for example, between game levels or after completing a task. The SDK reads creative_type from the server response and automatically renders the right view. You use the same code regardless of whether the server sends an image, a video, or an App Store install creative.
import SwiftUI
import GrowthCat
struct LevelCompleteView: View {
@State private var showInterstitial = false
var body: some View {
VStack {
Text("Level Complete!")
Button("Continue") {
showInterstitial = true
}
}
.overlay(
GrowthCatAdView(
placementKey: "level_complete_interstitial",
appUserID: "user_123",
isPresented: $showInterstitial,
onDismiss: { showInterstitial = false }
)
)
}
}Image creative (creative_type: "image")
- Full-screen modal with headline, body, and a CTA button.
- Countdown in the top-right corner. After
skippable_after_seconds, the X button appears. - If
skippable_after_secondsisnull, close appears after 3 seconds. impressionfires immediately on presentation.clickfires if the user taps the CTA.ad_closedfires on dismiss.
Video creative (creative_type: "video")
- Full-screen modal with auto-playing video (muted by default).
- Countdown replaces the X button until
skippable_after_secondsis reached. - If
skippable_after_secondsisnull, cannot close until video finishes. video_start+impressionfire when playback begins.video_progressfires at 25%, 50%, and 75%.video_completefires when video reaches the end.
9. Rewarded ads
A rewarded ad is an interstitial placement where placement.rewardEnabled == true. The server sets this per placement in your dashboard — no special format enum is needed. The in-app reward is only granted after POST /v1/ads/reward/validate returns reward_validated: true.
appUserID is required for rewarded ads
import SwiftUI
import GrowthCat
struct CoinStoreView: View {
@State private var showRewardedAd = false
@State private var coins: Int = 0
var body: some View {
VStack(spacing: 24) {
Text("You have \(coins) coins")
.font(.title)
Button("Watch ad to earn 50 coins") {
showRewardedAd = true
}
.buttonStyle(.borderedProminent)
}
.overlay(
GrowthCatAdView(
placementKey: "rewarded_coins",
appUserID: "user_123", // REQUIRED for rewarded ads
isPresented: $showRewardedAd,
onReward: { response in
// Only called when reward_validated == true from the server.
// response.reward contains { name: "Coins", amount: 50 }
coins += Int(response.reward?.amount ?? 0)
showRewardedAd = false
},
onDismiss: {
showRewardedAd = false
}
)
)
}
}Reward flow step by step
- User taps "Watch ad" →
showRewardedAd = true. - The SDK presents the rewarded ad full-screen.
- The user views the image or video.
- Once the user has watched for
minimum_view_seconds(set in your dashboard), the SDK callsPOST /v1/ads/reward/validatewith the actual elapsed seconds. - Server responds:
{ reward_validated: true, reward: { name: "Coins", amount: 50 } }. - The SDK calls your
onRewardclosure — this is the only safe place to grant the reward. - If the network is unavailable, the SDK does not grant the reward and does not retry silently. The user must watch again when online.
User closes early?
For rewarded placements the close button is hidden until minimum_view_seconds elapses (or the video finishes). The user cannot skip early, regardless of whether the creative is an image or video.
Network unavailable?
The SDK throws. The reward is not granted. No pending reward is stored locally — the user must watch the ad again when they are online.
10. App Store install ads
App Store install ads promote another app using StoreKit. When the user taps the CTA, the SDK presents either an SKOverlay anchored to the bottom or an SKStoreProductViewController as a full modal, depending on the presentation value in the ad object.
GrowthCatAdView(
placementKey: "app_store_promo",
appUserID: "user_123",
isPresented: $showAppStoreAd,
onDismiss: { showAppStoreAd = false }
)What the SDK handles automatically
impressionfires when the card appears.clickfires when the user taps the CTA button (before StoreKit appears).- StoreKit is presented —
SKOverlayforoverlay,SKStoreProductViewControllerforproduct_view. ad_closedfires when the user dismisses your ad card (regardless of whether they installed).
11. Unified GrowthCatAdView
GrowthCatAdView is the single public entry point for all non-banner formats. It automatically detects the format of the loaded ad and renders the correct view — you never need to check the format yourself. There are two ways to initialize it.
Placement-key initializer (dashboard-configured slot)
Use this when you have a named placement key created in your GrowthCat dashboard. The SDK calls GET /v1/ads/catalog?placement_key=… to fetch the best ad for that slot.
GrowthCatAdView(
placementKey: "my_placement",
appUserID: currentUser.id,
sessionID: currentSessionID,
isPresented: $isAdPresented,
onReward: { response in
// Reward granted (rewarded formats only).
grantUserReward(response.reward)
},
onDismiss: {
isAdPresented = false
}
)Format-based initializer (automatic format selection)
Use this when you want the SDK to find the best available ad for a given format without a fixed placement key. The SDK calls GET /v1/ads/serve?format=…, which accepts banner or interstitial.
// Show a full-screen ad (image, video, rewarded, or app store install — server decides).
GrowthCatAdView(
format: .interstitial,
appUserID: currentUser.id,
sessionID: currentSessionID,
isPresented: $isAdPresented,
onReward: { response in
// Called only if the server returns a rewarded creative.
grantUserReward(response.reward)
},
onDismiss: { isAdPresented = false }
)
// Show a banner using format-based selection.
GrowthCatAdView(
format: .banner,
appUserID: currentUser.id
)The SDK sends the format's rawValue directly to the server — GET /v1/ads/serve?format=banner or …?format=interstitial. The server picks the best creative for the slot and the SDK renders it automatically.
| Parameter | Type | Required | Description |
|---|---|---|---|
| placementKey | String | Yes (placement init) | Matches the placement key in your GrowthCat dashboard. |
| format | AdFormat | Yes (format init) | The ad format to request via /v1/ads/serve. Either .banner or .interstitial. |
| appUserID | String? | Yes for rewarded | Your app's user identifier. Required for reward attribution. |
| sessionID | String? | Recommended | Use GrowthCat.makeSessionID() to correlate events in one session. |
| bannerHeight | CGFloat | No | Minimum height in points for banner format. Default: 60. |
| bannerStyle | BannerStyle | No | .standard (full-width) or .rounded(cornerRadius:horizontalPadding:). Default: .rounded(). |
| bannerBackgroundColor | Color? | No | Override the banner background. nil uses secondarySystemBackground. |
| isPresented | Binding<Bool> | Yes for non-banner | Drives the .fullScreenCover. Set to true to show. |
| onReward | ((AdRewardValidationResponse) -> Void)? | No | Called when reward is validated by the server. |
| onDismiss | (() -> Void)? | No | Called when the ad is closed. Set isPresented = false here. |
12. Manual ad loading and event tracking
If you want full control — for example, to pre-check whether an ad is available before showing a "Watch Ad" button — you can load ads and fire events manually.
Loading an ad manually
loadAd returns nil when ads are disabled, there is no fill, the format is not allowed, or the cache has expired and the network is unavailable. It only throws for hard errors (bad API key, server 5xx, etc.).
let ad = try? await GrowthCat.shared.loadAd(placementKey: "rewarded_coins")
if ad != nil {
showWatchAdButton = true
} else {
// No fill — hide the button or show an alternative.
}Firing events manually
The SDK fires all events automatically from GrowthCatAdBannerView and GrowthCatAdView. Only use the manual API if you are building a fully custom renderer.
// Fire an impression when your custom ad becomes visible.
GrowthCat.shared.trackAdEvent(
.impression,
for: ad,
placementKey: "rewarded_coins",
appUserID: "user_123",
sessionID: sessionID
)
// Fire a click when the user taps your CTA.
GrowthCat.shared.trackAdEvent(
.click,
for: ad,
placementKey: "rewarded_coins",
appUserID: "user_123",
sessionID: sessionID
)
// Fire video progress with metadata.
GrowthCat.shared.trackAdEvent(
.videoProgress,
for: ad,
placementKey: "rewarded_coins",
appUserID: "user_123",
sessionID: sessionID,
metadata: ["progress_pct": "50"]
)| Event | When to fire |
|---|---|
| .impression | Ad is visible and fully rendered (once per ad show). |
| .click | User taps the CTA (once per tap). |
| .videoStart | Video begins playing. |
| .videoProgress | At 25%, 50%, and 75% of video duration. Pass "progress_pct" in metadata. |
| .videoComplete | Video reaches the end. |
| .adClosed | User dismisses or closes the ad. |
| .adReported | User taps "Report this ad" (if you expose that option). |
Do not fire reward_validated manually
POST /v1/ads/reward/validate. Never call trackAdEvent(.rewardValidated, ...) yourself.Batching strategy
impressionandclickare flushed immediately (single HTTP call).- Video progress and other events are flushed every 10 events or every 30 seconds, whichever comes first.
- All pending events are flushed when the app moves to the background.
13. Reward validation in detail
Call validateAdReward when you want to validate a reward from a fully custom ad renderer. When using GrowthCatAdView, this is handled automatically — your onReward closure is only called after the server confirms.
do {
let response = try await GrowthCat.shared.validateAdReward(
for: ad,
appUserID: "user_123", // required
sessionID: currentSessionID,
viewedSeconds: 30, // actual elapsed seconds the user viewed
completed: true // true if video played to end
)
if response.rewardValidated {
let reward = response.reward
print("Reward: \(reward?.amount ?? 0) \(reward?.name ?? "")")
grantReward()
}
} catch {
// Network unavailable or server error — do NOT grant the reward.
// The user must watch the ad again when online.
print("Reward validation failed: \(error)")
}Rules — these are non-negotiable
- Never grant the reward before calling this endpoint.
- Never grant the reward if the call throws or returns
reward_validated: false. - If the call fails due to a network error, do not queue a "pending reward" locally — the user must watch again when online.
- The server internally records the
reward_validatedevent. Do not calltrackAdEventfor it yourself.
14. Offline behavior
The SDK handles offline gracefully at every layer. Events are persisted to a JSON file in the app's Caches directory and replayed automatically when connectivity is restored via NWPathMonitor.
| Scenario | What happens |
|---|---|
| Ad catalog fetch fails (offline) | Returns cached entry if TTL has not expired; returns nil (no fill) otherwise. |
| Creative asset not pre-fetched | Ad view falls back to AsyncImage with the remote URL. |
| Event tracking (offline) | Events are persisted to a JSON file in the app's Caches directory. |
| App comes back online | NWPathMonitor detects connectivity and replays the offline queue automatically. |
| Reward validation (offline) | The SDK throws. The reward is not granted. No pending reward is stored locally. |
| Offline queue cap exceeded | Oldest events are dropped to make room for new ones. Default cap: 500, controlled by max_offline_ad_events. |
You can manually flush the event queue at any time:
Task {
await GrowthCat.shared.flushAdEvents()
}15. Prefetching on foreground
To ensure ads are ready the moment your users need them (no loading delay), prefetch catalogs when the app returns to the foreground. Prefetching runs in the background and never blocks the UI. It respects the catalog cache TTL — if the cached entry is still valid, no network request is made. Creative media is cached separately using the server-providedasset_cache hints.
GET /v1/sdk/apps/:appId/ads/catalog?placement_key=home_banner
{
"placement_key": "home_banner",
"cache_ttl_seconds": 300,
"media_cache_ttl_seconds": 604800,
"ads": [
{
"creative": {
"id": "uuid",
"creative_type": "video",
"public_asset_url": "https://media.example.com/ads/...",
"asset_cache": {
"cacheable": true,
"ttl_seconds": 604800,
"strategy": "prefetch"
}
}
}
]
}Media cache rules
cache_ttl_secondscontrols the placement catalog cache.media_cache_ttl_secondsis the default disk TTL for creative files.creative.asset_cache.ttl_secondscan override the media TTL per creative.asset_cache.strategy: prefetchmeans the SDK should download the asset before rendering when possible.- R2 is the v1 creative storage backend; the SDK should treat
public_asset_urlas the source of truth.
import SwiftUI
import GrowthCat
@main
struct MyApp: App {
@Environment(\.scenePhase) private var scenePhase
var body: some Scene {
WindowGroup { ContentView() }
.onChange(of: scenePhase) { _, newPhase in
if newPhase == .active {
GrowthCat.shared.prefetchAdCatalogs(placementKeys: [
"home_banner",
"level_complete_interstitial",
"rewarded_coins"
])
}
}
}
}16. Flushing events on background
Register a background task to flush any in-memory pending events before the system suspends your app. Losing events means losing attribution data for impressions and clicks.
Using the SwiftUI scene lifecycle (recommended):
.onChange(of: scenePhase) { _, newPhase in
if newPhase == .background {
Task { await GrowthCat.shared.flushAdEvents() }
}
}Using UIApplicationDelegate:
func applicationDidEnterBackground(_ application: UIApplication) {
let task = application.beginBackgroundTask(withName: "GrowthCatFlush")
Task {
await GrowthCat.shared.flushAdEvents()
application.endBackgroundTask(task)
}
}Background time is limited
UIApplication.beginBackgroundTask to extend the deadline if you need more than a few seconds. The SDK's flush is typically fast (one HTTP batch call), but the OS can suspend the app before it completes without the extension.17. Error handling
All GrowthCat operations throw GrowthCatError. Ads operations follow the same error taxonomy as referral operations.
do {
let ad = try await GrowthCat.shared.loadAd(placementKey: "home_banner")
// ad == nil → no fill. ad != nil → ready to show.
} catch GrowthCatError.unauthorized {
// SDK key is wrong. Check your dashboard configuration.
} catch GrowthCatError.network {
// No connectivity. The catalog cache will be used if still valid.
} catch GrowthCatError.rateLimited(let retryAfter) {
// Respect the retry-after interval before attempting again.
print("Retry after \(retryAfter ?? 60)s")
} catch {
print("Unexpected error: \(error)")
}| HTTP status | SDK behavior |
|---|---|
| 200 / 202 | Process normally. |
| 400 | Throw .server. Do not retry. |
| 401 | Throw .unauthorized. Check your API key. |
| 403 | Throw .server. Discard the request. |
| 404 (catalog) | Return no-fill (nil). Not an error. |
| 429 | Throw .rateLimited(retryAfter:). Back off. |
| 5xx | Throw .server. Retry with exponential backoff (max 3). |
| Network error | Throw .network. Queue ad events offline. |
18. API reference
Full type signatures for ads-related APIs on GrowthCatClient.
GrowthCatClient — ads methods
// Load an ad for a placement. Returns nil on no-fill or if ads are disabled.
func loadAd(placementKey: String) async throws -> AdObject?
// Fire a tracking event for an ad.
func trackAdEvent(
_ eventName: AdEventName,
for ad: AdObject,
placementKey: String,
appUserID: String? = nil,
sessionID: String? = nil,
metadata: [String: String]? = nil
)
// Validate a reward server-side. Only grant the reward if response.rewardValidated == true.
func validateAdReward(
for ad: AdObject,
appUserID: String,
sessionID: String? = nil,
viewedSeconds: Int,
completed: Bool
) async throws -> AdRewardValidationResponse
// Start background downloads for the given placement keys.
func prefetchAdCatalogs(placementKeys: [String])
// Flush all pending in-memory events to the server (or offline queue).
func flushAdEvents() asyncAdFormat
public enum AdFormat: String {
case banner // inline strip
case interstitial // full-screen (image / video / rewarded / app store — server decides)
public var isFullScreen: Bool { self == .interstitial }
}AdObject
Returned by loadAd. You rarely need to access its fields directly when using GrowthCatAdView, but they are available for custom renderers.
public struct AdObject {
public let placement: AdPlacement? // nil when using the format-based serve endpoint
public let campaign: AdCampaign // id, mode, objective, priorityWeight
public let creative: AdCreative // id, publicAssetURL, headline, body, ctaText, storeKit, layout
public let tracking: AdTracking // allocationID, token
public let closePolicy: AdClosePolicy?
}
public struct AdPlacement {
public let id: String
public let format: AdFormat // .banner or .interstitial
public let rewardEnabled: Bool // true → onReward callback will fire when validated
public let rewardName: String?
public let rewardAmount: Double?
public let minimumViewSeconds: Int?
public let isSkippable: Bool
public let skippableAfterSeconds: Int?
}
public struct AdClosePolicy {
public let isSkippable: Bool?
public let skippableAfterSeconds: Int?
public let rewardGrantAfterSeconds: Int?
}AdRewardValidationResponse
public struct AdRewardValidationResponse {
public let rewardValidated: Bool
public let accepted: Int
public let reward: AdReward?
}
public struct AdReward {
public let name: String // e.g. "Coins"
public let amount: Double // e.g. 50
}AdEventName
public enum AdEventName: String {
case impression
case click
case videoStart = "video_start"
case videoProgress = "video_progress"
case videoComplete = "video_complete"
case adClosed = "ad_closed"
case adReported = "ad_reported"
}GrowthCatSDKAdsConfig
Available via GrowthCat.shared.sdkBootstrap?.adsConfig.
public struct GrowthCatSDKAdsConfig {
public let adsEnabled: Bool // Master switch. False → no ads shown.
public let adCacheTTLSeconds: Int // Default catalog TTL (overridden per response).
public let maxOfflineAdEvents: Int // Max events queued offline (default 500).
}BannerStyle
public enum BannerStyle {
case standard
case rounded(cornerRadius: CGFloat = 14, horizontalPadding: CGFloat = 16)
}UI components
// Inline banner ad
GrowthCatAdBannerView(
placementKey: String,
appUserID: String?,
sessionID: String?,
minHeight: CGFloat, // default 60
style: BannerStyle, // default .rounded()
backgroundColor: Color?,
onTap: (() -> Void)?
)
// Placement-key initializer — all non-banner formats
GrowthCatAdView(
placementKey: String,
appUserID: String?,
sessionID: String?,
bannerHeight: CGFloat,
bannerStyle: BannerStyle,
bannerBackgroundColor: Color?,
isPresented: Binding<Bool>,
onReward: ((AdRewardValidationResponse) -> Void)?,
onDismiss: (() -> Void)?
)
// Format-based initializer — uses /v1/ads/serve
GrowthCatAdView(
format: AdFormat,
appUserID: String?,
sessionID: String?,
bannerHeight: CGFloat,
bannerStyle: BannerStyle,
bannerBackgroundColor: Color?,
isPresented: Binding<Bool>,
onReward: ((AdRewardValidationResponse) -> Void)?,
onDismiss: (() -> Void)?
)