Skip to main content

In-App Campaigns

Display targeted messages to users while they're actively using your app. In-app messages don't require permission, can include rich interactive content, and live alongside push and email in the unified Campaign wizard.

Overview

In-app campaigns let you:

  • Onboard new users with helpful tips and tutorials
  • Announce features and product updates
  • Drive engagement with prompts and CTAs
  • Promote offers with contextual messages
  • Run experiments with A/B variants and control groups

Key advantages:

  • No permission required (unlike push)
  • Rich, interactive content
  • Contextual to the user's current activity
  • Higher engagement rates than push or email
  • Renders with your app's own fonts, colours and dark mode (native messages)

Prerequisites

Make sure you've integrated the SDK first:

The SDK syncs eligible campaigns and displays them when users meet your trigger and targeting criteria.

Message types

Pick one of the layouts to match your use case:

TypeLayoutBest for
ModalCenter-screen with backdropImportant announcements, feature launches
BannerTop or bottom stripQuick tips, time-sensitive offers
Slide-upSlides up from the bottomSubtle prompts, achievement unlocks
FullscreenTakes over the entire screenOnboarding, major updates
CustomDeveloper-defined rendering (API-configured)Unique UI requirements

Creating an in-app campaign

In-app campaigns use the same six-step wizard as every other channel - see Creating Campaigns for the shared shell. The two steps that differ are Compose and Delivery.

Compose step

The Compose step uses VariantTabs with a 3-tile method picker - same gate as email:

  • Create with Drag & Drop - visual editor for non-developers.
  • Create with HTML - split view with a code editor on the left and a sandboxed iframe live preview on the right.
  • Create from Template - pick from your saved in-app templates.

Whichever method you pick, the authored HTML is stored on the variant's customContent. Once a variant is authored, it collapses to an AuthoredSummary card with a 200×220 iframe thumbnail of the rendered message - click it to re-open the editor.

HTML messages need the app to opt in

All three methods above produce HTML, which the app displays in a web view. Because an HTML message runs author-supplied JavaScript inside the app, the SDK will not display one unless the app's developers have explicitly turned it on (allowHtmlJsInAppMessages).

An app that has not opted in skips HTML campaigns silently - the user sees nothing. If you are not sure whether your app has opted in, ask the developers before scheduling an HTML campaign, or use a native message instead.

Native messages

A native message is structured content - headline, body, image, and up to three buttons - that the app draws with its own components. Nothing is skipped and no web view is involved, so it displays in every app regardless of the setting above, matches the app's fonts, colours and dark mode, and works with screen readers.

The trade is control: you choose the words, the image and the buttons; the app decides how they look.

NativeHTML
Displays without the app opting inYesNo
Matches the app's own stylingYesOnly if you rebuild it in CSS
Full control of layoutNoYes
Works where there is no web view (for example tvOS)YesNo

Native content is authored in the App style tile in the Compose step, next to Drag & Drop and HTML. If you create campaigns over the API instead, see the Campaigns API for the field reference.

Colours, fonts and custom CSS

Under Style (optional) you can override the card background, the text colour, the primary button fill and its label, and the corner radius. Leave a field empty and the message inherits that value from the app - which is what makes a native message look like it belongs there, so set one only when a campaign needs a brand moment.

If you set a button colour and no label colour, the label is picked automatically - black or white, whichever stays readable on your fill.

Two controls are web only:

  • Font - an app can render only the fonts it ships with, so naming one it does not have would fall back to something that looks wrong. Phones keep the app's own typeface.
  • Custom CSS, under Advanced - for anything the fields cannot express. Every selector you write is rewritten to sit inside this message before it is applied, so a rule can never reach the rest of the page, and rules here override the colour fields above. Phones have no CSS engine and ignore it.

What you can target in custom CSS: the card itself, h2 (headline), p (message), button.primary and button.secondary. The colour fields are also exposed as CSS variables - --joryio-inapp-bg, --joryio-inapp-fg, --joryio-inapp-primary, --joryio-inapp-primary-fg, --joryio-inapp-radius, --joryio-inapp-font - so you can reset them inside a media query.

Making buttons do things (web)

A custom-HTML message runs inside a sandboxed frame, so it cannot reach your page or ours directly. Joryio gives it a small bridge instead.

Inline onclick does not work, and never will. Every event-handler attribute is removed from the HTML when the message is saved - that is what stops a message from being able to run arbitrary script on your site. Writing onclick="..." produces a button that looks right and does nothing. Use one of the two mechanisms below.

data-joryio-action - the normal way

Put the action on the element as a data attribute:

