# User groups API

> Create, list, get, update, and delete organization user groups, and manage their membership, over the integrator API.

User groups are organization-scoped collections of members you can manage together. Each group has a
server-assigned ULID, a unique name within the organization (case-sensitive), an optional description,
a server-computed member count, and a server-computed project count. A group can be
[assigned to a project's access allowlist](https://docs.probara.net/en/developers/api/project-access/#group-allowlist),
granting every current and future member of the group access to that project in one operation.

A group managed by your identity provider has `managedByIdp` set to `true`. Its name and members are changed in the identity provider: renaming it, deleting it, or adding or removing a member returns `409 conflict` with the message “This group is managed by your identity provider. Change its name and members there.” Its description and project grants stay editable.

## Create

`POST /api/v1/groups`

Requires the manage member groups permission (held by **`owner`**/**`admin`**); **`member`** and **`viewer`** receive
**`403 forbidden`**.

### Request body

| Field             | Required | Notes                                                                           |
| ----------------- | -------- | ------------------------------------------------------------------------------- |
| `name`            | yes      | 1–120 characters, trimmed. Must be unique among groups in the organization      |
| `description`     | no       | Up to 500 characters, or omit/`null` for none                                   |
| `memberUserUlids` | no       | ULIDs of active organization members to seed as initial members, up to 200      |
| `projectUlids`    | no       | ULIDs of projects in the organization to seed as initial assignments, up to 200 |

A duplicate name within the same organization returns `409 conflict`. The same name is accepted in a
different organization.

`memberUserUlids` seeds the group's membership in the same atomic request that creates the group — a
group and its seeded members either all commit or none do, never a group left behind with some
members missing. Repeated ULIDs are silently deduplicated. Any entry naming a departed (or
never-a-member) user rejects the **entire** create with `404 not_found` and creates nothing — not
even the group row. Each seeded member emits its own `user_group.member_added` audit event (see
[Audit events](#audit-events)), identical in shape to a member added afterwards through
[Add a member](#add-a-member) — the activity feed cannot tell the two apart.

`projectUlids` seeds the group's initial [project assignments](#list-assigned-projects) in the same
atomic request, following the identical convention: repeated ULIDs are silently deduplicated, and an
entry naming a project outside the active organization rejects the **entire** create with
`404 not_found`, leaving no group row, no membership row and no assignment row behind. A seeded
assignment is **never** refused because the target project's stored access mode is `"public"` — a
group is usually created before anyone decides which of its projects are private, so a dormant
assignment on a public project is the common case, not an error. Each seeded assignment emits its own
`project.access_changed` audit event on the **project's** activity feed with `reason: "group_granted"`,
identical in shape to an assignment made afterwards through [Assign projects](#assign-projects) or the
[project-plane group endpoints](https://docs.probara.net/en/developers/api/project-access/#group-allowlist) — a reader of a
project's access history cannot tell which path produced it. Seeded assignments carry no separate
authorization check: creating a group already requires **`owner`**/**`admin`**, and both roles can
already assign any project in the organization.

`memberUserUlids` and `projectUlids` are accepted only here. [Update](#update) does not accept either
field — an unknown key rejects the whole request with `422`. Seeding members or projects after
creation goes through [Add a member](#add-a-member) and [Assign projects](#assign-projects) instead.

Supports the tenant-plane [idempotency store](https://docs.probara.net/en/developers/api-reference/#errors): retrying a create
with the same `Idempotency-Key` header resolves to the original group (with its seeded members)
rather than creating a second one.

### Response

`201` with the created group. See [Response fields](#response-fields).

### Example

```bash
curl -sS -X POST \
  -H "Authorization: Bearer $PROBARA_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"QA Squad","description":"Owns the regression sweep"}' \
  "https://app.probara.net/api/v1/groups"
```

Seeding members and projects in the same request:

```bash
curl -sS -X POST \
  -H "Authorization: Bearer $PROBARA_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"QA Squad","memberUserUlids":["01JMEMBERXXXXXXXXXXXXXXXXXX"],"projectUlids":["01JPROJECTXXXXXXXXXXXXXXXXX"]}' \
  "https://app.probara.net/api/v1/groups"
```

## List

`GET /api/v1/groups`

Returns the organization's groups, ordered by name ascending. Page-based pagination. Read-open to
every organization member regardless of role.

### Query parameters

| Parameter  | Notes                                                                       |
| ---------- | --------------------------------------------------------------------------- |
| `page`     | Page number (1-based, default `1`)                                          |
| `pageSize` | Items per page (default `50`, max `200`)                                    |
| `q`        | Case-insensitive substring search over the group's name **and** description |

### Response

`200` with `{ items, page, pageSize, total }`, each item following the [response shape](#response-fields).

### Example

```bash
curl -sS \
  -H "Authorization: Bearer $PROBARA_API_TOKEN" \
  "https://app.probara.net/api/v1/groups?q=qa"
```

## Get

`GET /api/v1/groups/{userGroupUlid}`

Returns a single group scoped to the active organization. Read-open to every organization member.

Returns `404 not_found` when the group is outside the active organization.

### Example

```bash
curl -sS \
  -H "Authorization: Bearer $PROBARA_API_TOKEN" \
  "https://app.probara.net/api/v1/groups/01JXXXXXXXXXXXXXXXXXXXXXXXXX"
```

## Update

`PATCH /api/v1/groups/{userGroupUlid}`

Partial update — both fields are optional, following the same validation as [Create](#create). Requires
**`owner`** or **`admin`**.

Renaming to a name already taken by another group in the organization returns `409 conflict`; the
original group keeps its name. A group managed by your identity provider answers a rename with the `409 conflict` above, and its description can still change. Returns `404 not_found` when the group is outside the active organization.

Neither `memberUserUlids` nor `projectUlids` is an accepted field here, deliberately — an unknown key
rejects the whole request with `422`. Membership changes after creation go through
[Add a member](#add-a-member) and [Remove a member](#remove-a-member); project assignment changes go
through [Assign projects](#assign-projects) and the
[project-plane group endpoints](https://docs.probara.net/en/developers/api/project-access/#group-allowlist).

### Example

```bash
curl -sS -X PATCH \
  -H "Authorization: Bearer $PROBARA_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"description":"Owns the release regression sweep"}' \
  "https://app.probara.net/api/v1/groups/01JXXXXXXXXXXXXXXXXXXXXXXXXX"
```

## Delete

`DELETE /api/v1/groups/{userGroupUlid}`

Requires **`owner`** or **`admin`**. Returns `204` on success; the group's membership rows are removed
with it (a foreign-key cascade — no separate call is needed or possible to empty a group first).
Deleting a group with members is never refused: there is no emptiness precondition.

Returns `404 not_found` when the group is outside the active organization.

### Example

```bash
curl -sS -X DELETE \
  -H "Authorization: Bearer $PROBARA_API_TOKEN" \
  "https://app.probara.net/api/v1/groups/01JXXXXXXXXXXXXXXXXXXXXXXXXX"
```

## List members

`GET /api/v1/groups/{userGroupUlid}/members`

Returns the group's members, ordered by join date ascending. Page-based pagination (`page`, `pageSize`
— same defaults as [List](#list)). Read-open to every organization member. A member who has left the
organization is excluded from both this list and the group's `userCount`.

### Response

`200` with `{ items, page, pageSize, total }`. Each item is the same shape as an organization member
(see [Organizations](https://docs.probara.net/en/developers/api-reference/)), minus `joinedAt`.

### Example

```bash
curl -sS \
  -H "Authorization: Bearer $PROBARA_API_TOKEN" \
  "https://app.probara.net/api/v1/groups/01JXXXXXXXXXXXXXXXXXXXXXXXXX/members"
```

## Add a member

`POST /api/v1/groups/{userGroupUlid}/members`

Requires **`owner`** or **`admin`**. Adds either ONE member or MANY, in the same request — this is a
single operation on a single path, not two.

### Request body

Exactly one of the following two shapes — a body carrying both, or neither, returns
`422 validation_failed`:

| Field       | Required | Notes                                                                   |
| ----------- | -------- | ----------------------------------------------------------------------- |
| `userUlid`  | one of   | ULID of a single active member of the organization (the original shape) |
| `userUlids` | one of   | Array of active-member ULIDs to add together, up to 200, `.min(1)`      |

`userUlids` is silently deduplicated before resolution. The write is **all-or-nothing**: a departed
(or never-a-member) ULID **anywhere** in the request — including the scalar `userUlid` form — rejects
the **entire** request with `404 not_found` and adds nobody. This holds even for a ULID that is already
a member: the active-member check runs before the already-a-member check, so a departed _existing_
member still answers `404`, never `409`.

A request that would add **zero new** memberships — every listed ULID already a member — returns
`409 conflict` and adds nothing; this is the exact behavior `{ userUlid }` already had for a single
already-member ULID, generalized rather than replaced. A selection mixing new and existing members adds
only the new ones and still returns `201`.

Supports the same `Idempotency-Key` header as [Create](#create).

### Response

`201` with an empty body. No per-member outcome is reported — a membership row has no
non-transactional side effect to report.

### Example

Adding one member (unchanged):

```bash
curl -sS -X POST \
  -H "Authorization: Bearer $PROBARA_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"userUlid":"01JMEMBERXXXXXXXXXXXXXXXXXX"}' \
  "https://app.probara.net/api/v1/groups/01JXXXXXXXXXXXXXXXXXXXXXXXXX/members"
```

Adding several members in one request:

```bash
curl -sS -X POST \
  -H "Authorization: Bearer $PROBARA_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"userUlids":["01JMEMBERAXXXXXXXXXXXXXXXX","01JMEMBERBXXXXXXXXXXXXXXXX"]}' \
  "https://app.probara.net/api/v1/groups/01JXXXXXXXXXXXXXXXXXXXXXXXXX/members"
```

## Remove a member

`DELETE /api/v1/groups/{userGroupUlid}/members/{userUlid}`

Requires **`owner`** or **`admin`**. Removal is **not** gated on active membership — a departed member's
row can still be removed. Returns `204` on success; `404 not_found` when the pair does not exist.

### Example

```bash
curl -sS -X DELETE \
  -H "Authorization: Bearer $PROBARA_API_TOKEN" \
  "https://app.probara.net/api/v1/groups/01JXXXXXXXXXXXXXXXXXXXXXXXXX/members/01JMEMBERXXXXXXXXXXXXXXXXXX"
```

## List assigned projects

`GET /api/v1/groups/{userGroupUlid}/projects`

Returns the projects this group is assigned to, ordered by name ascending. Page-based pagination
(`page`, `pageSize` — same defaults as [List](#list)). Read-open to every organization member,
like the other three group `GET`s above — **but unlike them**, this listing filters its results:
a private project the caller cannot otherwise discover (see
[Project access](https://docs.probara.net/en/developers/api/project-access/)) is silently omitted from both `items` and
`total`. An organization **owner**/**admin** sees every assignment, since that role can already
discover every project.

This is a deliberately **different** question from the group's own `projectCount` below, which
stays an unfiltered count — a private assignment the caller cannot see still counts toward
`projectCount`, even though it is absent from this list. A count is not a name.

### Response

`200` with `{ items, page, pageSize, total }`. Each item:

| Field           | Type           | Notes                                                           |
| --------------- | -------------- | --------------------------------------------------------------- |
| `ulid`          | string (ULID)  | The project's server-assigned unique identifier                 |
| `id`            | string         | The project's human-facing code (for example `ACME`)            |
| `name`          | string         | The project's name                                              |
| `mode`          | string         | The project's own stored access mode, `"public"` or `"private"` |
| `avatarKey`     | string \| null | Storage key of the project's uploaded avatar; `null` when none  |
| `avatarVersion` | integer        | Cache-busting version of that avatar; `0` when none             |

```json
{
  "items": [
    {
      "ulid": "01J...PROJECT",
      "id": "ACME",
      "name": "Acme Corp",
      "mode": "private",
      "avatarKey": "avatars/projects/01J...PROJECT.webp",
      "avatarVersion": 3
    }
  ],
  "page": 1,
  "pageSize": 50,
  "total": 1
}
```

### Example

```bash
curl -sS \
  -H "Authorization: Bearer $PROBARA_API_TOKEN" \
  "https://app.probara.net/api/v1/groups/01JXXXXXXXXXXXXXXXXXXXXXXXXX/projects"
```

## Assign projects

`POST /api/v1/groups/{userGroupUlid}/projects`

Assigns the group to one or many projects in the same request — a batch is one operation on one
path, not N calls to the [project-plane group endpoints](https://docs.probara.net/en/developers/api/project-access/#group-allowlist).
**No organization role gate of its own.** Authorization runs **per project**, inside the same
transaction, using the identical rule `POST /api/v1/projects/{projectId}/access/groups` already
enforces (organization `owner`/`admin`, or the project's current owner) — stacking a group-management
gate on top would make that per-project check unreachable as a denial for a `member`/`viewer` caller
who legitimately owns a project, and is deliberately not done here.

### Request body

| Field          | Required | Notes                                             |
| -------------- | -------- | ------------------------------------------------- |
| `projectUlids` | yes      | ULIDs of projects to assign, up to 200, `.min(1)` |

An unknown key, an empty array, or more than 200 distinct ULIDs returns `422 validation_failed`.

The write is **all-or-nothing**: an unresolvable project ULID, or one the caller is not authorized to
assign, anywhere in the batch rejects the **entire** request and commits nothing — including a project
earlier in the same batch the caller **was** authorized for. An unresolvable project ULID returns
`404 not_found`; an authorization failure returns `403 forbidden`. Repeated ULIDs within one request
are silently deduplicated to a single assignment. An already-assigned project in the batch is never an
error (matching the allowlist's own `onConflictDoNothing` semantics) — the request still returns `201`,
never `409`, and the rest of the batch still commits. A public target project is never refused, exactly
like [Create](#create)'s seeded assignments.

Each newly written assignment emits its own `project.access_changed` audit event on the **project's**
activity feed with `reason: "group_granted"` — including a re-assignment of an already-assigned
project, since the underlying delegate emits unconditionally.

Supports the same `Idempotency-Key` header as [Create](#create).

### Response

`201` with an empty body.

### Example

```bash
curl -sS -X POST \
  -H "Authorization: Bearer $PROBARA_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"projectUlids":["01JPROJECTAXXXXXXXXXXXXXXXX","01JPROJECTBXXXXXXXXXXXXXXXX"]}' \
  "https://app.probara.net/api/v1/groups/01JXXXXXXXXXXXXXXXXXXXXXXXXX/projects"
```

## Response fields

All mutating endpoints (`POST`, `PATCH`) and `GET /api/v1/groups/{userGroupUlid}` return a single
group object.

| Field          | Type           | Notes                                                                                                                                                  |
| -------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `ulid`         | string (ULID)  | Server-assigned unique identifier                                                                                                                      |
| `name`         | string         | 1–120 characters, unique within the organization                                                                                                       |
| `description`  | string \| null | Optional description, up to 500 characters                                                                                                             |
| `userCount`    | number         | Active member count, derived from the group's membership rows                                                                                          |
| `projectCount` | number         | Real, unfiltered count of projects assigned to the group — see [List assigned projects](#list-assigned-projects) for the caller-filtered list of names |
| `createdAt`    | number         | Unix epoch milliseconds                                                                                                                                |
| `updatedAt`    | number         | Unix epoch milliseconds                                                                                                                                |

## Audit events

Group mutations emit the following events on the organization activity feed:

| Action                      | Trigger                       |
| --------------------------- | ----------------------------- |
| `user_group.created`        | Group created                 |
| `user_group.updated`        | Name or description updated   |
| `user_group.deleted`        | Group deleted                 |
| `user_group.member_added`   | Member added to the group     |
| `user_group.member_removed` | Member removed from the group |

A create carrying `memberUserUlids` emits one `user_group.member_added` event per seeded member —
in addition to the one `user_group.created` event — never a single event summarizing a count. The
activity feed reads identically whether a member was seeded at creation or added afterwards.

See [Audit events](https://docs.probara.net/en/developers/api/audit-events/).
