Skip to content

Overview ​

An In-App message is a card or a bar that an SDK shows inside the app, while it is open, in one of four layouts. The dashboard (or the admin REST API) authors the message and its targeting; the SDK only asks the server what it may show and reports what happened.

Delivery is server-driven and rendering is client-side:

  • The server decides eligibility (schedule, platform, segment and frequency). The SDK never evaluates targeting rules itself.
  • The SDK renders the returned payload with native components and reports engagement events. The server counts them.

This page is the contract between the server and any SDK (React Native, iOS, Android, or a custom integration). The two device routes are in the In App API Reference; this page adds the rules the reference cannot express.

Both routes authenticate with the App's Client key (Authorization: Bearer ck_..., the same key used for device registration) and prove device ownership with the device's push token in the body. A REST key is rejected with 403, and a missing or invalid key with 401.

Session flow ​

  1. Session start. Once the SDK is initialized and the device is registered (it holds a device_id), call eligible. Do not call it on every screen: the route is rate limited per device (see Limits).
  2. Decide what to show. eligible returns up to 5 messages with a session_start trigger and up to 10 with an event trigger, oldest first within each (see Triggers).
    • Show the first session-start message the SDK can render, right away.
    • Hold the event messages in memory and show them when the app reports their event.
    • At most one message is visible at a time, and each message is shown at most once per session.
  3. Report the impression once per display, not per render. Send one impression when the message becomes visible to the user. A re-render, a rotation or a re-layout of the same display must not send another one. Send the impression before any click or dismiss of that display.
  4. Report the interaction.
    • A tap on a button sends click with that button's button_id.
    • A tap on the image, when the message has an image_action, runs that action and sends click with button_id: "image".
    • On a top or bottom message, a tap on the body runs the action of the first button and reports a click with that button's id.
    • Closing the message sends dismiss with a reason: close_button, swipe or outside_tap when the user closed it, auto when the timer closed it. A dismiss is only meaningful after an impression.
    • When a click closes the message, report the click; a dismiss reported after a click is ignored by the dismiss rate.

Fetching eligible messages ​

http
POST /v1/apps/{app_id}/in-app-messages/eligible
Authorization: Bearer ck_example
Content-Type: application/json
json
{
  "device_id": "00000000-0000-4000-8000-000000000001",
  "token": "example-push-token"
}

Both fields are required. Unknown fields are rejected with 422 invalid_body.

Response 200:

json
{
  "messages": [
    {
      "id": "00000000-0000-4000-8000-0000000000a1",
      "format": "center",
      "title": "Example title",
      "body": "Example body text.",
      "image_url": "https://cdn.example.com/example.png",
      "image_action": { "type": "url", "value": "https://example.com/offer" },
      "style": {
        "background_color": "#102030",
        "text_color": "#FFFFFF",
        "button_color": "#E94B8B",
        "button_text_color": "#FFFFFF",
        "text_align": "center",
        "margin": true
      },
      "dismiss_after_seconds": 8,
      "trigger": { "type": "event", "name": "checkout_done" },
      "buttons": [
        {
          "id": "a1b2c3d4",
          "label": "See more",
          "action": { "type": "deeplink", "value": "exampleapp://offers/42" }
        },
        {
          "id": "e5f6a7b8",
          "label": "Docs",
          "action": { "type": "url", "value": "https://example.com/docs" }
        }
      ]
    }
  ]
}

messages is always an array; it is empty when nothing is eligible, and also when the App is disabled.

Payload ​