<button data-joryio-action="requestPushPermission">Enable notifications</button>
<button data-joryio-action="closeMessage">No thanks</button>
ActionWhat it does
requestPushPermissionShows the browser's notification prompt
closeMessageDismisses the message
logConversionRecords a conversion for this campaign

logConversion can take a name via data-joryio-event; leave it off and the conversion is logged as in_app_conversion.

<a href="/upgrade" data-joryio-action="logConversion" data-joryio-event="upgraded_plan">Upgrade</a>

All of this is also in the editor's properties panel - On click, Conversion event (optional) and Click name (reports) - so most messages never need the HTML at all.

Clicks are logged for you

Any <a> or <button> click is reported automatically - you do not need to call anything. To name a click in reporting, add data-action:

<a href="/pricing" data-action="pricing_cta">See plans</a>

Links are followed normally. http(s) and root-relative links open from the host page (the frame cannot navigate your page itself); anchors, mailto: and tel: behave as usual.

window.joryioBridge - for messages with their own script

A message that includes its own <script> can call the bridge directly, which is the only way to pass arguments:

<button id="save">Save my size</button>
<script>
document.getElementById('save').addEventListener('click', function () {
joryioBridge.setCustomUserAttribute('preferred_size', 'M');
joryioBridge.logCustomEvent('size_selected', { size: 'M' });
joryioBridge.closeMessage();
});
</script>
MethodPurpose
logCustomEvent(name, properties)Record an event against the user
setCustomUserAttribute(key, value)Write an attribute to the profile
logConversion(event)Record a conversion for this campaign
logClick(action)Log a click by name
changeUser(userId)Identify the visitor
requestPushPermission()Show the browser notification prompt
navigate(url, target)Open a URL from the host page
closeMessage()Dismiss the message

Note the difference: addEventListener inside your own <script> is fine - it is the onclick attribute that is stripped.

These are web-only. On iOS and Android a custom-HTML message runs in the app's web view and native messages use the app's own buttons, so wire CTAs there through the campaign's button configuration rather than markup.

The same A/B variant tabs work for in-app, including control groups.

Delivery step

The Delivery step for in-app campaigns is purpose-built - instead of the send-type picker every other channel uses, you get:

Hero trigger picker

A row of five tiles, with On Event marked RECOMMENDED:

TriggerWhen the message shows
ImmediateOn the user's next eligible session.
On Event (recommended)When a tracked event fires for the user.
Push Notification TapAfter a push tap lands the user in your app.
Attribute ChangeWhen a watched attribute changes value.
Attribute ThresholdWhen a numeric attribute crosses a threshold.

Each tile has a one-line subtitle and a longer description. Picking a tile reveals per-mode configuration in a soft-grey panel underneath:

  • Immediate - no extra config, just the shared display delay below.
  • On Event - event picker (searchable, with a + Create event fallback) plus optional property conditions.
  • Push Notification Tap - a copy-paste snippet showing how to wire the SDK to this campaign id.
  • Attribute Change - attribute picker plus from-value / to-value inputs.
  • Attribute Threshold - attribute picker plus operator (>, <, , , =) and threshold value.

Display delay

A single Display delay field is shared across every trigger mode. Enter the duration in seconds, minutes, or hours via the unit picker - the message waits this long after the trigger fires before appearing.

Schedule

Pick starts at and expires at dates with separate timezones. The timezone picker pins the workspace default at the top, then Recipient local time, then the full IANA list - so the common cases are above the search box.

Frequency capping

One card holds the frequency controls:

  • Max impressions per user - e.g. 3 times.
  • Time window - 1h / 24h / 7d / 30d / 90d / lifetime, or a custom window.
  • Min delay between impressions - minimum gap between consecutive shows.

Below it, a separate Workspace touching rules card offers an Ignore Touching Rules switch. Bypassing workspace touching rules is fine for transactional or critical messages but should never be the default.

Frequency Best Practices
  • Don't overwhelm users with multiple in-app messages per session
  • Space out impressions (24h+ gap is a good default)
  • Reserve "Ignore Touching Rules" for genuinely critical messages

Evaluation Mode

A 2-card picker - not a dropdown, because the tradeoffs are too important to hide:

ModeLatencyWhat you getWhat you give up
Differential Sync (recommended)~80msUnlimited campaigns. Full segment / entity / behavioral targeting. Entity data in templates.One server round-trip when watched attributes change.
Session start only<10msEvaluated once at session start.Changes don't reflect until the user starts a new session.

Both modes hold the synced campaigns on the device and fire their triggers locally, so a message still appears with no round trip at display time.

A detail prose panel below the cards expands on the selected mode. Differential Sync is the right choice for most production use cases.

