# In-app messages

> Create, launch and pause in-app messages from your server, send a test and read the report.

Source: https://docs.grovs.io/docs/api-reference/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](https://docs.grovs.io/docs/engagement/in-app-messages).

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](https://docs.grovs.io/docs/api-reference/authentication) 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](https://docs.grovs.io/docs/api-reference/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.

**Endpoints**

- `GET /api/v1/sdk/server/in_app_campaigns`
- `POST /api/v1/sdk/server/in_app_campaigns`
- `GET /api/v1/sdk/server/in_app_campaigns/{campaign_id}`
- `PATCH /api/v1/sdk/server/in_app_campaigns/{campaign_id}`
- `DELETE /api/v1/sdk/server/in_app_campaigns/{campaign_id}`
- `POST /api/v1/sdk/server/in_app_campaigns/{campaign_id}/launch`
- `POST /api/v1/sdk/server/in_app_campaigns/{campaign_id}/pause`
- `POST /api/v1/sdk/server/in_app_campaigns/{campaign_id}/resume`
- `POST /api/v1/sdk/server/in_app_campaigns/{campaign_id}/end`
- `POST /api/v1/sdk/server/in_app_campaigns/{campaign_id}/test`
- `GET /api/v1/sdk/server/in_app_campaigns/{campaign_id}/report`

## The in-app message object

**Attributes**

- `id` (string): Hashid of the message.
- `name` (string): Internal label, up to 255 characters.
- `status` (enum): `draft`, `preparing` (a segment audience is being resolved), `scheduled`, `live`, `paused` or `ended`.
- `template` (enum): The format. See [The definition](#the-definition). Fixed after launch.
- `content` (object): What the message shows.
- `triggers` (array): What shows it.
- `conditions` (array): Checks on the device at trigger time.
- `caps` (object): How often one person sees it.
- `schedule` (object): Days and hours it may show, in device time.
- `priority` (integer): 1 (lowest) to 10. Default `1`.
- `delay_seconds` (integer): Wait after the trigger, 0 to 3600. Default `0`.
- `start_at` (string | null): ISO 8601. `null` starts at launch.
- `end_at` (string | null): ISO 8601. `null` runs until you end it.
- `audience` (object): `type` (`all` or `segment`), `segment_id`, `segment_definition_hash` and `segment_changed`, `true` when the segment was edited after launch.
- `revision` (integer): Goes up when a launched message changes what devices show.
- `lock_version` (integer): Send it back on update to refuse a save over someone else's change.
- `launched_at` (string | null): ISO 8601.
- `ended_at` (string | null): ISO 8601.
- `failure_reason` (string | null): Why a segment message went back from `preparing` to `draft`: `audience_busy`, `segment_invalid` or `audience_error`.
- `control_group` (integer | null): Percent of the audience held back, 1 to 50. Fixed after launch.
- `conversion` (object | null): The goal, such as `{ "event": "checkout", "window_hours": 72 }`. See [Experiments](https://docs.grovs.io/docs/engagement/experiments).
- `variants` (array | null): Up to two extra contents, `{ "id": "b" | "c", "content": {…} }`. The message's own content is variant `a`.
- `created_at` (string): ISO 8601.
- `updated_at` (string): ISO 8601.

**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](https://docs.grovs.io/docs/engagement/in-app-messages/create-a-message) writes.

**template**

- `template` (enum): `cover`, `interstitial` (default), `half_interstitial`, `header`, `footer`, `alert`, `cover_image`, `interstitial_image`, `half_interstitial_image` or `html`. See [Formats](https://docs.grovs.io/docs/engagement/in-app-messages#formats).

**content**

- `title` (string | null): Up to 256 characters; 80 for the `alert` template.
- `message` (string | null): Up to 1024 characters; 300 for `alert`. Title and message may use fields such as `{{ user.first_name | default: "there" }}`.
- `image` (object): `portrait` and `landscape`, each an `https://` URL or `null`.
- `buttons` (array): 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_action` (object | null): An action for a tap on the message itself.
- `colors` (object): `background`, `title`, `message` as `#RRGGBB` or `#RRGGBBAA`.
- `close_button` (boolean): Default `true`.
- `dismissible` (boolean): Close by tapping outside or swiping. Default `true`.
- `backdrop` (enum): `dim` (default) or `none`.
- `orientation` (enum): `any`, `portrait` or `landscape`.
- `question` (object | null): Stars or a choice. See [Surveys and ratings](https://docs.grovs.io/docs/engagement/in-app-messages/surveys-and-ratings).
- `html` (string): The page for the `html` template, up to 64 KB, with `layout`: `full`, `modal`, `banner_top` or `banner_bottom`. See [Custom HTML](https://docs.grovs.io/docs/engagement/in-app-messages/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**

- `triggers` (array): 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.
- `conditions` (array): 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`.
- `audience` (object): `{ "type": "all" }` or `{ "type": "segment", "segment_id": "gk3x" }` with an active [segment](https://docs.grovs.io/docs/api-reference/segments). Only a draft's audience can change.
- `caps` (object): `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).
- `schedule` (object): `days` (1 is Monday, 7 is Sunday), `from` and `to` as `HH:MM`, in each device's local time. `{}` for any time.

| Limit                                 | Value |
| ------------------------------------- | ----- |
| Definition size, without `html`       | 16 KB |
| `html` page                           | 64 KB |
| Triggers                              | 5     |
| Conditions                            | 10    |
| Buttons                               | 2     |
| Variants                              | 2     |
| Running messages per project          | 50    |
| Segments targeted at once per project | 20    |

**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.

Auth: Project key. See [Authentication](https://docs.grovs.io/docs/api-reference/authentication).

**Headers**

- `PROJECT-KEY` (string, required): Your project key.
- `ENVIRONMENT` (string, required): `production` or `test`.

**Query**

- `status` (enum): Only messages in this status.
- `limit` (integer): 1 to 200. Default `200`.
- `after` (string): `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**

| Status | Meaning                                                        |
| ------ | -------------------------------------------------------------- |
| 400    | Missing PROJECT-KEY, or ENVIRONMENT is not production or test. |
| 403    | Invalid credentials, or enterprise_required.                  |
| 422    | after is not a message ID, or limit is not an integer.         |
| 429    | rate_limited.                                                 |

**Request**

```bash
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"
```

**Response 200 OK**

```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](#launch-an-in-app-message) it.

Auth: Project key. See [Authentication](https://docs.grovs.io/docs/api-reference/authentication).

**Headers**

- `PROJECT-KEY` (string, required): Your project key.
- `ENVIRONMENT` (string, required): `production` or `test`.

**Body**

- `name` (string, required): Up to 255 characters.
- `template` (enum): Default `interstitial`.
- `content` (object): See [content](#the-definition).
- `triggers` (array): 1 to 5 triggers.
- `conditions` (array): Up to 10 device conditions.
- `audience` (object): Default `{ "type": "all" }`.
- `caps` (object): Frequency limits.
- `schedule` (object): Days and hours.
- `priority` (integer): 1 to 10.
- `delay_seconds` (integer): 0 to 3600.
- `start_at` (string): ISO 8601, such as `2026-11-01T09:00:00Z`.
- `end_at` (string): ISO 8601, after `start_at`.
- `control_group` (integer | null): 1 to 50.
- `conversion` (object | null): The goal.
- `variants` (array | null): Up to two `{ "id": "b" | "c", "content": {…} }`.

**Returns** `201` with `in_app_campaign`, an [in-app message object](#the-in-app-message-object).

**Errors**

| Status | Meaning                                                                                                                               |
| ------ | ------------------------------------------------------------------------------------------------------------------------------------- |
| 400    | Missing PROJECT-KEY, or ENVIRONMENT is not production or test.                                                                        |
| 403    | Invalid credentials, or enterprise_required.                                                                                         |
| 422    | A 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. |
| 429    | rate_limited.                                                                                                                        |

**Request**

```bash
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
}'
```

**Response 201 Created**

```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,
    "…": "…"
  }
}
```

**Response 422 Unprocessable Entity**

```json
{
  "error": "content must be an object"
}
```

## Retrieve an in-app message

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

Returns one message with its full content, `html` included.

Auth: Project key. See [Authentication](https://docs.grovs.io/docs/api-reference/authentication).

**Headers**

- `PROJECT-KEY` (string, required): Your project key.
- `ENVIRONMENT` (string, required): `production` or `test`.

**Path**

- `campaign_id` (string, required): The message's `id`.

**Returns** `200` with `in_app_campaign`, an [in-app message object](#the-in-app-message-object).

**Errors**

| Status | Meaning                                                            |
| ------ | ------------------------------------------------------------------ |
| 403    | Invalid credentials, or enterprise_required.                      |
| 404    | In-app campaign not found, or it belongs to the other environment. |

**Request**

```bash
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"
```

**Response 404 Not Found**

```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.

Auth: Project key. See [Authentication](https://docs.grovs.io/docs/api-reference/authentication).

**Headers**

- `PROJECT-KEY` (string, required): Your project key.
- `ENVIRONMENT` (string, required): `production` or `test`.

**Path**

- `campaign_id` (string, required): The message's `id`.

**Body**

- `lock_version` (integer): 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](#create-an-in-app-message) is accepted.

**Returns** `200` with `in_app_campaign`, an [in-app message object](#the-in-app-message-object).

**Errors**

| Status | Meaning                                                                                          |
| ------ | ------------------------------------------------------------------------------------------------ |
| 404    | In-app campaign not found.                                                                       |
| 409    | The message is ended, belongs to a journey, or changed since lock_version.                      |
| 422    | Invalid field, a definition that no longer launches, or The audience cannot change after launch. |
| 429    | rate_limited.                                                                                   |

**Request**

```bash
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"
}'
```

**Response 200 OK**

```json
{
  "in_app_campaign": {
    "id": "m7Kp2",
    "priority": 8,
    "end_at": "2026-11-30T23:59:59.000Z",
    "revision": 2,
    "lock_version": 4,
    "…": "…"
  }
}
```

**Response 409 Conflict**

```json
{
  "error": "Campaign changed while you were editing it; reload and retry"
}
```

## 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](#end-an-in-app-message) it instead to keep its report.

Auth: Project key. See [Authentication](https://docs.grovs.io/docs/api-reference/authentication).

**Headers**

- `PROJECT-KEY` (string, required): Your project key.
- `ENVIRONMENT` (string, required): `production` or `test`.

**Path**

- `campaign_id` (string, required): The message's `id`.

**Returns** `200` with a `message`.

**Errors**

| Status | Meaning                      |
| ------ | ---------------------------- |
| 404    | In-app campaign not found.   |
| 409    | Only a draft can be deleted. |

**Request**

```bash
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"
```

**Response 200 OK**

```json
{
  "message": "Deleted"
}
```

**Response 409 Conflict**

```json
{
  "error": "Only a draft can be 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.

Auth: Project key. See [Authentication](https://docs.grovs.io/docs/api-reference/authentication).

**Headers**

- `PROJECT-KEY` (string, required): Your project key.
- `ENVIRONMENT` (string, required): `production` or `test`.

**Path**

- `campaign_id` (string, required): A draft's `id`.

**Returns** `202` with `in_app_campaign`, an [in-app message object](#the-in-app-message-object).

**Errors**

| Status | Meaning                                                                                                              |
| ------ | -------------------------------------------------------------------------------------------------------------------- |
| 404    | In-app campaign not found.                                                                                           |
| 409    | Only a draft can be launched, or the message belongs to a journey.                                                   |
| 422    | Up to three problems with the definition, an archived or missing segment, or A project can run 50 campaigns at once. |

**Request**

```bash
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"
```

**Response 202 Accepted**

```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 },
    "…": "…"
  }
}
```

**Response 422 Unprocessable Entity**

```json
{
  "error": "The segment is archived"
}
```

## 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.

Auth: Project key. See [Authentication](https://docs.grovs.io/docs/api-reference/authentication).

**Headers**

- `PROJECT-KEY` (string, required): Your project key.
- `ENVIRONMENT` (string, required): `production` or `test`.

**Path**

- `campaign_id` (string, required): The message's `id`.

**Returns** `200` with the message, now `paused`.

**Errors**

| Status | Meaning                             |
| ------ | ----------------------------------- |
| 404    | In-app campaign not found.          |
| 409    | Only a live campaign can be paused. |

**Request**

```bash
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"
```

**Response 200 OK**

```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`.

Auth: Project key. See [Authentication](https://docs.grovs.io/docs/api-reference/authentication).

**Headers**

- `PROJECT-KEY` (string, required): Your project key.
- `ENVIRONMENT` (string, required): `production` or `test`.

**Path**

- `campaign_id` (string, required): The message's `id`.

**Returns** `200` with the message, now `live`.

**Errors**

| Status | Meaning                                |
| ------ | -------------------------------------- |
| 404    | In-app campaign not found.             |
| 409    | Only a paused campaign can be resumed. |

**Request**

```bash
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"
```

**Response 200 OK**

```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.

Auth: Project key. See [Authentication](https://docs.grovs.io/docs/api-reference/authentication).

**Headers**

- `PROJECT-KEY` (string, required): Your project key.
- `ENVIRONMENT` (string, required): `production` or `test`.

**Path**

- `campaign_id` (string, required): The message's `id`.

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

**Errors**

| Status | Meaning                                |
| ------ | -------------------------------------- |
| 404    | In-app campaign not found.             |
| 409    | Only a launched campaign can be ended. |

**Request**

```bash
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"
```

**Response 200 OK**

```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.

Auth: Project key. See [Authentication](https://docs.grovs.io/docs/api-reference/authentication).

**Headers**

- `PROJECT-KEY` (string, required): Your project key.
- `ENVIRONMENT` (string, required): `production` or `test`.

**Path**

- `campaign_id` (string, required): The message's `id`.

**Body**

- `identifier` (string, required): The user ID your app sets through the SDK.

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

**Errors**

| Status | Meaning                                                                                                              |
| ------ | -------------------------------------------------------------------------------------------------------------------- |
| 404    | In-app campaign not found.                                                                                           |
| 422    | identifier is required, Nobody in this project has that identifier, or the message is not complete enough to launch. |

**Request**

```bash
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" }'
```

**Response 202 Accepted**

```json
{
  "devices": 2
}
```

**Response 422 Unprocessable Entity**

```json
{
  "error": "Nobody in this project has that identifier"
}
```

## 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](https://docs.grovs.io/docs/engagement/in-app-messages/reporting). Answers are cached for 60 seconds.

Auth: Project key. See [Authentication](https://docs.grovs.io/docs/api-reference/authentication).

**Headers**

- `PROJECT-KEY` (string, required): Your project key.
- `ENVIRONMENT` (string, required): `production` or `test`.

**Path**

- `campaign_id` (string, required): The message's `id`.

**Query**

- `from` (string): `YYYY-MM-DD`, UTC. Default: the launch day, at most 91 days back.
- `to` (string): `YYYY-MM-DD`, UTC. Default: today. The range covers at most 92 days.
- `by` (enum): `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**

| Status | Meaning                                                                               |
| ------ | ------------------------------------------------------------------------------------- |
| 404    | In-app campaign not found.                                                            |
| 422    | from and to must be dates, the range must be 1 to 92 days, or by must be day or hour. |
| 503    | clickhouse_unavailable.                                                              |

**Request**

```bash
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"
```

**Response 200 OK**

```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
  }
}
```