FieldTypeNotes
idstring (UUID)Message id; goes in the path of the events route.
formattop, center, bottom or fullChooses the layout. An SDK that does not know a format renders center.
titlestringUp to 100 characters. May be empty when body is set.
bodystringUp to 500 characters, may contain newlines. May be empty when title is set.
image_urlstringAbsent when the message has no image. Always https.
image_actionobjectAbsent when the image is not tappable. Same shape as a button action: type (url or deeplink) and value. See Image action.
styleobject or nullnull means the SDK defaults. See Style.
dismiss_after_secondsinteger or nullnull keeps the message until the user closes it. See Auto dismiss.
triggerobjectAlways present. {"type": "session_start"} or {"type": "event", "name": "..."}. See Triggers.
buttonsarray0 to 2 buttons, in display order.
buttons[].idstringOpaque, assigned by the server and stable across edits of the message. Send it back as button_id.
buttons[].labelstringUp to 30 characters.
buttons[].action.typeurl, deeplink or push_permissionurl is always https; deeplink is https or a custom app scheme. push_permission is a built-in action, see Push permission action.
buttons[].action.valuestringThe link to open. Absent for push_permission: the key is not sent, not even as null.

Targeting (segment, platforms, schedule, frequency) is never part of the payload.

Layouts ​

The layout table is the contract. The sizes are bounds, not pixel specifications: each platform renders with its own components, but it must stay inside the bounds and offer the dismiss gestures listed here.

formatPlacementBackdropDismiss gesturesImage
topBar under the top safe area, at most 40% of the screen heightnoneclose_button, swipe upsmall leading thumbnail
bottomBar above the bottom safe area, at most 40% of the screen heightnoneclose_button, swipe downsmall leading thumbnail
centerCard centered, at most 90% of the width50% blackclose_button, outside_tapabove the text
fullEdge to edge inside the safe areas, buttons pinned to the bottomnot applicableclose_button onlyabove the text
  • A close control is always shown, whatever the layout and the timer.
  • style.margin: true insets the card 16 points from the screen edges (top, bottom and full); false removes the inset.
  • With two buttons, top and bottom place them side by side and wrap to a column when they do not fit.
  • On a top or bottom message, a tap on the body runs the action of the first button and reports a click with that button's id.
  • Every dismissal reports the reason of the gesture that was used.

Style ​

style carries optional look tokens. A token that is absent (or style: null) means the SDK default.

TokenValuesApplies to
background_color#RRGGBBThe card or bar background.
text_color#RRGGBBTitle and body.
button_color#RRGGBBButton background.
button_text_color#RRGGBBButton label.
text_alignleft, center or rightTitle and body. Absent means left.
marginbooleanSee Layouts. Absent means true.

When button_color or button_text_color is set, every button uses them (the one that is absent keeps the SDK default), so the secondary button is no longer drawn in a different style.

Colors are #RRGGBB with no alpha, upper case, so both platforms render the same value. The SDK must ignore a color it cannot parse and use its default for that token instead of failing. The server does not check contrast; the dashboard only warns the author.

Image action ​

When image_action is present, a tap on the image runs it and reports click with button_id: "image". Without image_action the image is not tappable. The action follows the same rules and link checks as a button action (see Plain text and links).

Auto dismiss ​

When dismiss_after_seconds is set (1 to 300), the SDK closes the message that many seconds after it became visible and reports dismiss with reason: "auto". The countdown pauses while the app is in the background. The close control stays available during the countdown, and the user can still close the message earlier, which reports the gesture's own reason.

Triggers ​

Fetching stays at session start: one eligible call per session.

  • {"type": "session_start"}: show the message at session start (the first renderable one, see Session flow).
  • {"type": "event", "name": "checkout_done"}: hold the message in memory and show it when the app reports an event with that exact name through the SDK's trackEvent(name). The SDK shows the oldest held message whose trigger names the event, only when no message is visible, and each message at most once per session.

Event names are matched locally: the SDK never sends them to the server and discards the held messages when the session ends. The server does not keep a registry of event names; the dashboard suggests the names already used by the App's messages.

Eligibility (audience, platform, schedule and frequency) is decided by the server at fetch time for both kinds, and the impression is reported when the message is actually shown.

Compatibility ​

  • The SDK must ignore payload fields it does not know.
  • For an unknown format, render center.
  • Changes to this payload after the first SDK release are additive only.

Push permission action ​

