Skip to main content

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-Key header); 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 draftlive → (pausedlive) → endedarchived; 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"
}
FieldTypeDescription
formatstringbanner, modal, inbox_only, card, or fullscreen.
statusstringdraft, live, paused, ended, or archived.
content.titlestringRequired.
content.bodystringRequired to publish.
content.image_urlstringOptional image URL (see Assets).
content.actionsarrayUp to 4 CTAs. Each needs id and title; an empty url makes it a dismiss action.
display.triggerstringimmediate, session_start, or event.
display.trigger_eventstringRequired when (and only when) trigger is event.
display.frequencystringonce or once_per_session.
display.priorityinteger1–10.
display.screensarrayUp to 20 route patterns, each starting with /. The SDKs only show the message on matching screens.
audience.typestringall, list, or segment; list_id / segment_id is required for the respective type.
start_at / end_atRFC3339Optional live window. end_at must be after start_at.
notification_idstringThe send a publish produced; absent on drafts.
variant_bobjectOptional A/B arm: title (required), body, image_url.
split_percentageinteger1–99, the share receiving arm A (defaults to 50 when a B arm exists).
holdout_percentageinteger0–99, share withheld for lift measurement.
archived_atRFC3339Set 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

ParameterTypeDefaultDescription
pageinteger11-based page number.
page_sizeinteger20Up to 100.
statusstringOptional 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
}
FieldTypeDescription
name, format, content, display, audienceAs on create.
start_at / end_atstringRFC3339 to set; empty string to clear; omit to leave unchanged.
variant_bobjectReplaces the B arm.
remove_variant_bbooleanTurns the A/B test off.
split_percentageinteger1–99.
holdout_percentageinteger0–99.

Response — 200 OK

The updated experience.


Lifecycle

Four transition endpoints, each taking no request body and returning the updated experience (200):

EndpointTransitionNotes
POST /api/v1/in-app/experiences/:id/publishdraft → liveRequires 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/pauselive → pausedDelivered messages are hidden; an undelivered scheduled send is canceled.
POST /api/v1/in-app/experiences/:id/resumepaused → liveRefused when end_at has passed — end the experience instead.
POST /api/v1/in-app/experiences/:id/endlive/paused → endedTerminal; 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 parameterRequiredDescription
subscriber_idYesThe 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 parameterRequiredDescription
subscriber_idYesThe subscriber whose inbox to read.
statusNounread, read, or dismissed.
limitNoDefault 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"
}
FieldTypeRequiredDescription
job_idstring (UUID)YesThe notification_id carried in the message payload.
interactionstringYesFor in-app: inapp_impression, inapp_click, or inapp_dismiss.
platformstringYesios, android, web, email, or server.
action_idstringNoOn inapp_click, names the tapped actions[] entry; omit for a plain body click.
urlstringNoDestination URL for link clicks.
subscriber_idstringNoThe interacting subscriber.
device_idstringNoThe interacting device.
variant_idstringNoA/B variant attribution.
occurred_atRFC3339YesWhen the interaction happened.

Response — 202 Accepted

Empty body. 422 on validation failure; 401 on a missing or invalid SDK key.