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:
| Type | Layout | Best for |
|---|---|---|
| Modal | Center-screen with backdrop | Important announcements, feature launches |
| Banner | Top or bottom strip | Quick tips, time-sensitive offers |
| Slide-up | Slides up from the bottom | Subtle prompts, achievement unlocks |
| Fullscreen | Takes over the entire screen | Onboarding, major updates |
| Custom | Developer-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. Scripts written into the message do not run there - the SDK's own bridge handles buttons and forms - but a web view inside the app is still the app developers' call, so the SDK will not display an HTML message unless they 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.
Max width and responsive HTML
A Modal or Slide-up built with Drag & Drop, HTML or a template has a Max width setting, in pixels. You find it on the variant card once the message is authored, in the editor toolbar next to the message background, in the HTML editor above the code, on a journey's in-app step, and on the in-app template itself. Leave it empty for the default - 600 px for a modal, 400 px for a slide-up - or enter a whole number from 240 to 1200. Banner and Fullscreen span the screen and take no max width; native messages draw their own card.
The message gets its width before its content lays out: the smaller of Max width and the screen width minus a small margin (16 px on each side on the web; the app's own margins on iOS and Android). Only the height follows the content. A 680 px design shows at 680 px in a desktop browser and fits the screen on a phone.
Write responsive HTML. Your media queries see the real width of the message, up to Max width: @media (max-width: 560px) inside the message matches when the message itself is 560 px or narrower, whatever the size of the browser. Design for Max width as the widest case and let your media queries stack columns below it. Give the outer container width: 100% or a max-width rather than a fixed width wider than a small phone (about 320 px).
The Preview in the editors uses the same rule on both the App and the Web surface, so the breakpoints fire there exactly where they fire on a device.
When it applies: on the web as soon as the backend is updated. On iOS and Android with the next SDK release; until then the apps keep their current layout (the screen width minus the margins, so a tablet shows a modal at full width). React Native and Unity apps draw messages themselves: from that release they receive the value as maxWidth / MaxWidth to size their web view with.
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.
| Native | HTML | |
|---|---|---|
| Displays without the app opting in | Yes | No |
| Matches the app's own styling | Yes | Only if you rebuild it in CSS |
| Full control of layout | No | Yes |
| Works where there is no web view (for example tvOS) | Yes | No |
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
A custom-HTML message runs inside a sandboxed frame on web and inside the app's web view on iOS and Android, once the app's SDK has opted in to HTML messages. It cannot reach your page or ours directly; Joryio gives it a small bridge instead.
Styles: inline only. Put your CSS in a <style> block at the top of the HTML or in style attributes. The apps show the message under a strict security policy that allows inline styles, images, fonts and media over https, and nothing else - a stylesheet linked from a CDN with <link> does not load on any device, so copy what you need into the <style> block.
What runs on the device. Joryio keeps your markup as written when the message is saved (only <base> and <meta http-equiv="refresh"> are removed, and malformed markup is repaired), but the apps run it under that security policy: your own <script> blocks, inline onclick handlers and <iframe> embeds (a YouTube video) do not execute on web, iOS or Android. Buttons, links, data-joryio-action attributes and data-jry-form forms work through Joryio's bridge - see below - and need no script. Personalization values are always HTML-escaped, so a profile field can never become code (add | raw deliberately when you need markup from a field). The campaign Preview shows the layout and lists the bridge actions your buttons trigger; it is not a guarantee that a script will run on a device.
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>
| Action | What it does |
|---|---|
requestPushPermission | Shows the browser's notification prompt |
closeMessage | Dismisses the message |
logConversion | Records 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 - what your buttons call
Behind data-joryio-action is the SDK's bridge, window.joryioBridge. The SDK's own script is the only script that runs in the message, and it carries out these methods for you:
| Method | Purpose |
|---|---|
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 |
data-joryio-action can name any of them and pass one argument in data-joryio-event - for example data-joryio-action="logCustomEvent" data-joryio-event="size_selected". A method that needs two arguments, such as setCustomUserAttribute, cannot be driven from markup. A <script> block or an inline onclick="joryioBridge.closeMessage()" written into the message does not run, so it cannot call the bridge either.
The full bridge is available on web, iOS and Android (iOS SDK and Android SDK from the releases that ship it - logCustomEvent, setCustomUserAttribute, changeUser, requestPushPermission, logConversion, logClick, navigate, closeMessage, submitForm; earlier native releases expose only closeMessage, navigate and click logging). React Native and Unity apps show messages through the native iOS and Android SDKs, so the same bridge applies. An app that draws messages in its own web view instead (React Native onInAppMessage, Unity SetInAppMessageHandler) decides what runs there and submits forms with submitInAppForm / SubmitInAppForm. Native messages use the app's own buttons, so wire their CTAs 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:
| Trigger | When the message shows |
|---|---|
| Immediate | On the user's next eligible session. |
| On Event (recommended) | When a tracked event fires for the user. |
| Push Notification Tap | After a push tap lands the user in your app. |
| Attribute Change | When a watched attribute changes value. |
| Attribute Threshold | When 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 eventfallback) 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 come first. Type in the field to search by city, country or UTC offset; the arrow keys move through the matches and Enter picks one.
The time you pick is read in the zone you choose: 09:00 with America/New_York starts the campaign at 09:00 New York time wherever you are when you save it, and the editor shows that same 09:00 to whoever opens it later. Recipient local time reads the time in each person's own timezone attribute - the campaign starts at 09:00 for a contact in Tokyo and, hours later, at 09:00 for one in New York; a contact with no timezone attribute uses the workspace timezone. The end picker follows the start zone unless you give it one of its own.
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.
Only a show counts: an impression the device reports (displayed, or dismissed by a client that reports only the dismissal). A page that loaded the message but was left before it appeared does not use up the cap. So that two devices cannot both show a once-only message at the same moment, a message handed to one device holds its slot for two minutes until that device confirms it. A device reports a message as displayed only after it has been on screen for about a second, or when the person taps or closes it sooner, so a message that flashed up during a redirect or a screen change does not use up the cap either.
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.
- 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:
| Mode | Latency | What you get | What you give up |
|---|---|---|---|
| Differential Sync (recommended) | ~80ms | Unlimited campaigns. Full segment / entity / behavioral targeting. Entity data in templates. | One server round-trip when watched attributes change. |
| Session start only | <10ms | Evaluated 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.
Show in. When the workspace has more than one app, the Audience step also asks which app shows the message: All apps (the default) or one app - for example only your Magento store's web app, or only the iOS app. The SDK of every other app never receives it. A journey's in-app step has the same Show in setting.
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.
Forms inside messages
An in-app message can carry a form: in the drag & drop editor, the Form block category has a signup form, email, phone, text, dropdown, choice and consent fields, and a submit button (the same blocks as landing pages). Give every field a Field key (lowercase, underscores). The Form view in the editor rail shows what the markup declares and lets you say where each value goes: the profile attribute it writes, the list a consent box subscribes to, and what happens after submit (a message in place, or a URL).
When someone submits, the SDK sends the values with the identity it holds: a known user's profile is updated (an email or phone only where the profile has none - a form never replaces them), an anonymous visitor becomes a profile once the form names them (email or phone), the ticked lists are subscribed, and In-App Form Submitted is recorded with campaignId, campaignName, variantId, the field keys, listsSubscribed, and the form's own email and phone with phoneUpdated (false when the profile kept a different phone of its own), so journeys, triggered campaigns and segments can act on it. The submission also appears as a click (form_submit) on the message.
Templates keep their form with the template; a campaign that uses the template inherits it. Supported in the Web SDK and, from the releases that ship the full bridge, in the iOS and Android SDKs; a React Native app that renders the HTML itself calls submitInAppForm; Unity shows native messages only.
The same editor and the same Form view are in a journey's in-app step: a form in a message the step carries itself, or in the template it uses, submits like a campaign's, and the submission's campaignId is the journey step's (journey:<journey id>:<step id>). Removing the form from the design removes its field settings too; a form added back starts from the defaults.
Link click tracking
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.
Each click also carries the name the message gave it: the element's data-action, a logClick call from the bridge, or the SDK's own link_click / cta_click. Open a variant from the campaign's analytics and switch to Click map: every button and link in the message is badged with the people who tapped it and their share of everyone who saw it, and the list beside it ranks the clicks by button. A native message (no HTML) shows the ranking on its own. The names need SDK releases from 2026-09-26 or later; older SDKs report every tap as one plain click.
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
Each user sees a campaign once, by default. Repetition is something you turn on: tick Allow this campaign to show more than once per user under Frequency Capping. Leave it off and the message is shown one time per person, ever - no window, no expiry.
With it on, leave the limit empty for unlimited, or set a number and a window (minutes, hours, days, weeks or months) to cap it. A window applies to the count only; the Min Delay below it is a separate cooldown between two showings.
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:
- Campaign status is
active(notdraft) - Schedule includes the current time (between
startsAtandexpiresAt) - The user matches the targeting filters
- Frequency cap hasn't been exceeded for this user
- No higher-priority campaign is blocking it
- 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
| Feature | In-app | Push |
|---|---|---|
| Permission | Not required | Required |
| When shown | While app is open | Anytime |
| Rich content | Yes | Limited |
| Interactivity | High | Limited |
| Reach | Active users only | All users with the SDK installed |
| Best for | Contextual engagement | Re-engagement |
Use both together: push to drive app opens, in-app to engage active users.
Next steps
- Creating Campaigns - shared wizard reference
- Push Notifications
- A/B Testing
- Liquid Templates