User groups 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, granting every current and future member of the group access to that project in one operation.
Create
Section titled “Create”/api/v1/groupsRequires the manage member groups permission (held by owner/admin); member and viewer receive
403 forbidden.
Request body
Section titled “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), identical in shape to a member added afterwards through
Add a member — the activity feed cannot tell the two apart.
projectUlids seeds the group’s initial project assignments 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 or the
project-plane group endpoints — 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 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 and Assign projects instead.
Supports the tenant-plane idempotency store: 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
Section titled “Response”201 with the created group. See Response fields.
Example
Section titled “Example”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:
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"/api/v1/groupsReturns the organization’s groups, ordered by name ascending. Page-based pagination. Read-open to every organization member regardless of role.
Query parameters
Section titled “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
Section titled “Response”200 with { items, page, pageSize, total }, each item following the response shape.
Example
Section titled “Example”curl -sS \ -H "Authorization: Bearer $PROBARA_API_TOKEN" \ "https://app.probara.net/api/v1/groups?q=qa"/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
Section titled “Example”curl -sS \ -H "Authorization: Bearer $PROBARA_API_TOKEN" \ "https://app.probara.net/api/v1/groups/01JXXXXXXXXXXXXXXXXXXXXXXXXX"Update
Section titled “Update”/api/v1/groups/{userGroupUlid}Partial update — both fields are optional, following the same validation as 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. 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 and Remove a member; project assignment changes go
through Assign projects and the
project-plane group endpoints.
Example
Section titled “Example”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
Section titled “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
Section titled “Example”curl -sS -X DELETE \ -H "Authorization: Bearer $PROBARA_API_TOKEN" \ "https://app.probara.net/api/v1/groups/01JXXXXXXXXXXXXXXXXXXXXXXXXX"List members
Section titled “List members”/api/v1/groups/{userGroupUlid}/membersReturns the group’s members, ordered by join date ascending. Page-based pagination (page, pageSize
— same defaults as 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
Section titled “Response”200 with { items, page, pageSize, total }. Each item is the same shape as an organization member
(see Organizations), minus joinedAt.
Example
Section titled “Example”curl -sS \ -H "Authorization: Bearer $PROBARA_API_TOKEN" \ "https://app.probara.net/api/v1/groups/01JXXXXXXXXXXXXXXXXXXXXXXXXX/members"Add a member
Section titled “Add a member”/api/v1/groups/{userGroupUlid}/membersRequires 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
Section titled “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.
Response
Section titled “Response”201 with an empty body. No per-member outcome is reported — a membership row has no
non-transactional side effect to report.
Example
Section titled “Example”Adding one member (unchanged):
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:
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
Section titled “Remove a member”/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
Section titled “Example”curl -sS -X DELETE \ -H "Authorization: Bearer $PROBARA_API_TOKEN" \ "https://app.probara.net/api/v1/groups/01JXXXXXXXXXXXXXXXXXXXXXXXXX/members/01JMEMBERXXXXXXXXXXXXXXXXXX"List assigned projects
Section titled “List assigned projects”/api/v1/groups/{userGroupUlid}/projectsReturns the projects this group is assigned to, ordered by name ascending. Page-based pagination
(page, pageSize — same defaults as List). Read-open to every organization member,
like the other three group GETs above — but unlike them, this listing filters its results:
a private project the caller cannot otherwise discover (see
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
Section titled “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 |
{ "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
Section titled “Example”curl -sS \ -H "Authorization: Bearer $PROBARA_API_TOKEN" \ "https://app.probara.net/api/v1/groups/01JXXXXXXXXXXXXXXXXXXXXXXXXX/projects"Assign projects
Section titled “Assign projects”/api/v1/groups/{userGroupUlid}/projectsAssigns 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.
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
Section titled “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’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.
Response
Section titled “Response”201 with an empty body.
Example
Section titled “Example”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
Section titled “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 for the caller-filtered list of names |
createdAt | number | Unix epoch milliseconds |
updatedAt | number | Unix epoch milliseconds |
Audit events
Section titled “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.