# Groups

Push groups — subscribe subscriber ids and/or device keys to a keyed group from any framework, read the members, and send to the whole group with one call. Every app also has three always-on groups (Lapsed, Active, New), filled automatically from app opens.

A **group** is an app-scoped, keyed audience — `weekly-news`, `bakery-specials`, `volunteers` — that any client can subscribe to. Subscribe once, then send to the group by its key.

## The three always-on groups

Every app always has three groups. There is nothing to create and nothing to turn on:

| Key      | Who is in it                      | Rule         |
| -------- | --------------------------------- | ------------ |
| `lapsed` | No app open in the last 30 days   | `lapsed_30d` |
| `active` | Opened the app in the last 7 days | `active_7d`  |
| `new`    | First app open in the last 7 days | `new_7d`     |

Membership is **computed live**, by subscriber id, from app opens: a device re-registers on every launch, so its subscriber counts as active the moment the app opens. What the counts report is what a send reaches — every member holds at least one live token.

Always-on groups are read-only: they cannot be subscribed to, edited or deleted, and their keys are reserved. Send to them — `"audience": { "type": "group", "key": "lapsed" }` — or [schedule](/docs/push/scheduling) against them like any other group.

The groups you create yourself are **manual** groups — the rest of this page is about them.

A manual group's members are one of two kinds:

- **A subscriber id** — an identity. It reaches every device registered under that `subscriberId`, now and in the future (new phones and reinstalls included).
- **A device key** — one registered `deviceId`, for anonymous audiences that have no subscriber identity (devices registered without a `subscriberId`, such as browsers).

Subscribing is **idempotent**: subscribing an existing member is a no-op (`joined: false`), so a client can subscribe on every launch.

> **Not the classic topic groups:**
>
> Universal groups are separate from the classic Expo path's [topic groups](/docs/topic-push-notifications): a different audience and send pipeline, and they do not interact.

All endpoints use your **app id + app token** — in the body for the POSTs, in the URL for the GETs.

## Subscribe

```text
POST https://app.nativenotify.com/api/universal/groups/subscribe
```

```json
{
  "appId": 123,
  "appToken": "yourAppToken",
  "key": "weekly-news",
  "name": "Weekly News",
  "subscriberId": "user_8241"
}
```

| Field                 | Required   | Notes                                                                                                                              |
| --------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `appId`, `appToken`   | yes        | Your app's pair.                                                                                                                   |
| `key`                 | yes        | 1–64 characters: lowercase letters, digits, dashes and underscores, starting with a letter or digit. Aliases: `groupKey`, `group`. |
| `subscriberId`        | one member | One subscriber id (max 256 chars). Aliases: `subscriber_id`, `subId`, `sub_id`.                                                    |
| `deviceId`            | one member | One registered device key (max 200 chars). Alias: `device_id`.                                                                     |
| `subscribers`         | one member | Bulk subscriber ids, up to 500 per call. Aliases: `subscriberIds`, `subscriber_ids`.                                               |
| `devices`             | one member | Bulk device keys, up to 500 per call. Aliases: `deviceIds`, `device_ids`.                                                          |
| `name`, `description` | no         | Used only when this call creates the group (max 120 / 500 chars). The name defaults to the key.                                    |
| `create`              | no         | Default `true`: an unknown key creates the group. Pass `false` to answer `404 group_not_found` instead.                            |

Send at least one member; the four member fields can be mixed in one call. More than 500 ids of one kind is a `400 too_many_members` — the batch is rejected, never truncated.

```json
{
  "ok": true,
  "group": { "id": 17, "key": "weekly-news", "name": "Weekly News", "created": true },
  "results": { "subscribers": [{ "subscriberId": "user_8241", "joined": true }], "devices": [] },
  "counts": { "subscribers": 1, "devices": 0, "resolvableDevices": 2, "prunableDevices": 0 }
}
```

## Unsubscribe

```text
POST https://app.nativenotify.com/api/universal/groups/unsubscribe
```

Same body as subscribe (`key` plus members). Answers `200` with `removed` per member (`false` when the member was not in the group) and the new counts; an unknown key is `404 group_not_found`.

