> ## 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 React Native SDK (v4)?

> Display Gameball in-app campaigns in your React Native 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

Your dashboard campaigns, drawn natively in your React Native app: slide-ups, modals, and full screens, triggered by session start or by the events you already send. In-app messaging needs **Gameball React Native SDK 4.0.0** or later.

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 |
| - | - |
| Package | `react-native-gameball` 4.0.0 or later. In-app messaging is part of the SDK, so there is no separate package to install |
| React Native | 0.70 or newer. Use 0.71 or newer for correct button spacing in messages, which use the `gap` style |
| Platforms | iOS 15.0 or newer, Android 7.0 (API 24) or newer |
| Required peers | `@react-native-async-storage/async-storage`, `react-native-device-info`, `react-native-modal`, `react-native-push-notification-popup`, and `react-native-webview`, the same peers 3.3.1 requires |
| Optional | `react-native-safe-area-context`, detected at runtime |
| New runtime dependencies | None. The only new peer is the optional `react-native-safe-area-context` |

***

## Install

```bash theme={null}
npm install react-native-gameball@^4.0.0 @react-native-async-storage/async-storage react-native-device-info react-native-modal react-native-push-notification-popup react-native-webview
cd ios && pod install
```

> [!WARNING]
>
> **Upgrading from 3.x?** SDK 4.0.0 raises the minimums to iOS 15.0 and Android API 24. Every 3.x call keeps its signature and behavior. Coming from 3.1.x or earlier, also install the required peers listed above. See [Migration Notes](https://docs.gameball.co/installation-guides/v4/react-native/migration-notes.md).

Version 4.0.0 adds five methods (`startInAppMessaging`, `stopInAppMessaging`, `setOverlayOpen`, `onInAppMessage`, and `logPurchase`), one read-only property (`isInAppMessagingStarted`), one component (`<GameballInAppMessages />`), and a `debug` option on `init`.

***

## Mount the Surface

React Native has no window of its own to draw into, so the SDK needs a place in your tree. Mount `<GameballInAppMessages />` once, near the root of your app and above your navigator.

```tsx App.tsx theme={null}
import GameballApp, { GameballInAppMessages } from 'react-native-gameball';

export default function App() {
  return (
    <>
      <NavigationContainer>{/* your app */}</NavigationContainer>
      <GameballInAppMessages />
    </>
  );
}
```

> [!WARNING]
>
> **Until it is mounted, nothing draws.** The SDK will not count an impression nobody could see, so it holds the message and displays it once a surface exists. If messages never appear, this is the first thing to check. The log says `no in-app messaging host mounted yet; deferring`.

Slide-ups render in place, so the app underneath stays usable. Modals and full screens render inside a React Native `Modal`, which puts them above everything your app draws, including a navigator's own screens, and gives Android's Back button somewhere to land.

***

## Start Messaging

Three calls, in order.

```tsx theme={null}
const app = GameballApp.getInstance();

await app.init({ apiKey: 'YOUR_API_KEY', lang: 'en' });
await app.initializeCustomer({ customerId: 'customer-123' });
await app.startInAppMessaging();
```

> [!NOTE]
>
> **Call `init` at module load, not from a component effect.** It is asynchronous, because it fetches your account's settings, and every network-facing method refuses to run until it resolves. A child component's effect runs before its parent's, so an effect-based init leaves a window in which your app is mounted and interactive while the SDK is not ready.

> [!NOTE]
>
> **Awaiting `startInAppMessaging` is optional.** The SDK loads the customer's message history before it judges any campaign, whether or not you await. The promise resolves once campaigns have synced and the session-start campaign has been evaluated. Events you send before then are not matched against campaigns, so await it before sending an event that should trigger one.

`startInAppMessaging` takes an explicit `customerId` if you would rather not rely on the last customer passed to a successful `initializeCustomer`. You don't need to stop to switch customers: a new `initializeCustomer`, or a start with a different `customerId`, fetches that customer's campaigns.

Call `stopInAppMessaging()` on logout: it dismisses what is showing, sends pending analytics, and lets go of the customer, so the next start needs a customer again. The device keeps the customer's message history, so the same customer signing back in is not shown a once-ever message twice. It keeps one customer's history at a time: when a different customer starts messaging on the device, the previous history is discarded.

***

## Control Display and Actions

Every option is optional. The hooks follow your most recent `startInAppMessaging` call. `sessionTimeoutSeconds` is read only when messaging starts for the first time, or for the first time after `stopInAppMessaging()`.

```tsx theme={null}
await app.startInAppMessaging({
  // Deep links from a campaign's button or surface.
  onNavigate: (route, args) => navigation.navigate(route, args),

  // 'show' displays it, 'later' holds it, 'discard' drops it.
  beforeDisplay: (message) => (checkoutInProgress ? 'later' : 'show'),

  // Return true to say you handled the action yourself.
  // The click is still recorded either way.
  onAction: (message, button, action) => false,

  // Every message the SDK selects, whatever you then decide.
  onMessage: (message) => analytics.track('gameball_message', { campaignId: message.campaignId }),

  // How long the app can be away and still count as the same session. Default 30 s, read on the first start.
  sessionTimeoutSeconds: 30,
});
```

A message you hold with `'later'` waits until the screen frees up: `setOverlayOpen(false)`, the profile widget or another message closing, or the device rotating. It is not passed to `beforeDisplay` again, so for a screen such as checkout, `setOverlayOpen` is the more reliable hold. `beforeDisplay` and `onAction` must return a value, not a promise. A message closes after any tap action, whatever `onAction` returns.

While your own modal, drawer, or checkout step is up, tell the SDK and it holds messages until you say the screen is yours again:

```tsx theme={null}
app.setOverlayOpen(true);
// … later
app.setOverlayOpen(false);   // A held message displays now, or once your account's minimum interval allows
```

You don't need to do this for the Gameball profile widget: messages already wait while it is open and are offered again when it closes.

***

## Actions

A campaign's surface or button can dismiss, open a URL, navigate to a route, or ask for push permission. `onAction` sees all of them first.

> [!WARNING]
>
> **React Native ships no in-app browser.** Without `openUrl`, both link kinds, `external: true` and `external: false`, leave for the system browser through `Linking.openURL`, so the customer leaves your app and comes back to it suspended, not restarted.

If you bundle a browser, pass `openUrl` and decide for yourself:

```tsx theme={null}
openUrl: async (url, external) => {
  if (external) {
    await Linking.openURL(url);     // The system browser
  } else {
    await InAppBrowser.open(url);   // Your in-app browser
  }
  return true;                      // Opened
}
```

Once you pass `openUrl`, it opens every campaign link, both kinds. Return `true` when the link opened. Returning `false` does not hand the link back to the SDK: nothing opens, and the log says `could not open "<url>"`.

Whatever you do, the SDK only ever opens `http`, `https`, `mailto`, and `tel`. A campaign carrying `javascript:`, `file:`, or your own deep-link scheme is refused and logged, so a campaign cannot launch arbitrary schemes in your app.

***

## Observe Messages

```tsx theme={null}
const off = app.onInAppMessage((message) => {
  // Every message the SDK selects, before beforeDisplay decides, including ones that are held or dropped.
});
// later
off();
```

This is a listener, not a veto. Use `beforeDisplay` to hold or drop a message. It fires for every listener you register, and a listener that throws is logged rather than allowed to break the display. It fires once, at selection. A held message does not fire again when it finally displays.

***

## Events and Purchases

Nothing extra to wire. Every event you already send is also a trigger, evaluated before the request goes out, so a campaign that fires on `add_to_cart` needs no new code:

```tsx theme={null}
await app.sendEvent({
  customerId: 'customer-123',
  events: { add_to_cart: { price: 1200, category: 'shoes' } },
});
```

The properties you attach are what the campaign's filters are matched against. Equality checks are exact and case-sensitive, and a number matches its numeric string, so `1200` equals `"1200"`. `contains` ignores case. Event names must match exactly, including case. A property the event does not carry never matches, even for a "does not equal" filter.

Purchases have their own method. `logPurchase` sends an event named `purchase` through `sendEvent`, so a campaign triggered by `purchase` can filter on `productId`, `price`, `currency`, `quantity`, and anything in `properties`. It works whether or not messaging has started, and takes the same optional `callback` and `sessionToken` arguments as `sendEvent`. If you already send `purchase` events yourself, switch to `logPurchase` rather than sending both.

```tsx theme={null}
await app.logPurchase({
  customerId: 'customer-123',
  productId: 'sku-001',
  price: 49.99,
  currency: 'USD',
  quantity: 2,                        // Optional, defaults to 1
  properties: { category: 'shoes' },  // Optional, also matched by filters
});
```

***

## Language and Right-to-Left

A campaign is fetched in one language. The customer's own `preferredLanguage`, a two-letter code, wins; without one, the SDK uses the language `init` was given, then English. `setLanguage` changes that language for every request from then on, and also saves it as the last initialized customer's `preferredLanguage`. A code the SDK does not support falls back to `en`. Campaigns already synced keep the copy they were fetched with until the next sync. `changeLanguage` still works but is deprecated in favor of `setLanguage`.

```tsx theme={null}
await app.initializeCustomer({
  customerId: 'customer-123',
  customerAttributes: { preferredLanguage: 'ar' },
});
```

> [!NOTE]
>
> **Mirroring is your app's business, not the SDK's.** Messages are drawn into whatever layout direction your app lays out in, so an app running under `I18nManager.isRTL` gets right-aligned copy, mirrored buttons, and the close glyph in the top-left corner. An app that stays left-to-right does not. The SDK never forces a direction on you.

***

## Personalization

Campaign text can carry placeholders such as `{player_name}`. See [In-App Messaging Campaigns](https://docs.gameball.co/product-documentation/communication-campaigns/create-and-configure-in-app-messaging-campaigns.md) for the list. Just before a message with placeholders displays, the SDK fetches the customer's current values and fills them in. It waits at most 2 seconds and reuses the values for up to a minute, and every event you send clears them so the next message quotes fresh numbers.

If the request fails, the SDK uses the last values it stored for the customer. Any placeholder still without a value, including every placeholder when the request times out, is removed rather than shown as raw braces, and the message still displays. Messages with no placeholders skip the fetch. Your hooks receive the message as authored, with its placeholders.

***

## Push Permission

React Native has no notification-permission API of its own, so a campaign that asks for one asks *you*. Without a requester the SDK logs and closes the message rather than pretending. For example, with `react-native-permissions`:

```tsx theme={null}
requestPushPermission: async () => {
  const { status } = await requestNotifications(['alert', 'sound']);
  return status === 'granted';
}
```

***

## Storage and Insets

The SDK keeps in-app messaging state in AsyncStorage, a required peer. The customer's message history, the campaign cache, unsent analytics, and personalization values survive a restart. If storage cannot be read, messaging still runs, starts empty, and logs why.

`react-native-safe-area-context` is optional and resolved at runtime. When it is installed, messages clear the notch and the home indicator, using the insets the device reports at startup. Without it, the SDK falls back to the status-bar height on Android and to zero elsewhere.

> [!TIP]
>
> If your app's own insets are the authority, pass them and they win: `<GameballInAppMessages insets={useSafeAreaInsets()} />`.

***

## Session Tokens

In-app messaging requests carry the SDK's current session token as `X-GB-TOKEN` when it has one, and each sync reports your app's version. The current token is whatever your last `initializeCustomer`, `sendEvent`, `logPurchase`, or `showProfile` call passed, so pass it on every call if you use session tokens.

***

## 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 a React Native app.

| **Topic** | In this SDK |
| - | - |
| Message types | Slide-up, modal, and full-screen campaigns with images. A modal or full screen shows at most two buttons, and a slide-up has none: the whole banner is the tap target. Other message types and video are skipped, and so is a campaign whose event filters are combined with OR, since the SDK matches filters with AND. |
| Slide-up auto-dismiss | A campaign that names no duration gets 8 seconds, so a banner cannot sit over your app bar indefinitely. A duration of 0 keeps it until dismissed. The clock starts when it paints, not when the trigger fires. |
| Swipe to dismiss | A slide-up is dragged toward its own edge, up for a top banner and down for a bottom one. A drag the OS cancels springs back. |
| Android Back | Closes a modal or a full screen, never a slide-up. |
| Orientation | A full screen authored portrait-only or landscape-only is held until the device is in that orientation, then displays on its own. A message never rotates your app. |
| Artwork | HTTPS only. Cleartext artwork is refused before any request goes out. Images download into memory when campaigns sync, and a message never draws before its image. A campaign whose image cannot be loaded is passed over and the next eligible campaign shows, rather than a broken card. If the image does not paint within 2 seconds, the message is taken away unshown, no impression is counted, and the campaign can show on its next trigger. |
| One at a time | A second campaign matching the same trigger waits behind the one on screen. It displays after that message closes and your account's minimum interval between messages has passed, without the trigger firing again. Only one message waits at a time, and a newer one replaces it. A trigger that fires inside the minimum interval is skipped, not held. |
| Sessions | A background longer than the session timeout starts a new session on resume: a fresh sync and a session-start evaluation. Shorter, and the session continues. On iOS, the app switcher, an incoming call, or a system prompt counts as leaving. |
| Sync and cache | If a sync fails, the SDK uses the campaigns it last cached for that customer. |
| Quiet hours | Judged on the device's local time. |
| Analytics | Impressions, clicks, and dismissals are batched and sent within 30 seconds, sooner once 10 are waiting or just before a link or route action runs, and flushed when the app leaves the foreground. A dismissal is only counted when the customer did not engage. Dashboard test sends display normally and are never reported. |

***

## Logging and Troubleshooting

Pass `debug: true` to `init` for a running commentary prefixed `[GameballIAM]`, showing which campaign was chosen, which was passed over, and why. It defaults to `__DEV__`, so it is on in development builds and off in release builds.

| **You see** | It means |
| - | - |
| `no in-app messaging host mounted yet; deferring` | `<GameballInAppMessages />` is not in the tree. Nothing will draw until it is. |
| `a second in-app messaging host mounted; the first one keeps the screen` | Mount `<GameballInAppMessages />` only once. |
| `startInAppMessaging ignored: call init({ apiKey }) first` | `startInAppMessaging` ran before `init`. Await `init` first. |
| `startInAppMessaging ignored: no customerId — pass one or identify a customer first` | Identify a customer first, or pass one. After `stopInAppMessaging()` the SDK deliberately holds no customer. |
| `start ignored: already running for customer "…"` | `startInAppMessaging` was called again with the same customer. Harmless. |
| `session start evaluated` | Start finished. The lines before it say what matched or was held. |
| `trigger ignored: no campaigns loaded` | The event came before the sync finished, or the sync returned no campaigns. |
| `campaign "N" deferred: <reason>` | The message is waiting. Reasons include `the host overlay is open`, `another message is showing`, and `no presentation surface available`. |
| `campaign "N" passed over: artwork not ready` | Its image did not download within 5 seconds, or it is served over `http`. The SDK tries again on a later trigger, at most every 30 seconds, and the campaign is eligible once the image arrives. |
| `artwork "<url>" is served over http and is refused — in-app artwork must be https` | The campaign's image is not HTTPS. Fix it in the dashboard. |
| `… cooldown has not elapsed since the last message; suppressed` | Your account's floor between two displays, set in the dashboard. |
| `<n> campaign(s) matched but it is inside the quiet-hours window (device local time); suppressed` | The account's quiet hours cover the device's local time. |
| `message "N" needs landscape orientation; deferring` | Working as intended. It will display when the device turns. |
| `navigate to "<route>" ignored: pass onNavigate to startInAppMessaging()` | Pass `onNavigate` to route campaign links. |
| Nothing at all | Logging is off. Release builds log only with `debug: true`. |

***

## API Reference

| **Member** | Signature | Notes |
| - | - | - |
| `startInAppMessaging` | `(options?) => Promise<void>` | Awaiting is optional. Options: `customerId`, `sessionTimeoutSeconds`, `beforeDisplay`, `onAction`, `onNavigate`, `onMessage`, `openUrl`, `requestPushPermission`. |
| `stopInAppMessaging` | `() => void` | Logout boundary. Dismisses, flushes, forgets the customer, keeps stored state. |
| `setOverlayOpen` | `(open: boolean) => void` | Holds messages while your own overlay is up. Closing it retries a held message. |
| `onInAppMessage` | `(listener) => () => void` | Returns its own unsubscribe. |
| `isInAppMessagingStarted` | `boolean` | Read-only property. |
| `logPurchase` | `(purchase: Purchase, callback?, sessionToken?) => Promise<boolean>` | Sends a `purchase` event through `sendEvent`. Works whether or not messaging has started. |
| `<GameballInAppMessages />` | `{ insets?: { top?, bottom? } }` | Mount once, near the root, above your navigator. |

`init` also takes `debug?: boolean`, which defaults to `__DEV__`.

Types `InAppMessage`, `MessageButton`, `ClickAction`, `MessageType`, `MessageLayout`, `MessageOrientation`, `SlidePosition`, `MessageStyle`, `ButtonStyle`, `TextAlign`, `BeforeDisplay`, `DisplayDecision`, `OnAction`, `StartInAppMessagingOptions`, `GameballInAppMessagesProps`, `PresentedMessage`, `PresentCallbacks`, and `Purchase` are exported from the package root.

***

## Launch Checklist

**1. Initialize at module load**

`init` runs at module load and is awaited before anything else.

**2. Mount the surface**

`<GameballInAppMessages />` is mounted once, above the navigator.

**3. Start and stop messaging**

`startInAppMessaging` is called after a customer is identified, and `stopInAppMessaging()` is called on logout.

**4. Wire the hooks**

`onNavigate` if any campaign uses a route action, `requestPushPermission` if any campaign asks for notifications, and `setOverlayOpen` bracketing your own modals and checkout steps.

**5. Check storage and insets**

AsyncStorage is installed, safe-area insets come from somewhere, and `debug` is off in release builds.

***

## 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/react-native/track-events.md)
>
> Send the events your campaigns are triggered by.

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

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


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