A button can carry {"type": "push_permission"}, with no value. It is the only action an SDK runs itself, and it takes no data from the message author: an SDK still never opens an author-supplied link, it only hands url and deeplink actions to the app. Only buttons accept it; image_action stays url or deeplink.

On a tap, report the click first, close the message, then act on the OS notification permission as it is at that moment:

StateWhat the SDK does
Not decidedShow the system permission prompt.
DeniedOpen the app's notification settings. Do not show a prompt the OS will suppress.
Already granted (it changed after eligible)Nothing.
UnknownNothing, and log nothing sensitive.

On Android below 13 there is no prompt: a device with notifications turned off goes to the settings. On Android 13 and above the OS cannot tell "never asked" from "permanently denied", so the SDK remembers that it has shown the prompt once and opens the settings on later taps. A user who hard-denied in an earlier install or by another path can see nothing on the first tap.

The click listener of the app receives the click with action: null, and the action runs with or without a listener. The action adds no data to what the SDK already collects or sends: permission_status is already part of the device profile, and the system dialog is the user's own choice.

  • Render title and body as plain text. Never interpret them as HTML or Markdown, and never pass them to a web view as markup.
  • The server rejects a link whose scheme is http, javascript, data, file, vbscript, blob or about. An accepted link is an https URL with a host, or (for deeplink) a custom app scheme such as exampleapp://offers/42. Even so, the SDK should only open links it expects, and a deeplink that the app cannot handle should be ignored instead of crashing.

Eligibility rules ​

The server returns a message only when all of these hold at request time:

  • The message is active, not deleted, and inside its window: starts_at has passed (when set) and ends_at has not (when set).
  • The message targets the device's platform (an empty platform list targets all).
  • The device matches at least one of the message's included segments (no included segment targets all devices) and none of its excluded segments. Push permission is not required: In-App does not depend on it.
  • The frequency allows another display (below).
  • A message with a push_permission button is shown only to a device that can use it. It is hidden when the device's push status is ok, and when the device's sdk_version is empty, unreadable or older than the minimum for the SDK the device reports in sdk_name. The minimums are React Native 0.8.0 and iOS 0.1.0; no other SDK receives the message yet. A device that does not report sdk_name is treated as React Native, so it needs 0.8.0 or newer. A device with a push status of off or invalid stays eligible. This rule fails closed: an unknown SDK, sdk_name or version never receives the message.

Edits, activation and pause reach devices on their next eligible call, with a delay of up to about 60 seconds. Edits to the rules of a segment used by a message can take up to 60 seconds to be seen by eligibility.

Frequency ​

The server counts the device's own impression events for that message. Clicks and dismissals do not count.

TypeThe message is eligible while
oncethe device has no impression of it.
n_timesthe device has fewer than max impressions of it.
intervalthe device has no impression of it, or the last one is at least interval_seconds old.

Because the server is the authority, an SDK must not keep its own "already shown" state to suppress messages, and must report impressions reliably: a lost impression makes a once message eligible again.

Reporting events ​

http
POST /v1/apps/{app_id}/in-app-messages/{id}/events
Authorization: Bearer ck_example
Content-Type: application/json
json
{
  "device_id": "00000000-0000-4000-8000-000000000001",
  "token": "example-push-token",
  "event_id": "00000000-0000-4000-8000-0000000000e1",
  "type": "click",
  "button_id": "a1b2c3d4",
  "occurred_at": "2026-10-17T14:30:05Z"
}
FieldRequiredRules
device_id, tokenyesSame as eligible.
event_idyesA UUID generated by the client once per event. Reuse the same value when retrying that event.
typeyesimpression, click or dismiss.
button_idfor clickThe id of one of the message's buttons, or image for a tap on the image of a message that has an image_action. Not allowed on other types.
reasonfor dismissclose_button, swipe or outside_tap when the user closed the message, auto when the timer did. Not allowed on other types.
occurred_atnoRFC3339. Used only when it is within 7 days in the past and 5 minutes in the future of the server time; otherwise the server stores its own time.

