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
- Session start. Once the SDK is initialized and the device is registered (it holds a
device_id), calleligible. Do not call it on every screen: the route is rate limited per device (see Limits). - Decide what to show.
eligiblereturns up to 5 messages with asession_starttrigger and up to 10 with aneventtrigger, 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.
- Report the impression once per display, not per render. Send one
impressionwhen 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 anyclickordismissof that display. - Report the interaction.
- A tap on a button sends
clickwith that button'sbutton_id. - A tap on the image, when the message has an
image_action, runs that action and sendsclickwithbutton_id: "image". - On a
toporbottommessage, a tap on the body runs the action of the first button and reports aclickwith that button's id. - Closing the message sends
dismisswith areason:close_button,swipeoroutside_tapwhen the user closed it,autowhen 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.
- A tap on a button sends
Fetching eligible messages
POST /v1/apps/{app_id}/in-app-messages/eligible
Authorization: Bearer ck_example
Content-Type: application/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:
{
"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
| Field | Type | Notes |
|---|---|---|
id | string (UUID) | Message id; goes in the path of the events route. |
format | top, center, bottom or full | Chooses the layout. An SDK that does not know a format renders center. |
title | string | Up to 100 characters. May be empty when body is set. |
body | string | Up to 500 characters, may contain newlines. May be empty when title is set. |
image_url | string | Absent when the message has no image. Always https. |
image_action | object | Absent when the image is not tappable. Same shape as a button action: type (url or deeplink) and value. See Image action. |
style | object or null | null means the SDK defaults. See Style. |
dismiss_after_seconds | integer or null | null keeps the message until the user closes it. See Auto dismiss. |
trigger | object | Always present. {"type": "session_start"} or {"type": "event", "name": "..."}. See Triggers. |
buttons | array | 0 to 2 buttons, in display order. |
buttons[].id | string | Opaque, assigned by the server and stable across edits of the message. Send it back as button_id. |
buttons[].label | string | Up to 30 characters. |
buttons[].action.type | url, deeplink or push_permission | url is always https; deeplink is https or a custom app scheme. push_permission is a built-in action, see Push permission action. |
buttons[].action.value | string | The 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.
format | Placement | Backdrop | Dismiss gestures | Image |
|---|---|---|---|---|
top | Bar under the top safe area, at most 40% of the screen height | none | close_button, swipe up | small leading thumbnail |
bottom | Bar above the bottom safe area, at most 40% of the screen height | none | close_button, swipe down | small leading thumbnail |
center | Card centered, at most 90% of the width | 50% black | close_button, outside_tap | above the text |
full | Edge to edge inside the safe areas, buttons pinned to the bottom | not applicable | close_button only | above the text |
- A close control is always shown, whatever the layout and the timer.
style.margin: trueinsets the card 16 points from the screen edges (top,bottomandfull);falseremoves the inset.- With two buttons,
topandbottomplace them side by side and wrap to a column when they do not fit. - On a
toporbottommessage, a tap on the body runs the action of the first button and reports aclickwith that button's id. - Every dismissal reports the
reasonof the gesture that was used.
Style
style carries optional look tokens. A token that is absent (or style: null) means the SDK default.
| Token | Values | Applies to |
|---|---|---|
background_color | #RRGGBB | The card or bar background. |
text_color | #RRGGBB | Title and body. |
button_color | #RRGGBB | Button background. |
button_text_color | #RRGGBB | Button label. |
text_align | left, center or right | Title and body. Absent means left. |
margin | boolean | See 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'strackEvent(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, rendercenter. - 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:
| State | What the SDK does |
|---|---|
| Not decided | Show the system permission prompt. |
| Denied | Open the app's notification settings. Do not show a prompt the OS will suppress. |
Already granted (it changed after eligible) | Nothing. |
| Unknown | Nothing, 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.
Plain text and links
- Render
titleandbodyas 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,bloborabout. An accepted link is anhttpsURL with a host, or (fordeeplink) a custom app scheme such asexampleapp://offers/42. Even so, the SDK should only open links it expects, and adeeplinkthat 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_athas passed (when set) andends_athas 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_permissionbutton is shown only to a device that can use it. It is hidden when the device's push status isok, and when the device'ssdk_versionis empty, unreadable or older than the minimum for the SDK the device reports insdk_name. The minimums are React Native0.8.0and iOS0.1.0; no other SDK receives the message yet. A device that does not reportsdk_nameis treated as React Native, so it needs0.8.0or newer. A device with a push status ofofforinvalidstays eligible. This rule fails closed: an unknown SDK,sdk_nameor 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.
| Type | The message is eligible while |
|---|---|
once | the device has no impression of it. |
n_times | the device has fewer than max impressions of it. |
interval | the 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
POST /v1/apps/{app_id}/in-app-messages/{id}/events
Authorization: Bearer ck_example
Content-Type: application/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"
}| Field | Required | Rules |
|---|---|---|
device_id, token | yes | Same as eligible. |
event_id | yes | A UUID generated by the client once per event. Reuse the same value when retrying that event. |
type | yes | impression, click or dismiss. |
button_id | for click | The 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. |
reason | for dismiss | close_button, swipe or outside_tap when the user closed the message, auto when the timer did. Not allowed on other types. |
occurred_at | no | RFC3339. 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:
201with{ "duplicate": false }. - Repeat of the same
event_id:200with{ "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_idandoccurred_atand 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 shiftsintervalfrequency, so drop queued events that old instead of sending them late. - Do not call
eligiblewhile 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"}}.
| Status | Code | Route | Meaning and SDK behavior |
|---|---|---|---|
| 401 | unauthorized | both | Missing or invalid Client key. Do not retry. |
| 403 | forbidden | both | The 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. |
| 404 | not_found | events | The message does not exist in this App. Drop the event. |
| 422 | invalid_body | both | Malformed JSON, unknown field, missing device_id or token, or a bad occurred_at. Fix the client, do not retry. |
| 422 | invalid_content | events | The 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. |
| 429 | rate_limited | both | Per-device limit reached. Wait for the Retry-After header (seconds) and retry with the same event_id. |
| 5xx | - | both | Retry 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.
| Metric | Definition | Fed by |
|---|---|---|
| Impressions | Count of impression events. | impression |
| Reach | Distinct devices with at least one impression. | impression |
| Clicks | Count of click events, also per button_id. Taps on the image are the image row, listed first. | click |
| Unique clickers | Distinct devices with at least one click. | click |
| CTR | Unique clickers / reach, as a percentage with one decimal. null when reach is 0. | impression, click |
| Dismiss rate | Devices 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-dismissed | Devices 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 device | Impressions / reach. | impression |
| Eligible devices | The 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 rate | Reach / 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.