Docs
grovs.io Start for free
Examples for

In-app messages

Drive the whole lifecycle of a message from code: draft it, send a test, launch it, pause or end it, and read its numbers. What a message can show and when is explained in the In-app messages guide.

These endpoints live on the SDK host: https://sdk.sqd.link on grovs Cloud, https://sdk.<SERVER_HOST> when you self-host. Send your project key in the PROJECT-KEY header. Send production or test in the ENVIRONMENT header. Each environment has its own messages.

Availability: an active paid plan on grovs Cloud. Always on for self-hosted Enterprise. Without it, every call answers 403 with error_code: "enterprise_required". Reports need ClickHouse.

Rate limit: 120 requests a minute per project key, shared with segments. Over it, the answer is 429 with error_code: "rate_limited" and a Retry-After header.

Images: put https:// image URLs in content.image. The server API takes no file upload.

Dashboard only: image and media uploads, survey answers and comments, experiment results, duplicate, audience update and project settings.

IDs: message IDs are hashids, such as m7Kp2. Journey steps are not listed and cannot be changed here.

The in-app message object

Attributes
idstring

Hashid of the message.

namestring

Internal label, up to 255 characters.

statusenum

draft, preparing (a segment audience is being resolved), scheduled, live, paused or ended.

templateenum

The format. See The definition. Fixed after launch.

contentobject

What the message shows.

triggersarray

What shows it.

conditionsarray

Checks on the device at trigger time.

capsobject

How often one person sees it.

scheduleobject

Days and hours it may show, in device time.

priorityinteger

1 (lowest) to 10. Default 1.

delay_secondsinteger

Wait after the trigger, 0 to 3600. Default 0.

start_atstring | null

ISO 8601. null starts at launch.

end_atstring | null

ISO 8601. null runs until you end it.

audienceobject

type (all or segment), segment_id, segment_definition_hash and segment_changed, true when the segment was edited after launch.

revisioninteger

Goes up when a launched message changes what devices show.

lock_versioninteger

Send it back on update to refuse a save over someone else's change.

launched_atstring | null

ISO 8601.

ended_atstring | null

ISO 8601.

failure_reasonstring | null

Why a segment message went back from preparing to draft: audience_busy, segment_invalid or audience_error.

control_groupinteger | null

Percent of the audience held back, 1 to 50. Fixed after launch.

conversionobject | null

The goal, such as { "event": "checkout", "window_hours": 72 }. See Experiments.

variantsarray | null

Up to two extra contents, { "id": "b" | "c", "content": {…} }. The message's own content is variant a.

created_atstring

ISO 8601.

updated_atstring

ISO 8601.

JSON
The in-app message object
JSON
{
  "id": "m7Kp2",
  "name": "Plans nudge",
  "status": "live",
  "template": "interstitial",
  "content": {
    "title": "Your plan is one tap away",
    "message": "Unlimited video visits from 9.99 a month.",
    "image": { "portrait": "https://cdn.medly.health/inapp/plans.png", "landscape": null },
    "buttons": [
      { "id": "see_plans", "text": "See plans", "action": { "type": "open_url", "url": "medly://plans" } }
    ],
    "close_button": true,
    "backdrop": "dim"
  },
  "triggers": [{ "type": "screen_view", "name": "Plans" }],
  "conditions": [
    { "type": "event_count", "name": "plans_viewed", "op": "gt", "value": 2 },
    { "type": "user_attribute", "key": "subscribed", "op": "neq", "value": true }
  ],
  "caps": { "session": 1, "day": 1, "week": null, "lifetime": 3, "ignore_global": false },
  "schedule": {},
  "priority": 5,
  "delay_seconds": 2,
  "start_at": null,
  "end_at": null,
  "audience": { "type": "all", "segment_id": null, "segment_definition_hash": null, "segment_changed": false },
  "revision": 1,
  "lock_version": 3,
  "launched_at": "2026-10-22T09:30:00.000Z",
  "ended_at": null,
  "failure_reason": null,
  "control_group": null,
  "conversion": null,
  "variants": null,
  "created_at": "2026-10-22T09:00:00.000Z",
  "updated_at": "2026-10-22T09:30:00.000Z"
}

The definition

A draft may be incomplete. Launch, and every save after it, checks the whole definition. The format is the one the composer writes.

template
templateenum

cover, interstitial (default), half_interstitial, header, footer, alert, cover_image, interstitial_image, half_interstitial_image or html. See Formats.

