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

# Build the in-app engagement channel: content cards + in-app messages

> Enable Orbit's owned, zero-carrier-cost in-app channel — author content cards and in-app messages in Messages → In-app, target them by segment, render them with the web and React Native SDKs, and read engagement.

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

* **Web** — `OrbitInApp` 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 Native** — `OrbitInAppClient` 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:

```bash theme={null}
curl -X PUT "https://api.orbit.devotel.io/api/v1/messages/in-app" \
  -H "X-API-Key: $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "enabled": true,
    "cards": [],
    "messages": []
  }'
```

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

```bash theme={null}
curl -X PUT "https://api.orbit.devotel.io/api/v1/messages/in-app" \
  -H "X-API-Key: $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "enabled": true,
    "cards": [
      {
        "id": "promo-summer-2026",
        "type": "classic",
        "title": "Summer sale starts now",
        "description": "20% off through Sunday — no code needed.",
        "imageUrl": "https://yourapp.example/assets/summer.png",
        "url": "https://yourapp.example/sale",
        "pinned": true,
        "dismissible": true,
        "expiresAt": "2026-09-01T00:00:00.000Z"
      }
    ],
    "messages": [
      {
        "id": "welcome-v1",
        "type": "modal",
        "header": "Welcome back",
        "body": "Pick up where you left off — your cart is saved.",
        "buttons": [
          { "id": "resume", "text": "Resume", "action": "url", "url": "https://yourapp.example/cart" },
          { "id": "close", "text": "Not now", "action": "close" }
        ],
        "priority": 10
      }
    ]
  }'
```

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](/guides/website-personalization-slots) 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):

```ts theme={null}
import { OrbitInApp } from "@devotel-orbit/web";

const inApp = new OrbitInApp({
  publicKey: "dv_live_pk_your_public_key",
  apiUrl: "https://api.orbit.devotel.io",
  userId: currentUser.id, // optional; omit for anonymous visitors
});

// Render content cards into a container you own:
await inApp.renderCards("#orbit-feed");

// Or show the single highest-priority in-app message as an overlay:
await inApp.showMessage();
```

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

```ts theme={null}
import { OrbitInAppClient } from "@devotel-orbit/react-native";

const inApp = new OrbitInAppClient({
  publicKey: "dv_live_pk_your_public_key",
  apiUrl: "https://api.orbit.devotel.io",
  userId: currentUser.id, // optional
});

const { cards, messages } = await inApp.fetchFeed();
// Render `cards` and `messages` with your own components, then report:
await inApp.reportEvent("impression", cards[0].id);
await inApp.reportEvent("click", cards[0].id);
await inApp.dismiss(cards[0].id);
```

**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](/guides/delivery-log) — 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):

```bash theme={null}
curl -X PUT "https://api.orbit.devotel.io/api/v1/messages/in-app" \
  -H "X-API-Key: $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "enabled": true,
    "cards": [
      {
        "id": "promo-summer-2026",
        "type": "classic",
        "title": "20% off for VIPs",
        "description": "Early access starts now — ends Sunday.",
        "imageUrl": "https://yourapp.example/assets/summer.png",
        "url": "https://yourapp.example/sale",
        "pinned": true,
        "segmentLabels": ["vip"],
        "expiresAt": "2026-09-01T00:00:00.000Z"
      }
    ],
    "messages": [
      {
        "id": "promo-summer-modal",
        "type": "modal",
        "header": "VIP preview",
        "body": "Your 20% early-access window is open.",
        "buttons": [
          { "id": "shop", "text": "Shop now", "action": "url", "url": "https://yourapp.example/sale" },
          { "id": "dismiss", "text": "Later", "action": "close" }
        ],
        "priority": 50,
        "segmentLabels": ["vip"],
        "expiresAt": "2026-09-01T00:00:00.000Z"
      }
    ]
  }'
```

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

```ts theme={null}
await inApp.renderCards("#orbit-feed");
await inApp.showMessage();
```

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):

```bash theme={null}
curl -X PUT "https://api.orbit.devotel.io/api/v1/messages/in-app" \
  -H "X-API-Key: $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "enabled": true,
    "cards": [
      {
        "id": "order-status-oct",
        "type": "banner",
        "imageUrl": "https://yourapp.example/assets/order-update.png",
        "url": "https://yourapp.example/orders",
        "expiresAt": "2026-10-31T00:00:00.000Z"
      }
    ],
    "messages": []
  }'
```

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:

```bash theme={null}
curl -X POST "https://api.orbit.devotel.io/api/v1/notify" \
  -H "X-API-Key: $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "mode": "waterfall",
    "fallback_window_seconds": 3600,
    "body": "Your order has an update — open the app to see it.",
    "bindings": [
      { "channel": "whatsapp", "address": "+15551234567" },
      { "channel": "sms",      "address": "+15551234567" }
    ]
  }'
```

Order the chain richest → cheapest (`whatsapp` → `sms` 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](/guides/notify-cascade-failover).

## Where to go next

* [Messages → In-app](/guides/channel-settings-consoles) is the console page — use it to author without the API.
* [Website personalization](/guides/website-personalization-slots) covers the same `contact_scores.segment_label` targeting signal.
* [Cascade fallback](/guides/notify-cascade-failover) is the reference for mixing in-app with carrier channels.
* [Delivery logs](/guides/delivery-log) is where in-app engagement events land alongside the rest of Messages.