Server-side rendering

In Differential Sync and Session start only modes, message content is rendered on the server before it reaches the device: personalization tokens, content blocks, and catalog data are all resolved server-side. That means in-app messages get the same rendering power as email - entity data, blocks.* snippets, and product catalog lookups all work.

Use the Server render test button in the in-app campaign builder to render your message server-side against a chosen user and see exactly what the SDK will receive - verify personalization, blocks, and catalog output before the campaign goes live.

Advanced Settings

A collapsed panel below the evaluation card. When opened:

Watch mode - controls when the SDK re-evaluates campaign eligibility after attribute changes:

  • Auto (recommended) - the SDK auto-detects which attributes the campaign targeting and templates depend on, and re-evaluates when those change.
  • Always - re-evaluates on every attribute change.
  • Never - session-start only.

Priority tiebreaker - used when two eligible campaigns share the same priority:

  • Newest (default) - show the most recently created campaign first.
  • Oldest - show the oldest campaign first (FIFO).
  • Random - random selection from eligible campaigns.

Audience step deviations

The Audience step works like every other channel - same mode chooser, same v2 filter builder - with one piece hidden:

  • The Sending options card is hidden. In-app messages have no subscription concept; the SDK shows them regardless of email/SMS/push opt-in state.

The "Send to everyone" amber callout uses different copy too: in-app's risks are modal fatigue and frequency-cap exhaustion, not deliverability. Example filter chips switch to last_seen within 7 days and plan = free accordingly.

Personalization with Liquid

In-app templates support full Liquid template syntax. The template renders server-side, so besides user.* it can use entity data, content blocks, and catalog data:

Hi {{ user.firstName | default: "there" }},

You've earned {{ user.points }} points!

{% if user.plan == 'free' %}
<p>Upgrade to unlock more rewards.</p>
{% else %}
<p>Thanks for being a {{ user.plan }} member!</p>
{% endif %}

Latest order: #{{ entities.order.id }} ({{ entities.order.total | currency }})

Available filters include date_format, pluralize, currency, truncate, and default. See Liquid Templates for the full reference.

Clicks on links inside in-app messages are tracked automatically and attributed to the campaign - no extra setup or tag rewriting on your side. Click counts appear in campaign analytics alongside impressions, so you can measure click-through and use clicks as conversion signals in A/B tests.

A/B testing

In-app supports the same A/B variant tabs as every other channel, including control groups. Variant assignment is deterministic: the first time a user is evaluated, their user id is hashed into a weight bucket and the assignment is persisted - so the same user always sees the same variant.

Control groups see no message - they're held out entirely, which lets you measure the incremental lift of the campaign vs. doing nothing by comparing conversion rates.

Variant A: 45% - Show campaign
Variant B: 45% - Show alternative
Control: 10% - Show nothing

Variant A conversion: 5%
Control conversion: 2%
Lift: +150%

Best practices

Start with Differential Sync

Most campaigns should run in Differential Sync mode - it gives you the best balance of features and performance. Choose Session start only when eligibility does not need to change mid-session.

Use Auto Watch mode

Let the SDK detect attribute dependencies. Only switch to Always if you need to watch attributes that aren't directly used in targeting; only switch to Never for static welcome messages.

Implement frequency caps

Prevent message fatigue:

  • High engagement - 3 impressions per 7 days
  • Moderate - 2 impressions per 14 days
  • Low frequency - 1 impression per 30 days

Test with A/B variants

Always include control groups when measuring conversion impact. Recommended split: 45% / 45% / 10% control.

Troubleshooting

Campaign not displaying

Check, in order:

  1. Campaign status is active (not draft)
  2. Schedule includes the current time (between startsAt and expiresAt)
  3. The user matches the targeting filters
  4. Frequency cap hasn't been exceeded for this user
  5. No higher-priority campaign is blocking it
  6. The user isn't in a control group

Liquid template errors

  • Missing attributes - wrap with | default: "fallback"
  • Wrong syntax - check for matching {% endif %}, {% endfor %}
  • Entity data shows as blank - use the Server render test button to see exactly what the SDK will receive.

Performance issues

  • Simplify targeting logic
  • Optimize Liquid templates (avoid heavy loops)
  • Use Session-start only for static messages

In-app vs. push

FeatureIn-appPush
PermissionNot requiredRequired
When shownWhile app is openAnytime
Rich contentYesLimited
InteractivityHighLimited
ReachActive users onlyAll users with the SDK installed
Best forContextual engagementRe-engagement

Use both together: push to drive app opens, in-app to engage active users.

Next steps