content
titlestring | null

Up to 256 characters; 80 for the alert template.

messagestring | null

Up to 1024 characters; 300 for alert. Title and message may use fields such as {{ user.first_name | default: "there" }}.

imageobject

portrait and landscape, each an https:// URL or null.

buttonsarray

Up to two { "id", "text", "action", "colors" }. id is 1 to 16 of a-z, 0-9, _. text is up to 64 characters; 30 for alert.

tap_actionobject | null

An action for a tap on the message itself.

colorsobject

background, title, message as #RRGGBB or #RRGGBBAA.

close_buttonboolean

Default true.

dismissibleboolean

Close by tapping outside or swiping. Default true.

backdropenum

dim (default) or none.

orientationenum

any, portrait or landscape.

questionobject | null

Stars or a choice. See Surveys and ratings.

htmlstring

The page for the html template, up to 64 KB, with layout: full, modal, banner_top or banner_bottom. See Custom HTML.

An action is { "type": "close" }, { "type": "open_url", "url": "…" }, { "type": "key_value", "payload": {…} } (up to 20 keys), or request_push_permission, request_app_rating, open_store (apps only).

Targeting and timing
triggersarray

1 to 5. Any one shows the message. { "type": "app_open" }, { "type": "screen_view", "name": "Plans" } (omit name for any screen) or { "type": "event", "name": "cart_viewed", "filters": [...] } with up to 5 filters.

conditionsarray

Up to 10, all must hold. type is platform, language, app_version, os_version, event_count, user_attribute or first_seen, with op and value.

audienceobject

{ "type": "all" } or { "type": "segment", "segment_id": "gk3x" } with an active segment. Only a draft's audience can change.

capsobject

session, day, week, lifetime (views per person, null for no cap; day and week up to 20) and ignore_global (not held back by the project limit).

scheduleobject

days (1 is Monday, 7 is Sunday), from and to as HH:MM, in each device's local time. {} for any time.

LimitValue
Definition size, without html16 KB
html page64 KB
Triggers5
Conditions10
Buttons2
Variants2
Running messages per project50
Segments targeted at once per project20
JSON
A minimal message
JSON
{
  "name": "Welcome",
  "template": "interstitial",
  "content": {
    "title": "Hello",
    "message": "Welcome to Medly",
    "image": { "portrait": "https://cdn.medly.health/inapp/welcome.png", "landscape": null }
  },
  "triggers": [{ "type": "app_open" }]
}

List in-app messages

GET/api/v1/sdk/server/in_app_campaigns

Returns messages newest first, a page at a time. Journey steps are left out.

AuthProject keyAuthentication ›
Headers
PROJECT-KEYstringRequired

Your project key.

ENVIRONMENTstringRequired

production or test.

Query
statusenum

Only messages in this status.

limitinteger

1 to 200. Default 200.

afterstring

next_after from the previous page.

Returns 200 with in_app_campaigns, truncated (true when there is another page) and next_after. List rows leave out content.html.

Errors

400Missing PROJECT-KEY, or ENVIRONMENT is not production or test.
403Invalid credentials, or enterprise_required.
422after is not a message ID, or limit is not an integer.
429rate_limited.
Request · cURL
curl -X GET "https://sdk.sqd.link/api/v1/sdk/server/in_app_campaigns?status=live&limit=50" \
  -H "PROJECT-KEY: $GROVS_PROJECT_KEY" \
  -H "ENVIRONMENT: production"
JSON
{
  "in_app_campaigns": [
    {
      "id": "m7Kp2",
      "name": "Plans nudge",
      "status": "live",
      "template": "interstitial",
      "…": "…"
    }
  ],
  "truncated": true,
  "next_after": "m7Kp2"
}

Create an in-app message

POST/api/v1/sdk/server/in_app_campaigns

Saves a new message as a draft. Nothing reaches devices until you launch it.

AuthProject keyAuthentication ›
Headers
PROJECT-KEYstringRequired

Your project key.

ENVIRONMENTstringRequired

production or test.

Body
namestringRequired

Up to 255 characters.

templateenum

Default interstitial.

contentobject

See content.

triggersarray

1 to 5 triggers.

conditionsarray

Up to 10 device conditions.

audienceobject

Default { "type": "all" }.

capsobject

Frequency limits.

scheduleobject

Days and hours.

