Skip to content

Scope ​

The In-App admin routes are under /v1/apps/{app_id}/in-app-messages and accept an admin session or a REST key (Authorization: Bearer rk_...), so a backend or a script can manage messages without the dashboard. Every route is scoped to the App in the path. The field-by-field request and response shapes are in the generated In App API Reference.

Devices do not use these routes. The SDK-facing routes (eligible and events) use a Client key and are described in the In-App Messages Contract.

Create and activate ​

POST /v1/apps/{app_id}/in-app-messages creates a draft by default, or an active message when the body has "status": "active":

bash
curl -X POST https://your-instance/v1/apps/{app_id}/in-app-messages \
  -H "Authorization: Bearer $REST_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Example announcement",
    "format": "top",
    "title": "Example title",
    "body": "Example body.",
    "buttons": [
      { "label": "Open", "action": { "type": "deeplink", "value": "exampleapp://offers/42" } }
    ],
    "platforms": ["ios", "android"],
    "frequency": { "type": "once" }
  }'
  • Unknown fields are rejected. format is the layout: top, center (default in the dashboard), bottom or full. frequency is once, n_times (with max) or interval (with interval_seconds).
  • The image is either media_id (an upload from the Media API) or an image_url over https, never both.
  • Button ids are assigned by the server and stay stable across edits.
  • Links accept https and custom app schemes; http, javascript, data, file, vbscript, blob and about are rejected.
  • A button action is url, deeplink or push_permission. push_permission asks the device for the notification permission, takes no value (a value answers 422 invalid_content, with buttons[0].action.value: invalid_action_value in details.fields for the first button) and is not accepted on image_action. The server hides a message with this action from devices that already receive push and from devices on an SDK that does not support it yet; see the contract.
  • A rejected body answers 422 invalid_content with details.fields mapping each field to a code.

PATCH .../{id} updates content (an omitted key keeps its value, null clears media_id, image_url, starts_at, ends_at, style, image_action and dismiss_after_seconds; buttons, platforms, segment_ids, exclude_segment_ids, frequency, style, image_action and trigger are replaced whole, and null or [] empties the two segment lists). Status changes go through POST .../{id}/activate and POST .../{id}/pause. Edits to an active message reach devices on their next fetch. DELETE .../{id} soft-deletes the message: it leaves lists and devices, its metrics stay readable by id, and the segments it used can be deleted again.

Look, behavior and trigger ​

Four optional fields shape how the SDK shows the message. They are part of the same create and PATCH body:

json
{
  "format": "center",
  "style": {
    "background_color": "#102030",
    "text_color": "#FFFFFF",
    "button_color": "#E94B8B",
    "button_text_color": "#FFFFFF",
    "text_align": "center",
    "margin": true
  },
  "image_url": "https://cdn.example.com/offer.png",
  "image_action": { "type": "url", "value": "https://example.com/offer" },
  "dismiss_after_seconds": 8,
  "trigger": { "type": "event", "name": "checkout_done" }
}
  • style holds up to five tokens (background_color, text_color, button_color, button_text_color as #RRGGBB; text_align as left, center or right) and margin. The set is closed: an unknown key answers 422 invalid_content with style.<key>: unknown. A color that is not #RRGGBB answers style.<key>: invalid_color, an alignment outside the three values style.text_align: invalid_align. Colors are stored in upper case. style is null when unset, and a PATCH that sends an object replaces the whole style, so a token you leave out is cleared.
  • image_action makes the image tappable. It has the shape and the link rules of a button action and needs an image (image_action: requires_image otherwise). Clear the action together with the image.
  • dismiss_after_seconds closes the message after 1 to 300 seconds on screen (out_of_range otherwise). null keeps it until the user closes it.
  • trigger is {"type": "session_start"} (the default, and the value the API returns when none is set) or {"type": "event", "name": "..."} with a name of 1 to 64 characters from a-z, 0-9, _, . and -. Codes: trigger: invalid_type, trigger.name: required, invalid_name, too_long, and not_allowed for a name on a session_start trigger. "trigger": null on a PATCH sets session start.

GET /v1/apps/{app_id}/in-app-messages/trigger-events returns { "events": ["checkout_done", "..."] }: the distinct event names used by the App's messages that are not deleted, sorted, at most 50. Event names are not registered anywhere else; the list only feeds suggestions.

How the SDK uses these fields is in the In-App Messages Contract.

Targeting with segments ​

A message targets the same segments you use for push. segment_ids lists the segments it is shown to and exclude_segment_ids lists the ones whose devices never see it:

  • A device sees the message when it matches at least one segment in segment_ids, or when that list is empty (every device), and it matches none of the segments in exclude_segment_ids.
  • Each list holds up to 10 segments. A segment cannot repeat or appear in both lists. The fields answer 422 invalid_content with the codes invalid_id, duplicate_id, included_and_excluded or too_many.
  • A segment of another App, or one that does not exist, answers 404 segment_not_found.
  • A segment whose stored rules are no longer valid never matches. A message that includes it is not shown, and a message that excludes it is not shown either, so an unreadable exclusion never widens the audience.
  • A segment used by a message that is not deleted cannot be deleted (409 segment_in_use).

Plan limit ​

The plan limits how many messages can be active at once per App. Activating past the limit, by activate or by creating an active message, answers:

json
{ "error": { "code": "in_app_limit_exceeded", "message": "...", "details": { "reason": "in_app_limit_exceeded" } } }

with status 402. An inactive subscription answers 402 subscription_inactive. Drafts and paused messages do not use a slot, and a message past its ends_at stops counting.

Changing to a plan that allows fewer active messages than an App currently has is not rejected. When the change takes effect, Notti pauses the messages above the new limit, keeping the oldest ones active (by creation date). The pause is applied by a background check that runs about every 5 minutes, so it can lag the plan change slightly. Paused messages keep their content and metrics, and you can reactivate them while the App is under its limit.

List and filter ​

GET /v1/apps/{app_id}/in-app-messages returns messages newest first, with cursor pagination (limit 1 to 100, cursor from next_cursor).

  • status filters on the display status: all (default), draft, active, scheduled, paused or completed. scheduled is an active message whose starts_at is still in the future, and completed is an active message whose ends_at has passed. The stored status field is only draft, active or paused; each item also carries display_status.
  • search matches the message name (up to 200 characters).
  • Each item has all-time impressions, reach and ctr, and the response has active_count and active_limit (null when there is no limit).

Audience estimate ​

POST /v1/apps/{app_id}/in-app-messages/audience-estimate with { "segment_ids": ["..."], "exclude_segment_ids": ["..."], "platforms": ["ios"] } returns { "reach": N }: how many of the App's devices the audience matches now, with the same include and exclude rules as above. Leave segment_ids empty for all devices and platforms empty for every platform. Devices count whether or not they allow push.

Metrics ​

GET /v1/apps/{app_id}/in-app-messages/{id}/metrics?range=14d returns totals, a zero-filled daily series, per-button clicks (a row with button_id: "image" comes first when the message has an image action) and breakdowns. totals carries dismissers, the devices that closed the message themselves, and auto_dismissed, the devices that only saw it close on its timer. range is 7d, 14d (default), 30d, 90d, or custom with from and to (YYYY-MM-DD, to inclusive). Deleted, paused and ended messages stay readable. The metric definitions are in What the metrics mean.