# 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.

Source: https://docs.grovs.io/docs/engagement/in-app-messages/custom-html

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.

> **Warning:** 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

### 1. Pick Custom HTML

In the composer's **Message** step, set **Template** to **Custom HTML**. Pick a **Layout**, and an **Orientation** if the message only works one way.

### 2. Start from an example

**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.

### 3. Edit the text, pictures and buttons

**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.

### 4. Upload media

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.

### 5. Preview it

**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 name
- `mailto:`, `tel:` or `sms:`
- a `#fragment`, which scrolls within the page
- your app's own scheme, such as `medly://` or `instagram://`

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:

```html
<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:

```html
<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:

```html
<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` (or `default-src` without `script-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-src` and `font-src`. `img-src` also covers built-in template pictures.
- `style-src 'unsafe-inline'` if the page uses `<style>` or `style=""`.

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](https://docs.grovs.io/docs/engagement/in-app-messages/create-a-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.

## Related

- [Create an in-app message](https://docs.grovs.io/docs/engagement/in-app-messages/create-a-message)
- [Set up in-app messages on iOS](https://docs.grovs.io/docs/sdk/ios/in-app-messages)
- [Set up in-app messages on the web](https://docs.grovs.io/docs/sdk/web/in-app-messages)