priorityinteger

1 to 10.

delay_secondsinteger

0 to 3600.

start_atstring

ISO 8601, such as 2026-11-01T09:00:00Z.

end_atstring

ISO 8601, after start_at.

control_groupinteger | null

1 to 50.

conversionobject | null

The goal.

variantsarray | null

Up to two { "id": "b" | "c", "content": {…} }.

Returns 201 with in_app_campaign, an in-app message object.

Errors

400Missing PROJECT-KEY, or ENVIRONMENT is not production or test.
403Invalid credentials, or enterprise_required.
422A field has the wrong shape or is out of range, such as content must be an object or triggers must be a list. Also Segment not found.
429rate_limited.
Request · cURL
curl -X POST "https://sdk.sqd.link/api/v1/sdk/server/in_app_campaigns" \
  -H "PROJECT-KEY: $GROVS_PROJECT_KEY" \
  -H "ENVIRONMENT: production" \
  -H "Content-Type: application/json" \
  -d '{
  "name": "Plans nudge",
  "template": "interstitial",
  "content": {
    "title": "Your plan is one tap away",
    "message": "Unlimited video visits from 9.99 a month.",
    "image": {
      "portrait": "https://cdn.medly.health/inapp/plans.png",
      "landscape": null
    },
    "buttons": [
      {
        "id": "see_plans",
        "text": "See plans",
        "action": { "type": "open_url", "url": "medly://plans" }
      }
    ]
  },
  "triggers": [
    { "type": "screen_view", "name": "Plans" }
  ],
  "conditions": [
    {
      "type": "event_count",
      "name": "plans_viewed",
      "op": "gt",
      "value": 2
    }
  ],
  "audience": { "type": "segment", "segment_id": "gk3x" },
  "caps": {
    "session": 1,
    "day": 1,
    "week": null,
    "lifetime": 3,
    "ignore_global": false
  },
  "priority": 5,
  "delay_seconds": 2
}'
JSON
{
  "in_app_campaign": {
    "id": "m7Kp2",
    "name": "Plans nudge",
    "status": "draft",
    "revision": 1,
    "lock_version": 0,
    "audience": { "type": "segment", "segment_id": "gk3x", "segment_definition_hash": null, "segment_changed": false },
    "launched_at": null,
    "…": "…"
  }
}

Retrieve an in-app message

GET/api/v1/sdk/server/in_app_campaigns/{campaign_id}

Returns one message with its full content, html included.

AuthProject keyAuthentication ›
Headers
PROJECT-KEYstringRequired

Your project key.

ENVIRONMENTstringRequired

production or test.

Path
campaign_idstringRequired

The message's id.

Returns 200 with in_app_campaign, an in-app message object.

Errors

403Invalid credentials, or enterprise_required.
404In-app campaign not found, or it belongs to the other environment.
Request · cURL
curl -X GET "https://sdk.sqd.link/api/v1/sdk/server/in_app_campaigns/{campaign_id}" \
  -H "PROJECT-KEY: $GROVS_PROJECT_KEY" \
  -H "ENVIRONMENT: production"
JSON
{
  "error": "In-app campaign not found"
}

Update an in-app message

PATCH/api/v1/sdk/server/in_app_campaigns/{campaign_id}

Changes only the fields you send. Each field replaces the stored one whole, so send all of content to change one part of it. On a launched message, devices pick up the change at their next sync and revision goes up.

After launch, template, audience, control_group and the number of variants are fixed. An ended message cannot be edited.

AuthProject keyAuthentication ›
Headers
PROJECT-KEYstringRequired

Your project key.

ENVIRONMENTstringRequired

production or test.

Path
campaign_idstringRequired

The message's id.

Body
lock_versioninteger

The lock_version you read. If the message changed since, the save is refused with 409.

Any body field of Create an in-app message is accepted.

Returns 200 with in_app_campaign, an in-app message object.

Errors

404In-app campaign not found.
409The message is ended, belongs to a journey, or changed since lock_version.
422Invalid field, a definition that no longer launches, or The audience cannot change after launch.
429rate_limited.
Request · cURL
curl -X PATCH "https://sdk.sqd.link/api/v1/sdk/server/in_app_campaigns/{campaign_id}" \
  -H "PROJECT-KEY: $GROVS_PROJECT_KEY" \
  -H "ENVIRONMENT: production" \
  -H "Content-Type: application/json" \
  -d '{
  "lock_version": 3,
  "priority": 8,
  "end_at": "2026-11-30T23:59:59Z"
}'
JSON
{
  "in_app_campaign": {
    "id": "m7Kp2",
    "priority": 8,
    "end_at": "2026-11-30T23:59:59.000Z",
    "revision": 2,
    "lock_version": 4,
    "…": "…"
  }
}

