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