In-App Messages API
In-app messages render inside your app: as a banner, modal, card, or fullscreen takeover, or silently into a durable per-subscriber inbox. The API has two halves:
- Management (experiences) — create, publish, and measure in-app
experiences. Authenticated with a tenant JWT bearer token
(
Authorization: Bearer ...), the credential used by dashboard sessions. - SDK delivery — the endpoints your app (or our client SDKs) call to
receive messages and report engagement. Authenticated with the publishable
SDK key (
X-SDK-Keyheader); a JWT bearer token is also accepted on the stream and inbox routes.
Every in-app endpoint is gated on your plan's in-app messaging feature. When
the plan does not include it, requests return 403:
{
"error": "in_app_not_permitted",
"message": "in-app messaging is not included in your plan (plan: ...)",
"upgrade_required": true
}
Experiences
An experience is the authored unit: content, a format, optional display
rules, an audience, and an optional live window. Its lifecycle is
draft → live → (paused ↔ live) → ended → archived; only drafts
are editable.
The experience object
{
"id": "3f7a1c9e-...",
"name": "Summer sale takeover",
"format": "modal",
"status": "draft",
"content": {
"title": "Summer sale",
"body": "Up to 40% off this week only.",
"image_url": "https://cdn.example.com/sale.png",
"actions": [
{ "id": "shop", "title": "Shop now", "url": "https://example.com/sale" },
{ "id": "later", "title": "Maybe later" }
]
},
"display": {
"trigger": "event",
"trigger_event": "cart_abandoned",
"frequency": "once",
"priority": 5,
"screens": ["/cart", "/checkout"]
},
"audience": { "type": "segment", "segment_id": "9f5b2c1e-..." },
"start_at": "2026-08-01T00:00:00Z",
"end_at": "2026-08-08T00:00:00Z",
"created_at": "2026-07-20T12:00:00Z",
"updated_at": "2026-07-20T12:00:00Z"
}
| Field | Type | Description |
|---|---|---|
format | string | banner, modal, inbox_only, card, or fullscreen. |
status | string | draft, live, paused, ended, or archived. |
content.title | string | Required. |
content.body | string | Required to publish. |
content.image_url | string | Optional image URL (see Assets). |
content.actions | array | Up to 4 CTAs. Each needs id and title; an empty url makes it a dismiss action. |
display.trigger | string | immediate, session_start, or event. |
display.trigger_event | string | Required when (and only when) trigger is event. |
display.frequency | string | once or once_per_session. |
display.priority | integer | 1–10. |
display.screens | array | Up to 20 route patterns, each starting with /. The SDKs only show the message on matching screens. |
audience.type | string | all, list, or segment; list_id / segment_id is required for the respective type. |
start_at / end_at | RFC3339 | Optional live window. end_at must be after start_at. |
notification_id | string | The send a publish produced; absent on drafts. |
variant_b | object | Optional A/B arm: title (required), body, image_url. |
split_percentage | integer | 1–99, the share receiving arm A (defaults to 50 when a B arm exists). |
holdout_percentage | integer | 0–99, share withheld for lift measurement. |
archived_at | RFC3339 | Set once archived. |
Display rules are evaluated client-side by the SDKs.
POST /api/v1/in-app/experiences
Creates a draft experience.
Request Body
{
"name": "Summer sale takeover",
"format": "modal",
"content": {
"title": "Summer sale",
"body": "Up to 40% off this week only.",
"actions": [{ "id": "shop", "title": "Shop now", "url": "https://example.com/sale" }]
},
"display": { "trigger": "immediate", "frequency": "once" }
}
name (at most 200 characters), format, and content.title are required;
display is optional.
Response — 201 Created
The experience object, with status: "draft".
400 names the invalid field; 401 on a missing or invalid bearer token.
GET /api/v1/in-app/experiences
Lists experiences (offset pagination).
Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
page | integer | 1 | 1-based page number. |
page_size | integer | 20 | Up to 100. |
status | string | — | Optional status filter (draft, live, paused, ended, archived). |
Response — 200 OK
{
"items": [],
"total": 12,
"page": 1,
"page_size": 20
}
items contains experience objects.
GET /api/v1/in-app/experiences/:id
Returns one experience. 404 when it does not exist.
PATCH /api/v1/in-app/experiences/:id
Partially edits a draft. Absent fields are unchanged; content,
display, and audience replace the whole block when provided. Editing a
non-draft returns 422.
Request Body
{
"audience": { "type": "list", "list_id": "ch_1111..." },
"start_at": "2026-08-01T00:00:00Z",
"end_at": "",
"variant_b": { "title": "Summer sale — B", "body": "40% off. Today." },
"split_percentage": 50,
"holdout_percentage": 10
}
| Field | Type | Description |
|---|---|---|
name, format, content, display, audience | — | As on create. |
start_at / end_at | string | RFC3339 to set; empty string to clear; omit to leave unchanged. |
variant_b | object | Replaces the B arm. |
remove_variant_b | boolean | Turns the A/B test off. |
split_percentage | integer | 1–99. |
holdout_percentage | integer | 0–99. |
Response — 200 OK
The updated experience.
Lifecycle
Four transition endpoints, each taking no request body and returning the
updated experience (200):
| Endpoint | Transition | Notes |
|---|---|---|
POST /api/v1/in-app/experiences/:id/publish | draft → live | Requires an audience and content.body (and variant_b.body for A/B). Enqueues the send — immediately, or at start_at if it is in the future. Refused when end_at is already past. |
POST /api/v1/in-app/experiences/:id/pause | live → paused | Delivered messages are hidden; an undelivered scheduled send is canceled. |
POST /api/v1/in-app/experiences/:id/resume | paused → live | Refused when end_at has passed — end the experience instead. |
POST /api/v1/in-app/experiences/:id/end | live/paused → ended | Terminal; recalls delivered messages. Idempotent. |
Invalid transitions return 400 with a message naming the current status.
POST /api/v1/in-app/experiences/:id/archive
Archives a draft or ended experience (idempotent). Live and paused
experiences must leave their active status first.
Response — 200 OK
The experience with status: "archived" and archived_at set.
POST /api/v1/in-app/experiences/:id/duplicate
Copies an experience into a new draft. Authored fields are kept; the live window and send linkage are not.
Request Body (optional)
{ "name": "Summer sale takeover v2" }
When omitted, a "Copy of ..." name is derived.
Response — 201 Created
The new draft experience.
GET /api/v1/in-app/experiences/:id/stats
The experience's engagement rollup.
Response — 200 OK
{
"experience_id": "3f7a1c9e-...",
"status": "live",
"impressions": 5120,
"unique_reach": 4980,
"clicks": 812,
"dismissals": 1204,
"ctr": 0.1586,
"dismiss_rate": 0.2352,
"by_variant": [
{ "variant_id": "a", "impressions": 2560, "clicks": 500, "dismissals": 600, "ctr": 0.1953 }
]
}
ctr and dismiss_rate are ratios over impressions (0 when there are no
impressions). by_variant is present for A/B experiences.
Assets
Image hosting for experience content, JWT-authenticated like the experience
endpoints. Uploaded assets get an immutable public URL to reference from
content.image_url.
POST /api/v1/in-app/assets
Multipart upload with a single file part. Returns 201:
{
"id": "b41d...",
"filename": "sale.png",
"content_type": "image/png",
"size_bytes": 183220,
"url": "https://storage.googleapis.com/.../sale.png",
"created_at": "2026-07-20T12:00:00Z"
}
400 when the file part is missing or the file exceeds the size limit.
GET /api/v1/in-app/assets
Offset-paginated list (page, page_size), returning
{ "items": [], "total": 0, "page": 1, "page_size": 20 } with asset objects
in items.
DELETE /api/v1/in-app/assets/:id
Deletes the metadata row and the stored object. 204 on success.
SDK delivery
The endpoints an app calls at runtime. Auth: X-SDK-Key header, or a JWT
bearer token. Every request identifies the end user with a subscriber_id
(the Hober subscriber UUID).
GET /v1/in-app/stream
Server-Sent Events stream of live in-app messages for one subscriber.
| Query parameter | Required | Description |
|---|---|---|
subscriber_id | Yes | The subscriber to stream messages for. |
The response is text/event-stream. Each data: frame carries one message
as JSON; a keepalive comment is sent every 30 seconds.
{
"id": "c9e3...",
"notification_id": "7d2b...",
"title": "Summer sale",
"body": "Up to 40% off this week only.",
"data": { "campaign": "summer" },
"sent_at": "2026-07-20T12:00:00Z",
"format": "modal",
"image_url": "https://cdn.example.com/sale.png",
"actions": [{ "id": "shop", "label": "Shop now", "url": "https://example.com/sale" }],
"display": { "trigger": "immediate", "frequency": "once" }
}
id is the durable inbox message id — use it to mark the message read or
dismissed, and to de-duplicate (redeliveries can emit the same message
twice). 503 when the live transport is unavailable; the durable inbox below
keeps working regardless.
GET /v1/in-app/messages
The durable inbox: messages delivered to a subscriber, newest first. Messages expire 30 days after delivery.
| Query parameter | Required | Description |
|---|---|---|
subscriber_id | Yes | The subscriber whose inbox to read. |
status | No | unread, read, or dismissed. |
limit | No | Default 50, up to 200. |
Response — 200 OK
{
"messages": [
{
"id": "c9e3...",
"notification_id": "7d2b...",
"content": {
"title": "Summer sale",
"body": "Up to 40% off this week only.",
"format": "modal"
},
"status": "unread",
"created_at": "2026-07-20T12:00:00Z",
"expires_at": "2026-08-19T12:00:00Z"
}
]
}
read_at is present once the message has been read. content carries the
delivered payload (title, body, and — for authored experiences — format,
image_url, actions, display).
POST /v1/in-app/messages/read
Marks all of a subscriber's messages read.
{ "subscriber_id": "8a2f6c1e-..." }
Response: 200 with { "updated": 7 }.
POST /v1/in-app/messages/:id/read
Marks one message read. Body: { "subscriber_id": "..." }. Returns the
updated message (200), 404 when the message does not exist for that
subscriber.
POST /v1/in-app/messages/:id/dismiss
Dismisses one message. Same body and responses as mark-read.
Engagement reporting
POST /api/v1/notifications/interactions
Records an end-user interaction. SDK key auth (X-SDK-Key) only. This is the
shared notification-interaction endpoint; the in-app interaction types feed
the experience stats above.
Request Body
{
"job_id": "7d2b...",
"interaction": "inapp_click",
"platform": "ios",
"action_id": "shop",
"subscriber_id": "8a2f6c1e-...",
"occurred_at": "2026-07-20T12:01:30Z"
}
| Field | Type | Required | Description |
|---|---|---|---|
job_id | string (UUID) | Yes | The notification_id carried in the message payload. |
interaction | string | Yes | For in-app: inapp_impression, inapp_click, or inapp_dismiss. |
platform | string | Yes | ios, android, web, email, or server. |
action_id | string | No | On inapp_click, names the tapped actions[] entry; omit for a plain body click. |
url | string | No | Destination URL for link clicks. |
subscriber_id | string | No | The interacting subscriber. |
device_id | string | No | The interacting device. |
variant_id | string | No | A/B variant attribution. |
occurred_at | RFC3339 | Yes | When the interaction happened. |
Response — 202 Accepted
Empty body. 422 on validation failure; 401 on a missing or invalid SDK
key.