Delete a draft

DELETE/api/v1/sdk/server/in_app_campaigns/{campaign_id}

Deletes a message that was never launched. A launched message cannot be deleted; end it instead to keep its report.

AuthProject keyAuthentication ›
Headers
PROJECT-KEYstringRequired

Your project key.

ENVIRONMENTstringRequired

production or test.

Path
campaign_idstringRequired

The message's id.

Returns 200 with a message.

Errors

404In-app campaign not found.
409Only a draft can be deleted.
Request · cURL
curl -X DELETE "https://sdk.sqd.link/api/v1/sdk/server/in_app_campaigns/{campaign_id}" \
  -H "PROJECT-KEY: $GROVS_PROJECT_KEY" \
  -H "ENVIRONMENT: production"
JSON
{
  "message": "Deleted"
}

Launch an in-app message

POST/api/v1/sdk/server/in_app_campaigns/{campaign_id}/launch

Checks the whole definition and starts delivery. An all audience goes live, or scheduled when start_at is in the future. A segment audience goes preparing while its members are written, then live, or back to draft with a failure_reason. The audience is frozen at launch.

AuthProject keyAuthentication ›
Headers
PROJECT-KEYstringRequired

Your project key.

ENVIRONMENTstringRequired

production or test.

Path
campaign_idstringRequired

A draft's id.

Returns 202 with in_app_campaign, an in-app message object.

Errors

404In-app campaign not found.
409Only a draft can be launched, or the message belongs to a journey.
422Up to three problems with the definition, an archived or missing segment, or A project can run 50 campaigns at once.
Request · cURL
curl -X POST "https://sdk.sqd.link/api/v1/sdk/server/in_app_campaigns/{campaign_id}/launch" \
  -H "PROJECT-KEY: $GROVS_PROJECT_KEY" \
  -H "ENVIRONMENT: production"
JSON
{
  "in_app_campaign": {
    "id": "m7Kp2",
    "status": "preparing",
    "launched_at": "2026-10-22T09:30:00.000Z",
    "audience": { "type": "segment", "segment_id": "gk3x", "segment_definition_hash": "a1b2c3d4…", "segment_changed": false },
    "…": "…"
  }
}

Pause an in-app message

POST/api/v1/sdk/server/in_app_campaigns/{campaign_id}/pause

Stops delivery of a live message. Devices in use stop showing it usually within a minute, and at most about 15 minutes on an idle device.

AuthProject keyAuthentication ›
Headers
PROJECT-KEYstringRequired

Your project key.

ENVIRONMENTstringRequired

production or test.

Path
campaign_idstringRequired

The message's id.

Returns 200 with the message, now paused.

Errors

404In-app campaign not found.
409Only a live campaign can be paused.
Request · cURL
curl -X POST "https://sdk.sqd.link/api/v1/sdk/server/in_app_campaigns/{campaign_id}/pause" \
  -H "PROJECT-KEY: $GROVS_PROJECT_KEY" \
  -H "ENVIRONMENT: production"
JSON
{
  "in_app_campaign": { "id": "m7Kp2", "status": "paused", "…": "…" }
}

Resume an in-app message

POST/api/v1/sdk/server/in_app_campaigns/{campaign_id}/resume

Puts a paused message back to live.

AuthProject keyAuthentication ›
Headers
PROJECT-KEYstringRequired

Your project key.

ENVIRONMENTstringRequired

production or test.

Path
campaign_idstringRequired

The message's id.

Returns 200 with the message, now live.

Errors

404In-app campaign not found.
409Only a paused campaign can be resumed.
Request · cURL
curl -X POST "https://sdk.sqd.link/api/v1/sdk/server/in_app_campaigns/{campaign_id}/resume" \
  -H "PROJECT-KEY: $GROVS_PROJECT_KEY" \
  -H "ENVIRONMENT: production"
JSON
{
  "in_app_campaign": { "id": "m7Kp2", "status": "live", "…": "…" }
}

End an in-app message

POST/api/v1/sdk/server/in_app_campaigns/{campaign_id}/end

