> ## Documentation Index
> Fetch the complete documentation index at: https://docs.gameball.co/llms.txt
> Use this file to discover all available pages before exploring further.

# How Do I Set Up In-App Messaging in the iOS SDK (v4)?

> Display Gameball in-app campaigns in your iOS app and control when and how they appear

> [!NOTE]
>
> **Beta.** In-App Messaging is in beta and open to every Gameball account. Send questions and feedback to [support@gameball.co](mailto:support@gameball.co).

# Display In-App Messages

One call after you identify the customer, and the Gameball SDK draws your dashboard campaigns above your app in its own window: slide-ups, modals, and full screens, triggered by session start or by the events you already send. In-app messaging needs **Gameball iOS SDK 4.0.0** or later. The module is dormant until you start it: it makes no requests, schedules no timers, stores nothing, and draws nothing.

For campaign options, triggers, filters, priority, cooldown, quiet hours, and how the analytics are defined, see [In-App Messaging Campaigns](https://docs.gameball.co/product-documentation/communication-campaigns/create-and-configure-in-app-messaging-campaigns.md).

***

## Requirements

| **Item** | Requirement |
| - | - |
| SDK | Gameball iOS SDK 4.0.0 or later, installed with Swift Package Manager |
| iOS | Deployment target iOS 15.0 or later, built with Xcode 14 or later (Swift 5.7) |
| Window | Messages are drawn in their own `UIWindow` at `.normal` level, attached to your active scene. The SDK finds the foreground scene itself and never takes key-window status, so your keyboard and first responder are untouched |
| Navigation | Implement `gameballShouldNavigate` on the delegate if your campaigns use `navigate` actions |
| Push permission | No capability is required to ask. Delivering pushes afterward is your app's existing setup |

***

## Install

```swift Package.swift theme={null}
dependencies: [
    .package(url: "https://github.com/gameballers/gameball-ios.git", from: "4.0.0")
]
```

In Xcode, choose **File > Add Packages**, enter `https://github.com/gameballers/gameball-ios.git`, and select version 4.0.0 or later.

> [!WARNING]
>
> **Upgrading from 3.x?** SDK 4.0.0 raises the minimum deployment target to iOS 15.0. Set your app target's **Minimum Deployments** to 15.0, and `platform :ios, '15.0'` in your Podfile if you use CocoaPods for other dependencies. Every 3.x call keeps its signature and behavior. See [Migration Notes](https://docs.gameball.co/installation-guides/v4/ios/migration-notes.md).

Initialize the SDK and identify the customer as you do today:

```swift theme={null}
let gameball = GameballApp.getInstance()
gameball.`init`(config: GameballConfig(apiKey: "YOUR_API_KEY", lang: "en")) { _ in }

let attributes = CustomerAttributes(preferredLanguage: "en")
let request = try InitializeCustomerRequest(customerId: "customer_123",
                                            customerAttributes: attributes)
gameball.initializeCustomer(request) { response, error in }
```

***

## Start Messaging

Call `startInAppMessaging()` once. It uses the customer already identified with the SDK. Call it before `initializeCustomer` and the module starts as soon as a customer is identified, or pass an ID explicitly.

```swift theme={null}
let gameball = GameballApp.getInstance()

gameball.startInAppMessaging()                           // Customer already identified
gameball.startInAppMessaging(customerId: "customer_123") // Or explicit

gameball.isInAppMessagingStarted   // Bool
gameball.stopInAppMessaging()      // Dismiss, send pending analytics, end messaging for this customer
```

Starting is idempotent for the same customer. Starting for a different customer refetches campaigns and resets frequency history, so you never need to stop first. While messaging runs, identifying a different customer with `initializeCustomer` switches messaging to that customer. App lifecycle is observed automatically.

`startInAppMessaging()` without an ID uses the customer identified in the current app session, which the SDK keeps in memory only, so call `initializeCustomer` on every launch or pass the ID. `isInAppMessagingStarted` stays `false` until a customer is identified.

Call `stopInAppMessaging()` on logout; it is safe when messaging was never started. Messaging stays off until you call `startInAppMessaging()` again, so call it at the next login. The customer's display history stays on the device until a different customer starts, so a campaign already shown to this customer does not return when they log back in.

***

## Control Display and Actions

Assign a `GameballInAppMessagingDelegate` to `inAppMessagingDelegate`, before or after starting. Every method has a default, so implement only what you need.

> [!WARNING]
>
> **The delegate is held weakly.** Keep your own strong reference, or your hooks stop firing as soon as the object is deallocated.

```swift theme={null}
final class MessagingCoordinator: GameballInAppMessagingDelegate {
    init() { GameballApp.getInstance().inAppMessagingDelegate = self }

    // Asked immediately before a message would display.
    func gameballShouldDisplay(_ message: GameballInAppMessage) -> GameballDisplayDecision {
        isCheckingOut ? .later : .show
    }

    // Return true when you handled the tap yourself; the SDK then skips its own handling.
    // `button` is nil for a tap on the message surface.
    func gameballDidHandleAction(_ message: GameballInAppMessage,
                                button: GameballMessageButton?,
                                action: GameballClickAction) -> Bool {
        if case .openURL(let url, _) = action, url.host == "my-app.example" {
            router.open(url)
            return true
        }
        return false
    }

    // Called once for each message a trigger selects, whatever happens to it next.
    func gameballDidSelectMessage(_ message: GameballInAppMessage) {
        analytics.log("gameball_message_selected", ["id": message.id])
    }

    // Called for a `navigate` action, so your own router drives the transition.
    func gameballShouldNavigate(route: String, arguments: [String: Any]?) {
        router.push(route, arguments)
    }
}
```

| **Method** | Default | Meaning |
| - | - | - |
| `gameballShouldDisplay(_:)` | `.show` | `.show` displays now. `.later` holds the message in the deferred stack and retries at the next opportunity. `.discard` drops it for this occurrence. A discarded campaign is not recorded as shown, so it can display at its next trigger. |
| `gameballDidHandleAction(_:button:action:)` | `false` | `true` skips the SDK's own handling of the action. The click is reported whatever you return, and a `dismiss` action closes the message whatever you return. |
| `gameballDidSelectMessage(_:)` | No-op | Observation only. Fires once for the campaign a trigger selects, before `gameballShouldDisplay`, so it also sees messages you defer or discard. It does not fire again when a deferred message is retried, or for a runner-up. |
| `gameballShouldNavigate(route:arguments:)` | No-op | `route` is a bare name; the SDK strips any leading slash. Without an implementation, a `navigate` action does nothing beyond reporting the click. |

Each hook is called on the main thread. When no delegate is set, or it has been deallocated, the SDK uses the defaults above.

***

## Actions

`GameballClickAction` is an enum, so an exhaustive `switch` is safe. The SDK closes the message for `dismiss` and `requestPushPermission`. After `openURL` and `navigate`, the message stays on screen until the customer closes it, and there is no API to close it from your code.

| **Case** | Associated values | Built-in behavior |
| - | - | - |
| `.dismiss` | None | Closes the message. |
| `.openURL` | `url: URL`, `external: Bool` | `external == false` presents an `SFSafariViewController` from the front-most view controller. `true`, a non-web URL, or no view controller to present from hands off to `UIApplication.open`. The message stays open. |
| `.navigate` | `route: String`, `arguments: [String: Any]?` | Forwarded to `gameballShouldNavigate`. The message stays open above your new screen until the customer closes it. |
| `.requestPushPermission` | None | Requests alert, badge, and sound authorization, then closes. |
| `.unsupported` | `type: String` | An action type this SDK version does not know. The tap is reported as a click and logged, and the message stays open. |

A button whose action is missing or unusable, such as `openURL` without a URL, falls back to `.dismiss`.

***

## Events and Purchases

Events you already send drive the triggers, and there is nothing extra to call. Triggers are evaluated on the device as soon as you call `sendEvent`, without waiting for the network. Event names match triggers case-insensitively. Every filter on a campaign must pass, and a filter on a property your event does not carry fails, so send numbers as numbers where a filter compares them.

```swift theme={null}
let event = try Event(events: ["place_order": ["price": 150, "currency": "USD"]],
                      customerId: "customer_123")
gameball.sendEvent(event) { success, error in }
```

Log purchases with `logPurchase`. It sends an event named `purchase` to Gameball through `sendEvent`, with `productId`, `price`, `currency`, and `quantity` in its metadata, so the purchase is recorded whether or not in-app messaging has started. While messaging runs, the same event reaches campaigns, which can filter on those fields. Use it instead of a hand-built purchase event, not alongside one.

```swift theme={null}
try gameball.logPurchase(customerId: "customer_123",
                         productId: "sku-1",
                         price: 150,
                         currency: "USD",
                         quantity: 2,
                         properties: ["source": "app"]) { success, error in }
```

`logPurchase` throws `GameballError.emptyCustomerId` when `customerId` is empty, and sends nothing. A key in `properties` named `productId`, `price`, `currency`, or `quantity` replaces that field. `sessionToken` works as it does in `sendEvent`.

***

## Language

There is no separate language setting for messages. Each sync asks for campaigns in the customer's preferred language, set through `initializeCustomer` or `setLanguage(_:)`, and otherwise the `lang` given at `init`. A code outside the SDK's supported languages resolves to `en`. The language is resolved at every sync, so a change applies from the next session.

Messages mirror for right-to-left languages through directional constraints, following your app's layout direction. The SDK never touches `UIView.appearance()`.

***

## Personalization

Campaign text can contain placeholders such as `{player_name}` in the header, body, and button labels. See [In-App Messaging Campaigns](https://docs.gameball.co/product-documentation/communication-campaigns/create-and-configure-in-app-messaging-campaigns.md) for the list. Right before a message displays, the SDK fetches the customer's current values, waiting at most 2 seconds. If the request fails or times out, it uses the last values it holds for the customer, then removes any placeholder still without a value, so the customer never sees raw braces. The message still shows.

The message passed to `gameballDidSelectMessage` and `gameballShouldDisplay` still contains the raw placeholders. `gameballDidHandleAction` receives the personalized text.

***

## Push Permission

The **request push permission** button action calls `UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound])`, logs the outcome, and closes the message. The click is reported the same way whether the customer allows or declines.

> [!TIP]
>
> To run your own pre-prompt or permission flow, handle `.requestPushPermission` in `gameballDidHandleAction` and return `true`. The SDK then neither asks for permission nor closes the message, so it stays on screen until the customer closes it.

***

## SwiftUI and UIKit

The module needs no view hierarchy from you. It attaches its window to the foreground `UIWindowScene`, so it works the same in a SwiftUI `App` and in a UIKit app with a scene delegate. Keep the delegate object alive for as long as you want the hooks, for example as a property of your app model.

***

## Behavior Reference

The campaign rules themselves are described in [In-App Messaging Campaigns](https://docs.gameball.co/product-documentation/communication-campaigns/create-and-configure-in-app-messaging-campaigns.md). This is what they mean in an iOS app.

| **Topic** | In this SDK |
| - | - |
| Sessions | A session starts on `startInAppMessaging` and on becoming active after more than 30 seconds in the background. Each session re-syncs and evaluates session-start campaigns. Backgrounding sends pending analytics. |
| Sync and cache | Campaigns and account settings are fetched at session start and cached in the SDK's own `UserDefaults` suite, `co.gameball.inappmessaging`, not your app's preferences. The cache is read only when the sync fails. When a sync fails and nothing is cached, the SDK retries after 1 and then 3 seconds. |
| Where it draws | Its own `UIWindow` at `.normal` level, never key. Touches outside a slide-up pass through to your app. A modal's overlay and a full screen cover the app. |
| One at a time | A message that cannot show now waits in a deferred stack, newest on top. The SDK retries it when a message is dismissed, when the device rotates, when the app returns to the foreground within 30 seconds, and when the cooldown lapses. On retry it is dropped if its repeat rule no longer allows it or quiet hours have started. |
| Runner-up | The second-best campaign for the same trigger is kept behind the winner and shows after dismissal once the cooldown lapses. |
| Cooldown and quiet hours | Both come from the account settings; the cooldown defaults to 30 seconds. Quiet hours are judged on the device's local clock. A message triggered inside quiet hours is dropped, not delayed, and so is a deferred message whose turn comes inside them. |
| Orientation | A message authored for portrait or landscape waits for a matching interface orientation and displays on rotation. It is never forced, so if your app does not support that orientation, the message does not display. |
| Artwork | Prefetched at every sync, with a 5-second timeout per image, before session-start campaigns are evaluated. A campaign whose artwork has not loaded is skipped, and its artwork is fetched again at the next sync. `http://` URLs are refused before any request, independent of your ATS settings. |
| Auto-dismiss | A slide-up closes itself after 8 seconds unless the campaign sets another duration or turns the timer off. A modal or full screen stays until the customer closes it, unless the campaign sets a duration. The timer counts visible time only. |
| Impressions | Recorded once the message has finished appearing on screen, which is also when auto-dismiss starts. |
| Analytics | Impression, click (with the button ID, and the URL for `openURL`), and dismissal. A dismissal is reported only when the message closes without a tap. Events are batched and stored on the device until sent: every 30 seconds, once 10 are waiting, when the app goes to the background, and on `stopInAppMessaging()`. Failed sends are retried. Test sends display and report nothing, but still count toward the cooldown and repeat rules on that device. |
| Accessibility | Dynamic Type throughout, a 48pt close target, campaign buttons at least 44pt tall, and entrance animations skipped under Reduce Motion. VoiceOver focus moves to every message when it appears. Modals and full screens are marked modal for VoiceOver; slide-ups are not, so the app behind them stays reachable. |
| Dismissal | A slide-up closes when swiped toward its edge or when its timer runs out. A modal or full screen closes with its close glyph when the campaign shows one, and a modal also closes on a tap outside the card when the campaign allows it. A button closes the message only when its action is `dismiss` or `requestPushPermission`. Dismissing never pops your navigation stack. |

***

## Logging and Troubleshooting

The module explains its decisions in the console with a `[GameballIAM]` prefix and never posts these lines to the backend. Logging is on in Debug builds and off in Release builds. To turn it on in a Release build while you reproduce a problem, set:

```swift theme={null}
GameballInAppMessagingLogging = true
```

Filter Xcode's console on the prefix.

| **Log line** | Meaning |
| - | - |
| `sync failed (…); falling back to the cache` | The session sync failed. The last successful sync for this customer is used, if one is stored. |
| `sync failed and nothing is cached; retrying in 1.0s (attempt 2)` | Nothing is cached, so the SDK retries after 1 and then 3 seconds. |
| `campaign N skipped: artwork not ready` | The image has not loaded, or is `http://`. The next eligible campaign takes the slot. |
| `cannot present: campaign wants portrait and the device is landscape; waiting for a rotation` | The message is waiting for a matching orientation. |
| `deferring campaign B: campaign A took this trigger first` | Runner-up kept for after A is dismissed. |
| `holding deferred campaign N: inside the display floor` | The cooldown is being honored, and a retry is booked. |
| `suppressed: inside quiet hours …` | The account's quiet hours cover the device's local time. The message is dropped, not delayed. |
| `cannot present: the host has no window to attach to yet` | No window scene is connected yet. The message is deferred and retried. |

> [!NOTE]
>
> **Nothing shows at all?** Check in order: `init` ran with the API key and a customer is identified, `startInAppMessaging` was called, the campaign is live and targets iOS and the customer is in its audience, no `skipped` or `suppressed` line names it, and the campaign has not already been shown to this customer. Once per customer is the default. A fresh customer ID resets frequency history while testing. In a Release build, set `GameballInAppMessagingLogging = true` first.

***

## UI Testing

Messages carry stable accessibility identifiers, so your XCUITests do not depend on campaign copy, which can change from the dashboard at any time. VoiceOver still reads the campaign's own text.

| **Identifier** | Element |
| - | - |
| `GameballAccessibility.surface(for:)` | The message surface: `gameball.message.slideup`, `gameball.message.modal`, or `gameball.message.fullscreen` |
| `GameballAccessibility.card` | `gameball.message.card`, the card inside a modal |
| `GameballAccessibility.closeButton` | `gameball.message.close` |
| `GameballAccessibility.button(_:)` | `gameball.message.button.<id>` for a campaign button ID |

***

## API Reference

| **Member** | Signature |
| - | - |
| Start | `func startInAppMessaging(customerId: String? = nil)` on `GameballApp` |
| Stop | `func stopInAppMessaging()` |
| State | `var isInAppMessagingStarted: Bool` |
| Delegate | `weak var inAppMessagingDelegate: GameballInAppMessagingDelegate?` |
| Decision | `enum GameballDisplayDecision { case show, later, discard }` |
| Actions | `enum GameballClickAction`: `dismiss`, `openURL(url:external:)`, `navigate(route:arguments:)`, `requestPushPermission`, `unsupported(type:)` |
| Message model | `GameballInAppMessage`: `id`, `type`, `header`, `body`, `imageURL`, `iconURL`, `clickAction`, `buttons`, `showCloseButton`, `dismissOnScrimTap`, `autoDismissAfter`, `layout`, `orientation`, `slidePosition`, `extras`, `style`. `id` is the campaign ID, or `campaignId/variationId` for an A/B variant, and stays stable for the life of the campaign |
| Button model | `GameballMessageButton`: `id`, `text`, `action`, `style` |
| Styles | `GameballMessageStyle`: `backgroundColor`, `textColor`, `headerColor`, `closeButtonColor`, `borderColor`, `frameColor`, `headerAlignment`, `bodyAlignment`. `GameballButtonStyle`: `backgroundColor`, `textColor`, `borderColor` |
| Enums | `GameballMessageType` (`slideup`, `modal`, `fullscreen`, `unsupported`), `GameballMessageLayout` (`textWithImage`, `imageOnly`), `GameballMessageOrientation` (`portrait`, `landscape`, `any`), `GameballSlidePosition` (`top`, `bottom`), `GameballTextAlignment` (`leading`, `center`, `trailing`) |
| Purchase | `func logPurchase(customerId: String, productId: String, price: Double, currency: String, quantity: Int = 1, properties: [String: Any]? = nil, completion: ((Bool, String?) -> Void)? = nil, sessionToken: String? = nil) throws` |
| Logging | `var GameballInAppMessagingLogging: Bool`, a global. Defaults to `true` in Debug builds and `false` in Release builds |
| UI testing | `enum GameballAccessibility`: `surface(for:)`, `card`, `closeButton`, `button(_:)` |

***

## Launch Checklist

**1. Update the SDK and deployment target**

The `gameball-ios` package at 4.0.0 or later, and your app target's minimum deployment at iOS 15.0.

**2. Initialize and identify**

`init` with the production API key, then `initializeCustomer` with a `preferredLanguage` on every launch.

**3. Start and stop messaging**

`startInAppMessaging()` once the customer is known, and `stopInAppMessaging()` on logout.

**4. Keep a delegate alive**

A delegate object retained by your app, with `gameballShouldNavigate` wired if any campaign uses `navigate`.

**5. Check events and purchases**

Events carry numeric properties as numbers where campaigns filter on them, and purchases go through `logPurchase` instead of a hand-built event.

**6. Test end to end**

Run one campaign on a fresh customer ID in a Debug build, or with `GameballInAppMessagingLogging = true`, with the Xcode console filtered on `[GameballIAM]`.

***

## Related Articles

> [card] [In-App Messaging Campaigns](https://docs.gameball.co/product-documentation/communication-campaigns/create-and-configure-in-app-messaging-campaigns.md)
>
> Campaign options, triggers, delivery rules, and analytics definitions.

> [card] [Track Events](https://docs.gameball.co/installation-guides/v4/ios/track-events.md)
>
> Send the events your campaigns are triggered by.

> [card] [Push Notifications](https://docs.gameball.co/installation-guides/v4/ios/push-notifications.md)
>
> Reach customers when the app is closed.

> [card] [Go-Live Checklist](https://docs.gameball.co/installation-guides/v4/ios/go-live-checklist.md)
>
> Verify the full iOS integration before release.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.