Skip to main content

Build the in-app engagement channel

The in-app channel is Orbit’s owned engagement surface: content cards (a persistent, feed-style list) and in-app messages (transient modal / slide-up / fullscreen / HTML overlays) that render inside your own website or app. Alongside SMS, WhatsApp, and email, this is the channel that asks nothing of a carrier — the authoring, targeting, and engagement analytics run over the same API as every other Messages page. This guide walks the channel end to end: enable it, author your first card and message, target them to a visitor segment, render the feed with the web and React Native SDKs, and read open/click-through. Both worked examples can be run back to back to leave you with a working promotional feed.

1. Why in-app is zero-carrier-cost

Every carrier channel pays a per-message leg: an SMS exits through a gateway, a WhatsApp template exits through Meta, an email exits through a provider. The in-app channel has no carrier leg at all, because “delivery” on this channel is a pull, not a push:
  • Your app fetches the visitor’s eligible feed from GET /sdk/in-app/feed and renders it with your own components.
  • Engagement (impressions, clicks, dismissals) is reported back by the SDK in one lightweight POST /sdk/in-app/events call — the same traffic your app already pays for.
There is no per-send cost, no sender ID to register where carrier rules would demand one, and no throughput ceiling set outside your own infra. This is the owned-channel parity Braze, Iterable, and Customer.io sell as a separate product line; here the channel ships with the rest of Messages. Reach is the trade-off to know about: the channel can only show content to a visitor who has your web or React Native SDK installed and running. Later sections show how to pair it with a cheap SMS or email fallback so an unreachable visitor still hears from you.

2. How the SDKs surface your content

Two shipped SDK surfaces render the feed; they share one wire model, so a card you author once renders identically in both:
  • WebOrbitInApp from @devotel-orbit/web. Fetches the visitor’s feed, renders content cards into a container element you own, and shows the highest-priority in-app message as an overlay.
  • React NativeOrbitInAppClient from @devotel-orbit/react-native. Same feed and event model; your own components render the payload.
Both SDKs keep a sticky anonymous id per device (until you identify the visitor) and a per-device dismissed list, so a dismissed card never flashes back on the next fetch. Everything the SDK sends is the visitor’s own history: eligibility runs at fetch time, not at send time.

3. Enable the channel

The channel has one master switch per tenant, off by default. Toggle it from the dashboard or the API and it starts serving immediately. Dashboard: open Messages → In-app, toggle Enabled on, and save. The toggle sits above the cards and messages editors on the same page; a disabled channel returns an empty feed even if content is authored, so this is also your pause switch when you want to hold content without deleting it. API: PUT the config with enabled: true. Content is optional at this step — the channel will serve an empty feed until you author something:
GET /api/v1/messages/in-app returns the same envelope so you can read back what the channel will serve before you open it to visitors.

4. Author your first content card + in-app message

