Custom HTML messages
Show your own HTML page as an in-app message, with video, audio and a small JavaScript bridge back to the SDK.
The Custom HTML template shows a page you write, in a sandboxed web view on iOS and a sandboxed iframe on the web. Use it when built-in templates aren't enough: a video, a countdown, a three-slide tour or your own brand layout.
Triggers, audience, limits, priority, schedule, experiments, journeys and test messages work as for any other template.
Custom HTML needs an app built with iOS SDK 3.1.0 or Android SDK 3.1.0 or later, or a site on Web SDK 2.1.0 or later. Older versions skip the message silently.
Layouts
The page's height sets the message's height, up to the layout's maximum.
| Layout | How it shows |
|---|---|
| Full screen | Fills the screen inside the safe areas. The reported height is ignored. |
| Card in the middle | A card over a dimmed backdrop, at most 420 points wide and 90% of the screen tall. Taller pages scroll. |
| Banner at the top | Full width, at most 30% of the screen tall. The app stays usable underneath. |
| Banner at the bottom | As above, at the bottom. |
On an iPhone in landscape (under 500 points tall), a banner can grow to 45% of the screen.
The SDK always draws its own close button in the top-right corner, whatever the close-button setting, so a broken script can never trap anyone. Don't draw your own X. Making the message non-closable only disables the backdrop tap, swipe and Escape key; the SDK's close button stays.
Build the page
In the composer's Message step, set Template to Custom HTML. Pick a Layout, and an Orientation if the message only works one way.
Start from an example offers eight working pages: Promo video, Product launch, Push permission primer, Countdown offer, Feature tour, Feedback, Announcement and Win-back offer. Or choose Start blank. Every example works on phone and desktop, in light and dark mode.
Picking an example later replaces your HTML, and the code editor can't undo it.
Quick edit turns every element marked data-grovs-edit="Label" into a form field: text, image, video or audio file, button label and action, or colour. Script-controlled elements are offered as text only.
For anything else, switch to Code. The editor autocompletes grovs.*, the data-grovs-* attributes and {{ user.x }} fields. Snippets inserts a "No thanks" button, a tracked button, a link button, a push permission button, a sized video, editable text and personalized text. How it works opens a short reference. The editor shows the size against the 64 KB limit and runs the server's checks as you type.
Upload pictures, video and audio in the Media panel and copy each file's link into your HTML. Each file shows Used in this message or Not used yet. Files still referenced in the HTML can't be deleted.
Preview runs your page with the real bridge on Phone, Phone landscape or Browser, in light or dark. Sample values fill personalization fields. Each bridge call appears under What the message did, so you can check what buttons close, track and open.
Limits
| What | Limit |
|---|---|
| HTML size | 64 KB of UTF-8. Doesn't count toward the 16 KB limit for the rest of the message. |
| Images | PNG, JPEG or GIF, up to 1 MB each |
| Video | MP4, up to 20 MB each |
| Audio | MP3 or M4A, up to 10 MB each |
| Files per message | 30 files and 100 MB in total |
Media downloads when the message shows, not at sync. Export videos with "fast start" (web-optimized) so playback starts before the whole file arrives.
Duplicating a message copies only the files its HTML still uses. A message's files are deleted with it.
Links and resources
The server checks your HTML on save, and the SDK re-checks links on tap.
Links (<a href> and data-grovs-url) may be:
https://with a host namemailto:,tel:orsms:- a
#fragment, which scrolls within the page - your app's own scheme, such as
medly://orinstagram://
Refused: http:, javascript:, vbscript:, data:, blob:, file:, about:, intent:, and relative links (the page has no base address).
Resources (src, poster, srcset, <link href> and url() in a style attribute) must be https:. Images may also be data:image/….
<base> and <meta http-equiv="refresh"> aren't allowed. Give every <img> and <video> a width and height, or an aspect-ratio, or the message jumps as it loads. The builder warns when one is missing.
Personalize
Write fields in text as in any message:
<h1>Hi {{ user.first_name | default: "there" }}</h1>Fields are filled in text and in the alt, title, value, placeholder, aria-* and data-* attributes. They're never filled in scripts, styles, <template>, <textarea> or any URL attribute. A {{ inside href or src is refused on save. To build a personalized link, read the value in a script:
<script>
const link = "https://medly.app/plan?name=" + encodeURIComponent(grovs.user.first_name || "");
</script>If a field has no value and no fallback, the message is skipped for that trigger and not counted, as with built-in templates.
The JavaScript bridge
The SDK injects a grovs object before any of your scripts run.
| Call | What it does | Counts as the click |
|---|---|---|
grovs.close() | Closes the message | No |
grovs.track(name, properties?) | Records a custom event with the message's campaign_id added. Names are 1 to 100 characters and can't be a reserved name such as in_app_viewed or install | Yes, if it's the first |
grovs.open(url) | Opens a link allowed by the rules above, then closes the message. Ignored unless it follows a real tap in the page | Yes, if it's the first |
grovs.setAttribute(key, value) | Sets an attribute of the person, as the SDK's own attribute call does | No |
grovs.requestPushPermission() | Asks for notification permission. Returns a promise of "granted", "denied" or "unsupported". If the person already decided, it returns that answer without asking again | No |
grovs.user | The person's attributes, read-only | |
grovs.event | The properties of the event that triggered the message, read-only | |
grovs.capabilities.pushPermission | "prompt" while the app can still ask, otherwise "granted", "denied" or "unsupported" |
Calls made before the message is on screen aren't queued: requestPushPermission() rejects with not_shown, and the others are dropped.
On the web, push permission is always "unsupported". A push primer should hide its button unless grovs.capabilities.pushPermission is "prompt". The gallery's primer does this.
Buttons without code
Most messages need no JavaScript. Put an action on any element:
<button type="button" data-grovs-action="close">No thanks</button>
<button type="button" data-grovs-action="open" data-grovs-url="https://medly.app/plans">See plans</button>
<button type="button" data-grovs-action="track" data-grovs-event="tour_finished">Got it</button>A plain <a href> also works: a tap opens the link and closes the message. A link to #id scrolls to that element instead. If your own click handler calls preventDefault(), the bridge leaves the link alone.
How links open
| iOS | Web | |
|---|---|---|
https:// links | Opened by the system | Opened in the same tab |
Other schemes (medly://, mailto:) | Opened by the system. Nothing happens if no app handles them | Handed to the browser |
How the message shows
The page loads hidden at its final width. The SDK shows it, and counts the view, once the page has finished parsing and reported a height above zero. A Full screen page only needs to finish parsing. Images still loading may then grow the message up to its maximum.
If the page isn't ready within 5 seconds, the SDK drops it without showing or counting it.
On iOS the page runs in a WKWebView that keeps no cookies or storage after closing. Inline video plays without a tap. Navigation away from the page is cancelled; links go through the bridge. Embedded frames, such as a YouTube player, can navigate.
On the web the page runs in an <iframe sandbox="allow-scripts" srcdoc> with no access to your site's origin. If the page navigates itself, the message closes.
Content-Security-Policy on your site
On the web, the message's iframe inherits your page's Content-Security-Policy, and the SDK can't change it. To show HTML messages, the policy must allow:
- Inline scripts. The bridge and your page's scripts are inline, so
script-src(ordefault-srcwithoutscript-src) needs'unsafe-inline'and no nonce, hash or'strict-dynamic'. Browsers ignore'unsafe-inline'when any of those is present, so such policies can't run HTML messages at all. - Your media hosts in
img-src,media-srcandfont-src.img-srcalso covers built-in template pictures. style-src 'unsafe-inline'if the page uses<style>orstyle="".
A blocked message never starts. The SDK skips it and reports inAppDisplayFailed (code 5) to your error callback once, naming the CSP. Built-in templates work under any policy.
Test messages
In a test message, grovs.track and grovs.setAttribute are logged, not sent. Everything else behaves as in production.
Self-hosting: the preview page
The builder's preview is a small page served by your API at /in_app/preview, embeddable only by the dashboard's own origins. If your dashboard runs on a non-default origin, list every allowed origin in IN_APP_PREVIEW_ORIGINS, comma-separated. The list replaces the defaults.