In-App Messaging
In-app messages are banners, modals and full-screen takeovers that you design on the PushEngage dashboard and the SDK displays inside your running app — onboarding prompts, promotions, feature announcements, or a notification-permission ask that explains itself before the system dialog appears.
This page covers the behavior that is common to all four SDKs. For the exact signatures on your platform, see the In-App Messaging section of your iOS, Android, Flutter or React Native SDK reference.
Availability
| SDK | Minimum version |
|---|---|
| iOS | 1.1.0 |
| Android | 1.0.0 |
| Flutter | 1.1.0 |
| React Native | 1.1.0 |
In-app messaging is a separate channel from push. It needs only your App ID — campaigns sync and display even if the user never granted notification permission and never subscribed, so there is no need to call subscribe() or requestNotificationPermission() first.
The one thing that does depend on a subscription is audience targeting on subscriber data.
How it works
- You create the campaign on the dashboard — how it looks, where it sits on screen, who should see it, and how often.
- You choose what makes it appear: the app opening, or an event your app reports, such as finishing onboarding or abandoning a cart.
- The SDK shows it at that moment, to the users who match, and reports back how it performed.
Button taps are handled for you — opening a link, asking for notification permission, closing the message. The exception is a button you set up as a custom action, which calls into your code so you can navigate or run whatever you need.
Setup
- iOS
- Android
- Flutter
- React Native
No setup required. In-app messaging is active as soon as the SDK is initialized with an App ID.
Rotation and other configuration changes are handled for you: the message stays on screen and isn't counted twice.
If a campaign uses a Request notification permission button, you do not need to forward onRequestPermissionsResult to the SDK. The SDK requests the permission and receives the system result itself, whatever type of activity hosts the message, and subscribes the user automatically on grant.
In-app messaging works with no setup. Campaigns refresh when the app opens.
Optionally, let campaigns also refresh while your app is in the background. iOS refuses a background task identifier the app has not declared, so this requires two changes:
- Add the identifier to your app's
Info.plist:
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>com.pushengage.iam.refresh</string>
</array>
- In Xcode, enable Signing & Capabilities → Background Modes → Background fetch.
Without both, the SDK logs a message and keeps refreshing on app open — nothing else changes. Note that iOS schedules these tasks opportunistically, so the 6-hour interval is an earliest-start, not a guarantee.
No Dart-side setup is required. In-app messaging is active once setAppId has run.
On iOS only, you can optionally let campaigns refresh in the background. Add to ios/Runner/Info.plist:
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>com.pushengage.iam.refresh</string>
</array>
Then enable Background Modes → Background fetch on the Runner target in Xcode. Without both, campaigns still refresh on app open.
Android needs nothing.
No JavaScript-side setup is required. In-app messaging is active once setAppId has run.
On iOS only, you can optionally let campaigns refresh in the background. Add to ios/<YourApp>/Info.plist:
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>com.pushengage.iam.refresh</string>
</array>
Then enable Background Modes → Background fetch on your app target in Xcode. Without both, campaigns still refresh on app open.
Android needs nothing.
Triggering messages
Every campaign declares a trigger. A campaign with no trigger can never be displayed.
App-open campaigns
Campaigns with an app open trigger need no code at all — the SDK fires them itself.
Two things about the timing are worth knowing:
- They fire once per process lifetime, at SDK initialization or the first screen appearing, whichever comes first. Sending the app to the background and returning does not re-fire them; only a fresh launch does. So an app-open message appears when the app opens, not on every screen change.
- An optional delay is measured from the app-open instant, not from when the sync finished. A slow sync eats into the delay rather than being added on top, and a delay that has already elapsed by the time the sync completes releases the message immediately. Backgrounding pauses the countdown, so background time never counts — a 5-second delay backgrounded at 2 seconds still has 3 seconds left when the app returns.
Custom trigger events
Campaigns with a custom trigger show when your app reports a matching event.
- iOS
- Android
- Flutter
- React Native
PushEngage.triggerIAMEvent(
eventName: "cart_abandoned",
parameters: ["cart_value": 120, "currency": "USD"]
) { success, error in
if !success {
print("Trigger failed: \(error?.localizedDescription ?? "")")
}
}
PushEngage.triggerIAMEvent(
"cart_abandoned",
mapOf("cart_value" to 120, "currency" to "USD")
)
await PushEngage.triggerIAMEvent(
'cart_abandoned',
{'cart_value': 120, 'currency': 'USD'},
);
await PushEngage.triggerIAMEvent('cart_abandoned', {
cart_value: 120,
currency: 'USD',
});
The event name must be the same string you entered in the campaign's trigger on the dashboard — pass it exactly, including its casing.
It must also be non-empty. Every SDK rejects an empty name with the message Event name is required, rather than accepting the call and matching nothing — so a typo'd or unset event name surfaces as an error instead of silence. Android, React Native and Flutter also report code 400. Native iOS has no error code: its error carries only the message, read through error.localizedDescription.
How trigger parameters are matched
The campaign states the requirements; your app supplies the data. The rules are the same on every platform:
- A campaign with no trigger conditions matches every occurrence of its event. You can call the trigger with parameters or without — it will still show.
- Parameters you send that no condition names are ignored. Sending extra context never narrows your reach.
- A condition naming a parameter you did not send is treated as missing — positive operators (
equals,greater than,contains, …) fail, negative ones (not equals,not in) pass. - The parameter values you pass are turned into text before matching. Whatever you send — a number, a boolean — reaches the matcher as a string.
- The condition's type, set on the dashboard, decides how that text is compared. Give numeric comparisons a
number(orversion) condition: the text is parsed back into a number on both sides, so120,"120"and120.0all count as equal, and greater than orders numerically rather than alphabetically. Astringcondition compares the text as-is — don't use it for numbers — andcontainsapplies to text only. A value that doesn't parse as its declared type fails the condition instead of matching. - All conditions must pass unless the campaign's trigger is set to match any.
Use ordering operators (greater than, less than, …) only on number or version conditions; on string or boolean conditions they never match. To see why a campaign was rejected, enable SDK logging: the reason is logged with the campaign id.
Audience targeting
A trigger decides when a campaign fires; its audience decides who is eligible. A campaign with no audience rules reaches everyone.
Rules sit two levels deep, and the defaults are asymmetric:
- Groups are OR'd — matching any one group qualifies the device.
- Conditions inside a group are AND'd — every condition in that group must pass.
Either level can be switched between all and any on the dashboard, and there is no deeper nesting than that.
What you can target
Rules over device data are resolved on the device and always work, including on a fresh install:
| Field | Type | Notes |
|---|---|---|
platform | string | Exactly iOS or Android — values are matched case-sensitively |
os_version | version | Marketing version (17.4, 14), not the Android API level |
app_version | version | Your app's own version string |
language | string | Bare language code (en), never en-US |
device_region | string | Region code from the device locale (US) |
timezone | string | Identifier such as Asia/Kolkata |
notification_enabled | boolean | Whether notification permission is currently granted |
Rules over subscriber data come from the PushEngage subscriber record:
| Field | Type | Notes |
|---|---|---|
segments | list | Uses includes / excludes only — scalar operators never match |
city, state, geo_country | string | Derived from the subscriber's IP |
has_unsubscribed | boolean | |
attr.<key> | declared per rule | Subscriber attributes — the attr. prefix is required, and a bare attribute name never matches |
Campaigns targeting subscriber data are held back, not evaluated, until the SDK has fetched the subscriber record at least once — which requires push notifications to be set up and the user subscribed. In an in-app-messaging-only integration those campaigns never become eligible, and on a fresh install they typically first evaluate on the second app open. Device-data rules are unaffected.
Operators
equals, not equals, greater than, less than, greater or equal, less or equal, in, not in, and contains, plus includes / excludes for segments.
The rules match the trigger-condition rules, with a few specifics worth knowing:
- Version fields compare component-wise, so
14beats8.1.0and8.1equals8.1.0— never alphabetically. containsis text-only and won't match against numbers, booleans or versions.- A rule naming data that isn't there fails positive operators and passes negative ones, exactly as trigger conditions do.
- String comparison is exact and case-sensitive —
platform equals "android"never matches, because the value isAndroid.
Handling message actions
Each button's action is chosen on the dashboard. Three are handled entirely by the SDK; only Custom action reaches your code.
| Action type | Who handles it | What happens |
|---|---|---|
| Open URL | SDK | Opens the URL in the external browser. A URL with no scheme defaults to https; anything that is not http/https is refused. |
| Request notification permission | SDK | Shows the system permission prompt. On grant, the SDK registers the device so the user becomes a push subscriber — no code of yours runs. |
| Dismiss | SDK | Closes the message. |
| Custom action | Your app | Delivers the button's parameters to your handler, then closes the message. |
Every button with an action closes the message after the action runs, including Custom action buttons, and including a button whose action could not be carried out — so a campaign whose only button has a broken URL can still be closed.
Every tap on a button with an action is reported as a click, Dismiss buttons included.
Receiving custom actions
Native iOS and Android register a handler. Flutter and React Native subscribe to a stream and dispose of the subscription themselves.
- iOS
- Android
- Flutter
- React Native
PushEngage.setIAMCustomActionHandler { _, parameters in
switch parameters["action"] {
case "view_cart":
router.push(.cart)
case "open_offers":
router.push(.offers)
default:
break
}
}
import com.pushengage.pushengage.iam.action.IAMCustomActionHandler
PushEngage.setIAMCustomActionHandler(object : IAMCustomActionHandler {
override fun onCustomAction(actionId: String, parameters: Map<String, Any>) {
when (parameters["action"]) {
"view_cart" -> navigateToCart()
"open_offers" -> showOffers()
}
}
})
IAMCustomActionHandler is a regular interface, so Kotlin needs an object : expression here rather than a lambda. From Java, a lambda works.
Register it wherever you initialize the SDK. Registering before the SDK finishes initializing is supported — the listener is remembered and applied once in-app messaging is ready.
final subscription = PushEngage.onIAMCustomAction.listen((action) {
switch (action.parameters['action']) {
case 'view_cart':
navigatorKey.currentState?.pushNamed('/cart');
break;
case 'open_offers':
navigatorKey.currentState?.pushNamed('/offers');
break;
}
});
// Later, e.g. in dispose():
await subscription.cancel();
useEffect(() => {
const subscription = PushEngage.onIAMCustomAction(({ parameters }) => {
switch (parameters.action) {
case 'view_cart':
navigation.navigate('Cart');
break;
case 'open_offers':
navigation.navigate('Offers');
break;
}
});
return () => subscription.remove();
}, []);
Route on parameters["action"], which holds the Action name you entered for the button on the dashboard. The action id is an internal key the dashboard generates (such as t04B05), so don't route on it. The SDK doesn't act on a custom action itself: it only tells your app which button was tapped. To open a link, use an Open URL action instead.
If a custom button is tapped and no handler is registered, the tap is still recorded as a click and the message still closes — your code simply never hears about it.
Display behavior
Positions. A campaign is displayed at one of four positions, set on the dashboard: top (Top Banner), bottom (Bottom Banner), center (Center Modal), or full (Full Screen). Banners (top and bottom) and full-screen messages need their own close affordance in the campaign content — the SDK does not add a close button for you.
Date window. A campaign's start and end dates are inclusive, and either side can be left open. The window is checked every time the SDK considers showing the campaign — not at sync time — so a campaign that goes live while your app is open can appear without a re-sync, and one that expires mid-session stops appearing immediately. Dates are absolute UTC instants, so a scheduled campaign opens at the same moment worldwide rather than at local wall-clock time.
Auto-dismiss. Under Display, choose Dismiss after to close the message after a set number of seconds. Display forever (the default) keeps it on screen until the user acts on it.
Two things to know if you do set it:
- The countdown starts when the message is presented, not when your HTML finishes rendering. A very short duration can elapse while the content is still loading, so leave comfortable headroom above your content's load time.
- Rotating the device does not restart the countdown, but sending the app to the background closes the message outright. It does not come back, and it already counted against the frequency cap.
Tap outside to dismiss. A campaign can allow the user to dismiss it by tapping the dimmed area around the message (Dismiss on tap, off by default). The rule is the same on both platforms and at all four positions: when a campaign enables it, an outside tap dismisses the message; when it doesn't, an outside tap does nothing.
Either way, taps on the message itself go to the message — that is what your buttons are for — and an outside tap never falls through to your app behind the message.
full is the one position where this earns little: a full-screen message covers its whole region, leaving almost no outside area to tap. Give full-screen campaigns a close button rather than relying on tap-to-dismiss.
Swipe to dismiss is Android-only and is enabled by the same setting. A swipe on the dimmed area around a modal (center) or full-screen (full) message dismisses it; swipes on the message itself go to your content, and banners can't be swiped away. iOS has no swipe dismissal at any position.
Because the SDK draws no close button, a campaign can be made genuinely unclosable — for example a banner with no auto-dismiss, no tap-to-dismiss, and no working button. Give every campaign a way out: a Close block or a button with a Dismiss action.
Priority and queueing. Eligible messages show in priority order: Highest, then Normal, then Lowest. The next message appears only after the current one is dismissed, and a newly eligible message never interrupts one already on screen. Among messages with the same priority, the order isn't guaranteed and can differ between iOS and Android.
What gets reported. Only button taps are recorded as clicks — including taps on Dismiss buttons. An auto-dismiss, a tap outside, a swipe, or a dismissal caused by backgrounding is not reported, so impressions and clicks are the only signals behind a campaign's numbers.
Frequency caps. How often a campaign may reappear on one device:
| Cap type | Behavior |
|---|---|
| No cap | Shows every time it is eligible. |
| One time | Shows only if it has never been displayed on this device. |
| Capped | Both halves apply — up to N displays total, and at most once every interval. Without an interval it is a pure count cap. |
| Recurring | Unlimited displays, spaced by at least interval. There is no total cap; use Capped when you want one. |
Display counts are stored per device. Give a Capped campaign a count and a Recurring campaign an interval; without them, the campaign is never shown.
Troubleshooting
A message that never appears is almost always eliminated by one of the on-device checks. Work down this list in order:
- Has the campaign synced yet? Campaigns arrive on SDK init and then every 6 hours. Relaunch the app after creating a campaign.
- Is the campaign active, and is in-app messaging enabled for your site? While in-app messaging is disabled for the site, no messages are shown.
- Does the trigger event name match? Compare it character-for-character with the campaign's configured trigger event.
- Is it inside its date window? A campaign outside its start/end dates is skipped.
- Has its frequency cap been reached? A One time campaign that already showed will never show again on that device.
- Do the audience rules match? Device-data rules always evaluate; rules over subscriber data are skipped until the subscriber record has been fetched once. See Audience targeting.
- Is another message already on screen? Only one is displayed at a time.
Enable SDK logging to see which check rejected the campaign — each rejection is logged with the campaign id.
Inspecting synced campaigns
Enable SDK logging and the SDK writes the campaigns stored on the device to the platform log by itself — campaign counts and, per campaign, its trigger, position, frequency display count, actions and HTML. Android logs it when the SDK initializes; iOS logs it once per session on app open. No API call is needed, and nothing is logged unless you have opted into logging.
Read it with adb logcat on Android or the Xcode/Console app on iOS, alongside
the per-campaign rejection reasons described above.