# Server push

> Send transactional push notifications from your server and track their delivery.

Source: https://docs.grovs.io/docs/api-reference/server-push

Send a push to your users, a list of devices or a segment, then read its delivery report. Campaigns built in the dashboard are covered in [Push campaigns](https://docs.grovs.io/docs/engagement/push-campaigns).

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.

**Availability:** an active paid plan on grovs Cloud. Always on for self-hosted Enterprise. Segment audiences need ClickHouse.

**Rate limit:** each project has a budget per minute of 600 campaigns, 10 segment campaigns and 100,000 list recipients. A refused request creates nothing and uses no budget.

**IDs:** campaign and segment IDs are hashids, such as `k9Qx2`. `link_id` is the numeric link ID from [Get link details](https://docs.grovs.io/docs/api-reference/links#get-link-details).

**Endpoints**

- `POST /api/v1/sdk/server/push`
- `GET /api/v1/sdk/server/push/{campaign_id}`

## The push campaign object

**Attributes**

- `id` (string): Hashid of the campaign.
- `name` (string): Internal label, up to 255 characters. Defaults to the title.
- `title` (string): Notification title, up to 255 characters.
- `body` (string): Notification text, up to 1000 characters. The whole APNs payload must fit in 4096 bytes, so a long body with a long `image_url` or `data` is refused with `422`.
- `image_url` (string | null): An `https://` image URL, up to 2048 characters. Use PNG, JPEG or GIF. iOS needs a Notification Service Extension in your app to show it.
- `image` (string | null): Read-only. The image the push carries.
- `sound` (string | null): `default` or the name of a sound file bundled in your app, up to 64 characters.
- `link_id` (integer | null): A grovs link of this project. Its URL travels in the payload as `grovs_link`.
- `data` (object): Custom keys delivered in the payload, at most 2048 bytes as JSON. `grovs_campaign_id`, `grovs_link` and `grovs_image` are overwritten. `aps`, `from`, `notification`, `message_type` and keys starting with `google`, `gcm` or `__rpush_` are refused.
- `source` (enum): `api` for campaigns sent through this API.
- `status` (enum): `scheduled`, `evaluating`, `sending`, `sent`, `failed` or `cancelled`.
- `audience` (object): `type`, `recipients_count`, `segment_id`, `segment_definition_hash` and `recipients`.
- `scheduled_at` (string | null): When the campaign goes out, ISO 8601.
- `local_time` (boolean): Deliver at the wall-clock time of `scheduled_at` in each device's own timezone.
- `timezone` (string): IANA zone the wall-clock is read in, and the fallback for devices with no timezone. Defaults to `UTC`.
- `report` (object): Delivery counters. See below.
- `evaluated_at` (string | null): When the audience was frozen.
- `started_at` (string | null): When sending started.
- `finished_at` (string | null): When every device had been handed off, or the campaign was cancelled.
- `failure_reason` (string | null): For a `failed` campaign: `segment_busy`, `segment_invalid`, `query_too_heavy` or `evaluation_error`.
- `created_at` (string): ISO 8601.
- `updated_at` (string): ISO 8601.

**report**

- `targeted` (integer): Every resolved device, held people excluded.
- `no_channel` (integer): Devices with no push token, or on web or desktop. Skipped.
- `not_found` (integer): Recipients that matched no visitor, malformed UUIDs included.
- `queued` (integer): Handed to the push process.
- `delivered` (integer): Accepted by APNs or FCM. Not the same as shown on screen.
- `failed` (integer): Rejected or not sent. `error_counts` gives the reasons.
- `opened` (integer): Devices whose SDK reported a tap, once per device.
- `cancelled` (integer): Devices dropped by a cancel before they were handed off.
- `held` (integer): People kept back as a control. Always `0` for server pushes.
- `waiting` (integer): People waiting for an A/B winner. Always `0` for server pushes.
- `error_counts` (object): Failures by reason. APNs reasons keep their name (`Unregistered`, `BadDeviceToken`). FCM failures are keyed by HTTP status (`404`). `capped` counts pushes over the project's daily cap; `no_app` and `no_token` count devices that could not be sent to.
- `audience` (object): `persons`, `devices` and `incomplete` for a segment audience. `incomplete` is `true` when a segment window reached past your data retention.

Per-device rows are kept for 30 days. The counters stay.

The object also has A/B test and goal fields. They are `null` for server pushes.

**The push campaign object**

```json
{
  "id": "k9Qx2",
  "name": "Flu shot reminder",
  "title": "Flu season is here",
  "body": "Book your flu shot in two taps.",
  "image_url": "https://cdn.medly.health/push/flu.png",
  "image": "https://cdn.medly.health/push/flu.png",
  "sound": "default",
  "link_id": 4821,
  "data": { "screen": "book", "service": "flu_shot" },
  "source": "api",
  "status": "sent",
  "scheduled_at": null,
  "local_time": false,
  "timezone": "UTC",
  "evaluated_at": "2026-10-22T09:12:05.000Z",
  "started_at": "2026-10-22T09:12:04.000Z",
  "finished_at": "2026-10-22T09:20:41.000Z",
  "failure_reason": null,
  "created_at": "2026-10-21T15:02:11.000Z",
  "updated_at": "2026-10-22T09:20:41.000Z",
  "control_group": null,
  "conversion": null,
  "variants": null,
  "test_share": null,
  "winner_after_minutes": null,
  "winner_by": null,
  "winner_due_at": null,
  "winner": null,
  "audience": {
    "type": "segment",
    "recipients_count": 0,
    "segment_id": "s9Kq1",
    "segment_definition_hash": "9c41e0b7…",
    "recipients": []
  },
  "report": {
    "targeted": 41230,
    "no_channel": 1204,
    "not_found": 0,
    "queued": 36020,
    "delivered": 35511,
    "failed": 509,
    "opened": 2210,
    "cancelled": 0,
    "held": 0,
    "waiting": 0,
    "error_counts": { "Unregistered": 474, "404": 35 },
    "audience": { "persons": 38120, "devices": 41230, "incomplete": false }
  }
}
```

## Send a push from your server

`POST /api/v1/sdk/server/push`

Creates and sends a campaign in one call. Use it for transactional pushes such as "your test results are ready".

The campaign has `source: "api"`. It takes no variants, goal or control group. The project-wide control group does not hold it back.

The response is `202` with status `evaluating`, or `scheduled` when you pass `scheduled_at`. A background job resolves the audience within a couple of seconds. Poll [Retrieve a server push](#retrieve-a-server-push) for progress.

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

- `title` (string, required): Up to 255 characters.
- `body` (string, required): Up to 1000 characters.
- `audience` (object, required): Who gets the push. One of:
  - `{ "type": "identifiers", "recipients": [...] }`: the user IDs your app set through the SDK.
  - `{ "type": "visitors", "recipients": [...] }`: visitor UUIDs.
  - `{ "type": "segment", "segment_id": "…" }`: an active [segment](https://docs.grovs.io/docs/analytics/segments).
  Lists hold 1 to 10,000 non-empty strings of at most 256 bytes. An identifier reaches at most 50 of its devices.
- `name` (string): Defaults to the title.
- `image_url` (string): `https://` only. The server API takes no file upload.
- `sound` (string): `default` or a bundled sound file name.
- `link_id` (integer): A link of this project.
- `data` (object): Custom payload keys, at most 2048 bytes as JSON.
- `scheduled_at` (string): ISO 8601, in the future and at most 30 days away.
- `local_time` (boolean): Deliver at the wall-clock time of `scheduled_at` in each device's timezone. Needs `scheduled_at`.
- `timezone` (string): IANA zone name. Defaults to `UTC`.

**Returns** `202` with a [campaign object](#the-push-campaign-object).

**Errors**

| Status | Meaning                                                                                      |
| ------ | -------------------------------------------------------------------------------------------- |
| 400    | Missing PROJECT-KEY, or ENVIRONMENT is not production or test.                               |
| 403    | Invalid credentials, or enterprise_required: the plan does not include the server push API. |
| 404    | Push campaigns are not available.                                                            |
| 422    | Validation failed. Also Link not found, Segment not found, data must be an object.           |
| 429    | rate_limited: the per-minute budget is used up.                                             |

**Request**

```bash
curl -X POST "https://sdk.sqd.link/api/v1/sdk/server/push" \
  -H "PROJECT-KEY: $GROVS_PROJECT_KEY" \
  -H "ENVIRONMENT: production" \
  -H "Content-Type: application/json" \
  -d '{
  "title": "Your test results are ready",
  "body": "Tap to see them in Medly.",
  "link_id": 5120,
  "data": { "screen": "results", "result_id": "r_88213" },
  "audience": {
    "type": "identifiers",
    "recipients": ["member-4471"]
  }
}'
```

**Response 202 Accepted**

```json
{
  "campaign": {
    "id": "pR4tz",
    "name": "Your test results are ready",
    "title": "Your test results are ready",
    "body": "Tap to see them in Medly.",
    "source": "api",
    "status": "evaluating",
    "link_id": 5120,
    "data": { "screen": "results", "result_id": "r_88213" },
    "audience": {
      "type": "identifiers",
      "recipients_count": 1,
      "segment_id": null,
      "segment_definition_hash": null,
      "recipients": ["member-4471"]
    },
    "report": { "targeted": 0, "queued": 0, "delivered": 0, "…": "…" },
    "…": "…"
  }
}
```

**Response 429 Too Many Requests**

```json
{ "error": "recipients: 100000 per minute", "error_code": "rate_limited" }
```

## Retrieve a server push

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

Returns a campaign of the project with its live 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 `id` from the send response.

**Returns** `200` with a [campaign object](#the-push-campaign-object) and its live `report`.

**Errors**

| Status | Meaning                                                        |
| ------ | -------------------------------------------------------------- |
| 400    | Missing PROJECT-KEY, or ENVIRONMENT is not production or test. |
| 403    | Invalid credentials, or enterprise_required.                  |
| 404    | No campaign with that ID in this environment.                  |

**Request**

```bash
curl -X GET "https://sdk.sqd.link/api/v1/sdk/server/push/{campaign_id}" \
  -H "PROJECT-KEY: $GROVS_PROJECT_KEY" \
  -H "ENVIRONMENT: production"
```

**Response 200 OK**

```json
{
  "campaign": {
    "id": "pR4tz",
    "status": "sent",
    "report": {
      "targeted": 2,
      "no_channel": 0,
      "not_found": 0,
      "queued": 2,
      "delivered": 2,
      "failed": 0,
      "opened": 1,
      "cancelled": 0,
      "held": 0,
      "waiting": 0,
      "error_counts": {},
      "audience": { "persons": null, "devices": null, "incomplete": false }
    },
    "…": "…"
  }
}
```