Stops a launched message for good. The report stays readable. An ended message cannot be edited or relaunched.

AuthProject keyAuthentication ›
Headers
PROJECT-KEYstringRequired

Your project key.

ENVIRONMENTstringRequired

production or test.

Path
campaign_idstringRequired

The message's id.

Returns 200 with the message, now ended, and ended_at set.

Errors

404In-app campaign not found.
409Only a launched campaign can be ended.
Request · cURL
curl -X POST "https://sdk.sqd.link/api/v1/sdk/server/in_app_campaigns/{campaign_id}/end" \
  -H "PROJECT-KEY: $GROVS_PROJECT_KEY" \
  -H "ENVIRONMENT: production"
JSON
{
  "in_app_campaign": {
    "id": "m7Kp2",
    "status": "ended",
    "ended_at": "2026-11-02T17:45:10.000Z",
    "…": "…"
  }
}

Send a test

POST/api/v1/sdk/server/in_app_campaigns/{campaign_id}/test

Shows the last saved version of a message to one person, in any status. It reaches each of their devices, up to 50, at the next sync within 30 minutes. Triggers, conditions, limits and schedule are ignored. It shows once and is not counted in the report.

AuthProject keyAuthentication ›
Headers
PROJECT-KEYstringRequired

Your project key.

ENVIRONMENTstringRequired

production or test.

Path
campaign_idstringRequired

The message's id.

Body
identifierstringRequired

The user ID your app sets through the SDK.

Returns 202 with devices, the number of devices that will get it.

Errors

404In-app campaign not found.
422identifier is required, Nobody in this project has that identifier, or the message is not complete enough to launch.
Request · cURL
curl -X POST "https://sdk.sqd.link/api/v1/sdk/server/in_app_campaigns/{campaign_id}/test" \
  -H "PROJECT-KEY: $GROVS_PROJECT_KEY" \
  -H "ENVIRONMENT: production" \
  -H "Content-Type: application/json" \
  -d '{ "identifier": "member-4471" }'
JSON
{
  "devices": 2
}

Get the report

GET/api/v1/sdk/server/in_app_campaigns/{campaign_id}/report

Returns views, clicks and dismissals for a date range, with a trend and splits by platform and button. What counts as each is in Reporting. Answers are cached for 60 seconds.

AuthProject keyAuthentication ›
Headers
PROJECT-KEYstringRequired

Your project key.

ENVIRONMENTstringRequired

production or test.

Path
campaign_idstringRequired

The message's id.

Query
fromstring

YYYY-MM-DD, UTC. Default: the launch day, at most 91 days back.

tostring

YYYY-MM-DD, UTC. Default: today. The range covers at most 92 days.

byenum

day (default) or hour. The trend bucket.

Returns 200 with report:

  • totals: views, clicks, dismissals, unique_viewers, unique_clickers, click_rate.
  • trend and platforms: the same counts per bucket and per platform.
  • buttons: clicks per button_id. null is a tap on the message itself.
  • eligible_devices: devices in a segment audience. null for all.
  • answers: survey results. null for a message that asks no question.

Errors

404In-app campaign not found.
422from and to must be dates, the range must be 1 to 92 days, or by must be day or hour.
503clickhouse_unavailable.
Request · cURL
curl -X GET "https://sdk.sqd.link/api/v1/sdk/server/in_app_campaigns/{campaign_id}/report?from=2026-10-22&to=2026-10-28" \
  -H "PROJECT-KEY: $GROVS_PROJECT_KEY" \
  -H "ENVIRONMENT: production"
JSON
{
  "report": {
    "totals": {
      "views": 1840,
      "clicks": 412,
      "dismissals": 960,
      "unique_viewers": 1502,
      "unique_clickers": 398,
      "click_rate": 0.2239
    },
    "trend": [
      { "bucket": "2026-10-22", "views": 310, "clicks": 74, "dismissals": 158 }
    ],
    "platforms": [
      { "platform": "ios", "views": 1210, "clicks": 290, "dismissals": 611 },
      { "platform": "web", "views": 630, "clicks": 122, "dismissals": 349 }
    ],
    "buttons": [
      { "button_id": "see_plans", "clicks": 371 },
      { "button_id": null, "clicks": 41 }
    ],
    "eligible_devices": 8420,
    "answers": null
  }
}
Was this page helpful?
Last updated 2026-10-26