Skip to main content

The in-app channel model

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, or HTML overlays) that render inside your own website or app. It sits alongside SMS, WhatsApp, and email in the Messages hub, and it is the one channel that asks nothing of a carrier — this page explains the model behind it. The in-app channel guide is the operational how-to with copy-pasteable requests; the schema details below are deliberately not duplicated here.

Section 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 its 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.
Because eligibility is computed at fetch time, not send time, there is no per-send cost, no sender ID to register, and no throughput ceiling set outside your own infrastructure. The trade-off is reach: the channel can only show content to a visitor who runs your web or React Native SDK. Pair it with a carrier fallback (see cross-channel fallback) when a hard reach guarantee matters. This is the owned-channel parity that other engagement vendors sell as a separate product line; here it ships with the rest of Messages, one master toggle per tenant.

Section 2 — Two render classes

The channel splits into two render classes with deliberately different lifetimes. Both are authored on the Messages → In-app console page (or through PUT /api/v1/messages/in-app), and both travel over the same feed. Content cards — persistent, feed-style. A card is a durable surface that lives in the visitor’s feed until it expires or is dismissed. Three families exist: classic (title-led), banner (image-only, the whole card is the tap target), and captioned_image (image plus title). pinned: true floats a card to the top of the feed; dismissible controls whether the visitor can remove it. Cards are the “inbox that never pushes” surface — a promotion, an announcement, an order status that should stay visible across sessions. In-app messages — transient overlay. A message renders once as a modal, slideup, fullscreen, or html overlay and then leaves the feed. Buttons (capped at five; actions close, url, or deeplink) carry the call to action, and a numeric priority decides which message wins when several are eligible — the SDK shows one at a time. Messages are the “interrupt politely” surface — a welcome back nudge, a VIP preview, a one-time announcement. Both classes share expiresAt (ISO-8601), after which a surface drops out of the feed server-side, so a time-boxed campaign never needs a cleanup job. The authored set is capped at 100 cards and 100 messages per tenant — a real feed, not a firehose.

Section 3 — Targeting model

What a visitor sees is never a broadcast; it is decided per fetch by stacking eligibility checks. The primary targeting lever is segment-scoped delivery with the same Contacts/CDP vocabulary the carrier channels use: any card or message may carry segmentLabels, and 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. Two more levers stack on top: expiry filters time-boxed surfaces on every fetch, and per-device suppression keeps a dismissed id out of that device’s feed. Because the visitor’s segment assignment is resolved on each GET /sdk/in-app/feed, a change to either side — a new label on a surface, or a fresh identify on the visitor — takes effect on the next fetch with no separate publish step.

Section 4 — Engagement events

Carrier channels close the delivery loop with DLRs — receipts handed back by each carrier. The in-app channel has no carriers to hand receipts back, so engagement is the receipt: the SDK reports three verbs per rendered surface, tracked client-side and aggregated server-side.
  • 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 two-button message splits cleanly.
  • dismiss — the visitor closed the surface; the id also joins the per-device dismissed list.
These events land in the same tenant SDK-event stream the rest of the 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 delivery logs and Insights dashboards you use for carrier channels. Because the channel is pull-based, an impression event is a certainty signal rather than a carrier’s best-effort receipt: the surface rendered on the device, or the event never arrives.

Section 5 — SDK integration surface

Two shipped SDK surfaces render the feed, and they share one wire model so a surface authored once renders identically in both:
  • Web — OrbitInApp from the web SDK. Fetches the visitor’s feed with your public API key, renders content cards into a container element you own (renderCards), and shows the highest-priority eligible message as an overlay (showMessage). Rendered nodes are tagged (orbit-inapp-card orbit-inapp-card--<type>) so your CSS takes over.
  • React Native — OrbitInAppClient from the React Native SDK. Same feed and event model; returns raw { cards, messages } data for your own components and reports events the same way.
Both SDKs keep a sticky anonymous id per device (until you identify the visitor) and a per-device dismissed list, so a dismissed surface never flashes back on the next fetch.

Section 6 — Coexistence with carrier channels: the worked cascade pairing

In the Messages hub, in-app composes with the carrier channels rather than competing with them. The canonical pairing is a cascade fallback chain that prefers the free in-app surface and escalates the rest to a carrier leg. The cascade fallback guide owns the chain syntax; this section lays out the model-level pairing that makes in-app first the right default. The chain, numbered:
  1. In-app first — the zero-carrier-cost hop. The SDK’s next GET /sdk/in-app/feed pull renders the surface for every reachable visitor. That whole audience costs nothing on this hop — Section 1’s cost model applies: no per-send charge, no gateway leg.
  2. Evaluate the check-in window. Give the SDK audience a bounded window (hours, not minutes) to fetch and render. Reachable visitors consume the surface; unreached ones move down the chain.
  3. Escalate to a carrier backstop. Visitors with no active in-app surface — no web/React Native SDK running for subsequent identify pulls — are handed to SMS or email, carrying the same offer. Only this hop pays a carrier leg, and it pays it only for the audience in-app genuinely cannot reach.
Eligibility is fetch-time, so fallback is a per-pull decision. The whole cascade decision happens where eligibility is computed — at GET /sdk/in-app/feed, not at send time. Each visitor’s pull reports “you do (or do not) have an active in-app surface,” and the chain reads that signal per-pull. That is what separates the in-app fallback pairing from the carrier-to-carrier chains on the cross-channel fallback page: the advance trigger fires on reachability, not on a delivery receipt, because an in-app hop has no DLR to wait on. Worked example — segment-targeted offer with an SMS backstop. You target segmentLabels: ["vip_welcome"] on a content card. A visitor whose resolved primary segment (contact_scores.segment_label) matches, and whose browser/app runs the SDK, renders the card on their next feed fetch at zero carrier cost. A second matching visitor — same segment, but no active SDK surface — never produces a fetch, so the chain escalates their vip_welcome offer to SMS after the check-in window. One audience, one offer, two channel hops, and the carrier leg pays only for the second visitor. Broadening the chain to email as a third hop is a one-line change on the same model. Reach is why the backstop exists, and it is not something to paper over: the in-app channel can only reach a visitor who runs your web or React Native SDK. For anything with a hard reach guarantee, pair every in-app hop with a carrier fallback — never author in-app as the sole hop when the message must land.

Where to go next