# Groups (Universal)

Universal push groups — subscribe subscriber ids and/or device keys to a keyed group (topics, segments, interests) from any framework, read the members, and send to the whole group with one call.

A **group** is an app-scoped, keyed audience — `weekly-news`, `bakery-specials`, `volunteers` — that any client can subscribe to. It is the universal push service's topic/segment model: subscribe once, then send to the group by its key.

Each member is one of two kinds:

- **A subscriber id** — an identity. It reaches every universal 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 (mass devices, browsers).

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

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

All endpoints use your **app id + app token**, like every [universal push](/docs/universal-push) endpoint — 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; you can mix all four member fields in one call (a subscriber and a device are two memberships). More than 500 ids of one kind is a `400 too_many_members` — the batch is rejected, never truncated.

Response — `201`:

```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). It answers `200` with `removed` per member (`false` when the member was not in the group) and the new counts. An unknown key answers `404 group_not_found`. Removing a subscriber id stops the group reaching every device registered under it.

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

- **List** — `{ "ok": true, "groups": [ … ] }`: every group on the app, oldest first, each with `id`, `key`, `name`, `description`, `createdBy`, `createdAt`, `updatedAt` and `counts`.
- **One group** — `{ "ok": true, "group": { … } }`, by key (or by its numeric `id` when no group has that key — an all-digit key always wins). `404 group_not_found` when it does not exist. Read a single group for its `prunableDevices` count.
- **Members** — one page of membership rows, oldest first:

```json
{
  "ok": true,
  "group": { "id": 17, "key": "weekly-news", "name": "Weekly News", "counts": { "subscribers": 1, "devices": 0, "resolvableDevices": 2, "prunableDevices": 0 } },
  "total": 1,
  "take": 100,
  "skip": 0,
  "members": [
    {
      "memberId": 311,
      "subscriberId": "user_8241",
      "deviceId": null,
      "source": "api",
      "joinedAt": "2026-09-24T15:01:22.000Z",
      "resolvedDevices": 2
    }
  ]
}
```

`take` defaults to 100 and must be an integer 1..200 — a larger value is a `400 invalid_take`, never a silent cap. `total` is the group's full membership count, so page with `skip` until you have read `total` rows.

### What the counts mean

| 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/universal-push/environments) — a send only reaches the ones in its own environment).                      |
| `prunableDevices`         | Device memberships whose device is not registered (never registered yet, or deregistered). They are never removed automatically; the dashboard's **Prune** action removes exactly those rows. Subscriber memberships are never prunable. |

## Send to a group

A group is one audience of the ordinary [universal send](/docs/universal-push/sending) — inbox entries, analytics and token health included:

```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 device 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 is not an error — the response reports `devices: 0`, so check it.

## Errors

Errors use the envelope `{ "error": { "code", "message", "field"? } }`:

| 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 key 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`  | `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.            |
| `502`  | `subscribe_failed`, `unsubscribe_failed`         | The change could not be saved — retry.                                           |

## Managing groups from the dashboard

The dashboard's **Segments** tab lists universal groups next to classic segments, and can create a group (`409 key_taken` for a duplicate key), delete it (memberships go with it; devices, inbox and history are untouched), add or remove members, and prune stale device memberships. Those session routes live under `/api/agent/apps/:appId/groups`.

## For agents

The [MCP server](/docs/mcp-server) exposes the public surface as tools: `list_universal_groups`, `get_universal_group`, `list_universal_group_members` (with `all: true` to page through every member), `subscribe_to_universal_group`, `unsubscribe_from_universal_group` and `send_notification_to_universal_group`.