## Read groups and members

```text
GET https://app.nativenotify.com/api/universal/groups/:appId/:appToken
GET https://app.nativenotify.com/api/universal/groups/:appId/:appToken/:key
GET https://app.nativenotify.com/api/universal/groups/:appId/:appToken/:key/members?take=100&skip=0
```

The list returns every group on the app, oldest first, each with `id`, `key`, `name`, `description`, `createdBy`, `createdAt`, `updatedAt` and `counts`. One group returns a single `group` by key (or by its numeric `id` when no group has that key), or `404 group_not_found`. Members returns one page of membership rows — `{ memberId, subscriberId, deviceId, source, joinedAt, resolvedDevices }` — with `total`, `take` and `skip`. `take` defaults to 100 and must be an integer 1..200 (`400 invalid_take`), and `total` is the full membership count, so page with `skip` until you have read every row. For an always-on group the page is its computed members: read-only, `source: "auto"`, `memberId` is `null`, and rows are ordered by subscriber id.

| Count                     | Meaning                                                                                                                                                                                                   |
| ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `subscribers` / `devices` | Membership rows of each kind.                                                                                                                                                                             |
| `resolvableDevices`       | Distinct registered devices with at least one live token that the memberships reach right now (across all [environments](/docs/push/environments) — a send reaches only the ones in its own environment). |
| `prunableDevices`         | Device memberships whose device is not registered. Never removed automatically; the dashboard's **Prune** action removes exactly those rows. Subscriber memberships are never prunable.                   |

For an always-on group, `subscribers` and `resolvableDevices` are its computed member count — it stores no membership rows, and `devices` is always `0`.

## Send to a group

A group is one audience of the ordinary [send](/docs/push/sending):

```json
{
  "appId": 123,
  "appToken": "yourAppToken",
  "title": "This week's newsletter",
  "message": "New recipes from the bakery and the library's reading list.",
  "audience": { "type": "group", "key": "weekly-news" }
}
```

At send time the memberships resolve to live tokens in the send's `environment` (production when omitted). A device reached by both a subscriber membership and a device membership gets the push once. An unknown or empty group reports `devices: 0`, so check it.

Sending to a group is a **Pro plan feature**: on Premium the send answers `403 plan_upgrade_required` and nothing is sent. Subscribing, unsubscribing and reading groups work on every plan.

## Errors

| Status | Code                                             | Meaning                                                                                                       |
| ------ | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------- |
| `400`  | `invalid_body`, `missing_field`, `invalid_field` | Bad body, no `key`, a bad `appId`, or a `name` / `description` that is too long.                              |
| `400`  | `invalid_group_key`                              | The key does not match the rules above.                                                                       |
| `400`  | `invalid_subscriber_id`, `invalid_device_id`     | An empty or too-long member id, or a member list that is not an array.                                        |
| `400`  | `no_members`, `too_many_members`                 | No member at all, or more than 500 of one kind.                                                               |
| `400`  | `auto_group`                                     | A subscribe/unsubscribe named one of the always-on groups (`lapsed`, `active`, `new`) — they fill themselves. |
| `400`  | `reserved_group_key`                             | A group key reserved for the always-on groups was used to create a manual group.                              |
| `400`  | `invalid_take`, `invalid_skip`                   | Bad member-page parameters.                                                                                   |
| `404`  | `app_not_found`, `group_not_found`               | The app is not one this caller can access, or the key does not exist.                                         |
| `409`  | `key_reserved`, `system_group`                   | The dashboard-facing codes: a create named a reserved key, or an edit/delete targeted an always-on group.     |
| `502`  | `subscribe_failed`, `unsubscribe_failed`         | The change could not be saved — retry.                                                                        |

The dashboard's **Groups** tab lists every group — the three always-on ones first, with live counts — and can create, edit, and prune manual groups; the [MCP server](/docs/mcp-server) exposes the same surface as tools (`list_universal_groups`, `subscribe_to_universal_group`, `send_notification_to_universal_group`, …).