Author both surfaces in one PUT — the endpoint replaces the tenant’s in-app content with the body you send. Start with one card and one message so the shapes are obvious:
Field rules the API enforces at author time, so a broken surface never ships to visitors:
  • Cards accept three families. classic requires a title; banner requires an imageUrl (image-only, the whole card is the tap target); captioned_image requires both imageUrl and title.
  • Messages accept modal, slideup, fullscreen, or html. The html type requires an html payload; every other type requires a body. Buttons are optional and capped at five; their action is close, url, or deeplink.
  • imageUrl, url, and buttons[].url must be http/https. deeplink carries an app scheme (e.g. myapp://promo).
  • Every surface carries a stable, unique id. Duplicate ids within cards or messages are rejected — the SDK’s per-device dismissal is id-keyed, so a reused id would silently hide new content from returning visitors.
  • pinned: true floats a card to the top of the feed. expiresAt (ISO-8601) drops the surface out of the feed the instant it passes — set it on anything time-boxed so you never clean the feed by hand. priority orders messages: when more than one message is eligible, the SDK shows the highest-priority one first.
The whole authored set is capped at 100 cards and 100 messages per tenant — author a real feed, not a firehose.

5. Target & trigger: segments and per-visitor eligibility

What a visitor “sees” is decided at fetch time, and it is never a broadcast. Three eligibility levers stack to shape the per-visitor feed:
  • Segment targeting. Any card or message may carry segmentLabels (up to 50 labels). A labeled surface is served only to a visitor whose resolved primary segment (contact_scores.segment_label, the same signal website personalization uses) is on the list. An unlabeled surface is global — it shows to every visitor.
  • Expiry. A surface past expiresAt is dropped from the feed server-side; an SDK also filters it client-side as it renders.
  • Dedupe by device. The SDK keeps a per-device dismissed list and filters dismissed ids out of the feed locally, so a visitor who dismissed a surface never sees it again from that device. There is no cross-device suppression to configure — dismissed is per device and per surface, not per user.
When you want a promotion visible only to one segment, attach segmentLabels: ["vip"] to the card. When you want a message only until a known moment, set expiresAt. When you want a message to lose politely, drop its priority below the competing message rather than deleting it. There is no separate “trigger” step — the channel computes eligibility on every GET /sdk/in-app/feed, so a surfaced change is live on the next fetch.

6. Render the feed in your app

The dashboard lets you author; the SDKs render. Wire them once and the authored feed flows to visitors. Web. Install the SDK and create a client with your public API key (the dv_live_pk_… / dv_test_pk_… key, safe to ship in browser code):
renderCards mounts one element per card into the container and reports an impression per card; a tap on a card with a url reports a click and navigates; the dismiss control reports a dismiss and removes the node. showMessage renders the top eligible message and reports the same three verbs. If you need raw access rather than rendered nodes, call fetchFeed() and work the returned { cards, messages } yourself — the SDK still reports events when you do. React Native. The OrbitInAppClient returns feed data for your components to render, and reports events the same way:
Styling is yours on both. The web SDK tags each rendered node (orbit-inapp-card orbit-inapp-card--<type>) so your CSS takes over immediately; the React Native client returns data only, so presentation is entirely your component layer.

7. Analyse open and click-through

Every surface the SDK renders reports three verbs back to Orbit:
  • impression — the surface was rendered to this visitor (an “open” for a message, a “view” for a card).
  • click — the visitor tapped the surface or one of its buttons. Button-level events carry buttonId, so a “Shop now / Not now” pair splits cleanly.
  • dismiss — the visitor closed the surface. The per-device dismissed list also means a dismissed id drops out of that device’s feed.
The events land in the same {org}.sdk_events stream the rest of the Orbit analytics stack reads, so open rate (impressions per unique visitor), click-through (clicks per impression), and per-surface drop-off (dismissals) all compute from one place. Pull them through the same analytics and event-export tooling you use for delivery logs — no in-app-specific pipeline exists because none is needed. The cleanest way to read performance at a glance is the Insights dashboards, which aggregate SDK events alongside the rest of Messages; keep the per-surface id stable across edits if you want trend lines that don’t fork.

Worked example A: a promotional push (audience + template + render)

Goal: a summer promo, visible only to your highest-value segment, that deactivates itself after the sale. 1. Author the card and a matching modal (targeted to vip, expiring when the sale ends):
2. Read back and confirm what the channel will serve (GET /api/v1/messages/in-app) — the vip segment check happens on every SDK fetch, so the moment the segment is assigned (via /sdk/identify), eligible visitors start seeing the promo. 3. Render it on your site:
Cards pin to the top of that visitor’s feed, the modal shows as an overlay on the first eligible session, and the whole thing disappears the moment expiresAt passes — no cleanup job, no residual soak test.

Worked example B: in-app notification + SMS fallback

Goal: an order-status nudge that prefers the (free) in-app surface when the visitor is in-app, and degrades to an SMS only when they are not — a classic owned-plus-carrier combo that keeps the cheap channel primary. 1. Author the in-app half (global, unpinned, short-lived):
The banner fires whenever the visitor opens your app in October; an impression event tells you the nudge reached them in-session. 2. Fall back to SMS only for the unreachable. Use a waterfall notify chain that tries the app-reachable surface first and SMS only when the richer hop comes back undelivered inside the escalation window:
Order the chain richest → cheapest (whatsappsms here) and the SMS hop fires only when WhatsApp reports undelivered within the hour. In-app and waterfall chains compose: the in-app feed carries the zero-cost reminder to in-app users, while the cascade carries the same nudge to everyone else over channels that do have a carrier leg. For the full notify composer and chain syntax, see the cascade fallback guide.

Where to go next