> ## Documentation Index
> Fetch the complete documentation index at: https://docs.sequenzy.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Custom HTML in-app messages

> Show your own HTML, CSS and JavaScript in your app, such as a Survicate survey or a Braze custom HTML message, sandboxed, with a JS bridge to close it, open links, log events and save answers

A custom HTML message is an in-app message whose content is your own
document: a survey from Survicate, a promo built by your design team, or a
custom HTML message you already run in Braze. Sequenzy sends it from a
sequence step like any other [in-app message](https://docs.sequenzy.com/concepts/in-app-messages.md), and the
Sequenzy SDKs show it in a sandboxed web view over your app.

Inside the document, a small JavaScript bridge lets it close itself, open
links and deep links, log events on the contact and save attributes, so survey
answers flow back into Sequenzy. The bridge also answers to Braze's
`brazeBridge` and `appboyBridge` names, so most Braze custom HTML runs
unchanged.

## Create one

1. Add a **Show In-App** step to a sequence and pick **HTML** as the format.
2. Paste your document, or click **Upload .html**. It can be up to 100 KB;
   load images, fonts and scripts from https URLs instead of inlining large
   files.
3. Give it a **Title**. Screen readers announce it, and it names the message
   in the contact's history. It is not drawn on screen.
4. Leave **Close button** on unless your HTML always shows its own close
   control (`sequenzy.close()`, `brazeBridge.closeMessage()` or an element
   with `data-sequenzy-close`). Sequenzy turns it off for you when you upload
   or paste a document that has one.

The preview runs your document exactly as the SDKs will, with a sample contact
filled in. Click through it: everything it asks the app to do (close, open a
link, log an event, save an attribute) is listed under the preview and nothing
is sent.

Merge tags work anywhere in the document, for example
`<p>Hi {{firstName|there}}</p>`. Two extra tags exist for HTML messages:
`{{contactId}}` (the Sequenzy contact ID) and `{{externalId}}` (your own user
ID). Each value is escaped for where it lands: as HTML in text and
attributes, and as JavaScript string text inside `<script>` and `on...`
attributes, so put tags in scripts inside quotes:
`var name = "{{firstName}}";`. A contact's own data, such as a name typed
into a form, can't change your document's markup or code.

You can also create HTML steps through the API (`format: "html"` with `html`
on an `in_app` step), the CLI (`sequenzy in-app html set`) and MCP
(`insert_sequence_step` or `update_sequence_node`). See
[CLI and MCP](#cli-and-mcp).

## Which apps show them

| Platform | What you need |
| - | - |
| iOS | The Sequenzy Swift SDK. HTML messages show automatically; set `Sequenzy.showsHtmlMessages = false` to turn them off. |
| Android | The Sequenzy Android SDK. HTML messages show automatically; set `Sequenzy.showsHtmlMessages = false` to turn them off. |
| React Native | `@sequenzy/react-native` with `react-native-webview` installed: `<SequenzyInAppHost webView={WebView} />`. |
| Flutter | `sequenzy_flutter`. HTML messages show automatically; pass `showHtmlMessages: false` to `SequenzyInAppHost` to turn them off. |
| Web | The browser SDK with `data-sequenzy-in-app` on the snippet, or `createInAppMessages` from `@sequenzy/web-sdk`. See [Web](#on-the-web). |

Apps on older SDK versions, and apps that fetch the feed themselves, never
receive HTML messages unless they ask for them with
`formats: ["modal", "banner", "fullscreen", "html"]`. The message waits in the
feed until a supported app shows it or it expires, so an older app version
never shows a blank message in its place.

A message acts only for the contact it was fetched for. When your app calls
`reset` or identifies someone else, every SDK removes the previous contact's
message without reporting it, and ignores any bridge call it still sends.

## The JavaScript bridge

Sequenzy adds the bridge to the top of your document before any of your own
scripts run. Call it from your scripts or from `onclick` attributes.

| Call | What it does |
| - | - |
| `sequenzy.close()` | Closes the message. Counts as dismissed. |
| `sequenzy.openUrl(url, { external, buttonId })` | Counts a click, closes the message and opens `url` (https or an app deep link such as `myapp://`). |
| `sequenzy.logClick(buttonId)` | Counts a click without closing. `buttonId` is optional. |
| `sequenzy.track(name, properties)` | Logs a custom event on the contact, like `track` in the SDKs. It can start sequences. |
| `sequenzy.setAttributes({ key: value })` | Saves custom attributes on the contact. |
| `sequenzy.setAttribute(key, value)` | Saves one custom attribute. |
| `sequenzy.setName({ firstName, lastName })` | Saves the contact's name. |

Links (`<a href>`) open through the app as clicks, so deep links work and the
click is counted. Add `data-sequenzy-ignore` to a link to keep it inside the
message. Any element with a `data-sequenzy-close` attribute closes the
message when clicked, with no script needed, so it also works on websites
whose Content Security Policy blocks `onclick`. The Escape key and Android's
back button close the message.

The bridge fires `sequenzy:ready` (and Braze's `ab.BridgeReady`) on `window`
once the document has loaded.

Every call acts on the contact who is signed in to the app. A message cannot
change who the contact is, read other contacts, or reach your app's own
storage. Each message can make up to 50 bridge calls each time it is shown,
and each call is limited to 32 KB. Events and attributes follow the same rules
as the SDKs' `track` and `setProfile`: event names start with a letter or
number and are up to 100 characters, properties are up to 8 KB, and a contact
can get up to 60 event batches and 30 attribute updates a minute. Calls the
server rejects are logged in the console on the web.

### Braze compatibility

| Braze call | In Sequenzy |
| - | - |
| `brazeBridge.closeMessage()` | `sequenzy.close()` |
| `brazeBridge.logClick(id)` | `sequenzy.logClick(id)` |
| `brazeBridge.logCustomEvent(name, properties)` | `sequenzy.track(name, properties)` |
| `brazeBridge.getUser().setCustomUserAttribute(key, value)` | `sequenzy.setAttribute(key, value)` |
| `brazeBridge.getUser().setFirstName()` / `setLastName()` | `sequenzy.setName(...)` |
| `brazeBridge.requestImmediateDataFlush()` | Does nothing; calls are sent right away |
| `?abButtonId=`, `?abExternalOpen=`, `?abDeepLink=` on links | Honored and removed from the link |

`changeUser`, `setEmail`, `setPhoneNumber`, subscription groups, array and
increment attributes, `logPurchase` and `requestPushPermission` do nothing and
log a warning in the console: a message can't change identity or ask for push
permission.

Braze Liquid personalization is converted to Sequenzy merge tags when you
save:

| Braze | Sequenzy |
| - | - |
| `{{${braze_id}}}` | `{{contactId}}` |
| `{{${user_id}}}` | `{{externalId}}` |
| `{{${email_address}}}` | `{{email}}` |
| `{{${first_name}}}`, `{{${last_name}}}` | `{{firstName}}`, `{{lastName}}` |
| `{{custom_attribute.${plan}}}` | `{{plan}}` |
| `{{${first_name} \| default: 'there'}}` | `{{firstName \| default: 'there'}}` |

Liquid without an equivalent, such as `{% if %}` blocks, Connected Content or
`{{${country}}}`, has to be replaced before you can save; the editor names
each one.

## Survicate surveys

Survicate's **Braze In-App Message survey** works as is:

1. In Survicate, create the survey with the **Braze In-App Message** type and
   copy the code from its **Launch** tab.
2. Paste it into an HTML in-app step. Sequenzy converts `{{${braze_id}}}` and
   `{{${email_address}}}`, which Survicate needs to show the survey.
3. Survicate's script closes the message when the survey is finished or
   closed. Keep the close button on: if an ad blocker stops Survicate's
   script, it is the way out.

Answers are stored in Survicate. To get them into Sequenzy as well, pick
**Insert → Forward Survicate answers** in the editor. It adds a short script
that logs each answer as a `survicate_question_answered` event (with
`surveyId`, `questionId`, `answerType` and `answer`) and each finished survey
as `survicate_survey_completed`, so you can segment on answers and start
sequences from them. It uses Survicate's JavaScript event listeners, which
need a Survicate plan with JavaScript targeting.

On the web, Survicate's survey floats in the corner and the rest of your page
stays usable.

## On the web

Add `data-sequenzy-in-app` to the browser SDK snippet. It loads a separate
3 KB (gzipped) script, `sequenzy-in-app.js`, only on sites that use it:

```html theme={null}
<script async src="https://api.sequenzy.com/sequenzy.js"
  data-sequenzy-key="seq_pk_..."
  data-sequenzy-company="<your company ID>"
  data-sequenzy-in-app></script>
```

HTML messages show for the contact you identify with
`sequenzy.identify(email, identityToken)`. The SDK checks the feed on page
load, after `identify`, when the tab becomes visible, and every minute. After
`sequenzy.reset()` or an `identify` for someone else, a message on screen for
the previous contact is removed.

With the npm package:

```ts theme={null}
import { createBrowserClient, createInAppMessages } from "@sequenzy/web-sdk";

const sequenzy = createBrowserClient({ publicKey: "seq_pk_...", companyId: "<your company ID>" });
const inApp = createInAppMessages({
  publicKey: "seq_pk_...",
  companyId: "<your company ID>",
  getIdentity: () => sequenzy.getIdentity(),
  // Optional: return true when your router handled the link.
  onOpenUrl: (url) => false,
});
```

The web SDK shows custom HTML messages only. For modals, banners and cards on
the web, [fetch the feed](https://docs.sequenzy.com/concepts/in-app-messages.md#fetch-from-the-browser) and draw
them with your own components.

**Content Security Policy.** The message is an `iframe` with `srcdoc`, which
uses your page's Content Security Policy. If your site sets one, allow
`https://api.sequenzy.com` in `script-src` and `connect-src`, and allow the
hosts your message loads from (the editor lists them). For Survicate that is
`https://*.survicate.com` and `https://*.survicate-cdn.com` in `script-src`,
`style-src`, `font-src`, `img-src` and `connect-src`. If your policy uses a
nonce, add it to the snippet's `<script>` tag; the SDK copies it onto the
`sequenzy-in-app.js` tag and the message's inline scripts and styles. A
message whose script your policy blocks is skipped (with a console warning)
instead of covering the page.

## Security

* **Web:** the message runs in an `iframe` sandboxed without
  `allow-same-origin`, so it has an opaque origin of its own (servers it calls
  see `Origin: null`). It can run scripts, submit forms and open new tabs, but
  it cannot read your page, its cookies, local storage or IndexedDB, and it
  cannot navigate your page. It gets its own in-memory storage.
* **iOS:** a `WKWebView` with a private, non-persistent data store, so the
  message shares no cookies, storage or cache with your app's other web views.
* **Android:** a `WebView` with file and content access, mixed content and
  third-party cookies off.
* **React Native:** `react-native-webview` in incognito mode with shared and
  third-party cookies off.
* **Mobile:** the document loads at `https://in-app.sequenzy.com`, never
  your app's origin. Each time it is shown, its local storage, session
  storage and cookies are cleared before your scripts run, so a survey's
  saved state doesn't carry over from one contact to the next on a shared
  device. iOS and React Native also use a private web view store.
* **Everywhere:** navigation away from the document opens through your app's
  URL handler, which only accepts https links and app deep links;
  `javascript:`, `data:`, `file:` and plain `http:` links are blocked.
* A message that does not finish loading within 10 seconds is not shown, is
  not counted as displayed, and stays in the feed for a later visit. Until it
  has loaded it does not block taps on your app.

Only people who can edit sequences can create HTML messages. The HTML runs in
your own app's users' devices, so treat it like code you ship: use scripts
from providers you trust.

## Limits

| Limit | Value |
| - | - |
| Document size | 100 KB (128 KB after merge tags are filled in) |
| HTML messages per feed response | 3; the rest arrive after these are shown |
| HTML documents per history response | 3 (with `formats=html`) |
| Bridge calls each time it is shown | 50 |
| Size of one bridge call | 32 KB |
| Time to load | 10 seconds |

A step is skipped for a contact (`in_app.skipped` with reason
`html_too_large`) when merge tags make their copy larger than 128 KB.

## Results

HTML messages count like other in-app messages: created, displayed, clicked
and dismissed per step. A click logged with a button ID (`logClick("submit")`)
records the ID on the contact's `in_app.clicked` activity.

## CLI and MCP

| Task | CLI | MCP tool |
| - | - | - |
| Check a document | `sequenzy in-app html validate --file survey.html` | `validate_in_app_html` |
| Put it on a step | `sequenzy in-app html set <sequenceId> <stepId> --file survey.html` | `update_sequence_node` with `format: "html"` and `html` |
| Add a new HTML step | `sequenzy sequences update <id> --insert-steps-json '[{"type":"in_app","format":"html","html":"..."}]'` | `insert_sequence_step` with `type: "in_app"`, `format: "html"` |
| List HTML messages | `sequenzy in-app list --email jane@example.com --formats html` | `list_in_app_messages` with `formats: ["html"]` |

The API equivalent of the check is
[Validate In-App HTML](https://docs.sequenzy.com/api-reference/in-app/validate-html.md).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.