Unknown fields are rejected with 422 invalid_body.

Idempotency and retries ​

Events are deduplicated by (message, device, event_id):

  • First delivery: 201 with { "duplicate": false }.
  • Repeat of the same event_id: 200 with { "duplicate": true }; nothing new is stored.

Both mean the event is recorded; treat either as success and drop it from the queue. Generate the event_id when the event happens, store it with the queued event and never regenerate it on retry, so a retry after a timeout cannot double count.

Paused, expired and deleted messages still accept events, so an interaction with a message that was just paused is not lost.

Offline behavior ​

  • Queue each event with its event_id and occurred_at and send the queue when connectivity returns, oldest first.
  • Keep the real occurred_at. After 7 days the server replaces it with the receive time, which moves the event to the wrong day in the metrics and shifts interval frequency, so drop queued events that old instead of sending them late.
  • Do not call eligible while events are still queued if you can avoid it: flush first, otherwise the server may return a message the device already displayed.

Errors and limits ​

Error bodies use the standard envelope, {"error": {"code", "message", "details"}}.

StatusCodeRouteMeaning and SDK behavior
401unauthorizedbothMissing or invalid Client key. Do not retry.
403forbiddenbothThe Client key is not a Client key for this App, or the device_id/token pair does not match. The same code covers a wrong token, an unknown device, a deleted device and a device of another App. On events, an App disabled in the dashboard answers 403 too (eligible answers an empty list). Re-register the device, do not retry as is.
404not_foundeventsThe message does not exist in this App. Drop the event.
422invalid_bodybothMalformed JSON, unknown field, missing device_id or token, or a bad occurred_at. Fix the client, do not retry.
422invalid_contenteventsThe event is invalid. details.fields maps each field to a code: event_id: invalid_id, type: invalid_type, button_id: required, not_allowed or unknown_button (also a click on image for a message without image_action), reason: invalid_reason or not_allowed. Drop the event.
429rate_limitedbothPer-device limit reached. Wait for the Retry-After header (seconds) and retry with the same event_id.
5xx-bothRetry with backoff and the same event_id.

Per-device limits, per minute: 30 on eligible and 120 on events. These initial values may change; the Retry-After header is the source of truth. Batch your work: one eligible per session is far below the limit.

What the metrics mean ​

The dashboard and the metrics endpoint are computed from the events above, over a window of days in the App's timezone, by the event's stored occurred_at.

MetricDefinitionFed by
ImpressionsCount of impression events.impression
ReachDistinct devices with at least one impression.impression
ClicksCount of click events, also per button_id. Taps on the image are the image row, listed first.click
Unique clickersDistinct devices with at least one click.click
CTRUnique clickers / reach, as a percentage with one decimal. null when reach is 0.impression, click
Dismiss rateDevices that closed the message themselves (close_button, swipe, outside_tap) and never clicked / reach, as a percentage. null when reach is 0. A timeout is not a rejection and is not counted.impression, click, dismiss
Auto-dismissedDevices whose only way out was the timer (auto dismissal, no manual dismissal, no click). Reported as a count, apart from the dismiss rate.impression, click, dismiss
Avg impressions per deviceImpressions / reach.impression
Eligible devicesThe size of the targeted audience (segment and platforms) now. A point-in-time estimate, not a historical count; null for a deleted message.none
Reach rateReach / eligible devices, capped at 100.impression

Notes for SDK authors:

  • A click or dismiss without an impression of the same device in the window inflates unique clickers or dismissers relative to reach. That is why the impression must be reported first.
  • A device is counted in at most one of CTR, dismiss rate and auto-dismissed, so the three never add up to more than 100%.
  • Breakdowns (platform, app version, country) count devices with an impression, using the values the server read from the device when the event was received.
  • Because reach spans a window and eligible devices is a snapshot, reach can exceed eligible devices.

MAU ​

In-App events do not count as MAU activity: recording an impression, click or dismiss does not update the device's last_seen_at, so showing In-App messages never changes an App's MAU.