Skip to content

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.

POST/api/v1/groups

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

FieldRequiredNotes
nameyes1–120 characters, trimmed. Must be unique among groups in the organization
descriptionnoUp to 500 characters, or omit/null for none
memberUserUlidsnoULIDs of active organization members to seed as initial members, up to 200
projectUlidsnoULIDs 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.

201 with the created group. See Response fields.

Terminal window
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:

Terminal window
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"
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.

ParameterNotes
pagePage number (1-based, default 1)
pageSizeItems per page (default 50, max 200)
qCase-insensitive substring search over the group’s name and description

200 with { items, page, pageSize, total }, each item following the response shape.

Terminal window
curl -sS \
-H "Authorization: Bearer $PROBARA_API_TOKEN" \
"https://app.probara.net/api/v1/groups?q=qa"
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.

Terminal window
curl -sS \
-H "Authorization: Bearer $PROBARA_API_TOKEN" \
"https://app.probara.net/api/v1/groups/01JXXXXXXXXXXXXXXXXXXXXXXXXX"
PATCH/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.

Terminal window
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/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.

Terminal window
curl -sS -X DELETE \
-H "Authorization: Bearer $PROBARA_API_TOKEN" \
"https://app.probara.net/api/v1/groups/01JXXXXXXXXXXXXXXXXXXXXXXXXX"
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). Read-open to every organization member. A member who has left the organization is excluded from both this list and the group’s userCount.

200 with { items, page, pageSize, total }. Each item is the same shape as an organization member (see Organizations), minus joinedAt.

Terminal window
curl -sS \
-H "Authorization: Bearer $PROBARA_API_TOKEN" \
"https://app.probara.net/api/v1/groups/01JXXXXXXXXXXXXXXXXXXXXXXXXX/members"
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.

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

FieldRequiredNotes
userUlidone ofULID of a single active member of the organization (the original shape)
userUlidsone ofArray 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.

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

Adding one member (unchanged):

Terminal window
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:

Terminal window
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"
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.

Terminal window
curl -sS -X DELETE \
-H "Authorization: Bearer $PROBARA_API_TOKEN" \
"https://app.probara.net/api/v1/groups/01JXXXXXXXXXXXXXXXXXXXXXXXXX/members/01JMEMBERXXXXXXXXXXXXXXXXXX"
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). 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.

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

FieldTypeNotes
ulidstring (ULID)The project’s server-assigned unique identifier
idstringThe project’s human-facing code (for example ACME)
namestringThe project’s name
modestringThe project’s own stored access mode, "public" or "private"
avatarKeystring | nullStorage key of the project’s uploaded avatar; null when none
avatarVersionintegerCache-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
}
Terminal window
curl -sS \
-H "Authorization: Bearer $PROBARA_API_TOKEN" \
"https://app.probara.net/api/v1/groups/01JXXXXXXXXXXXXXXXXXXXXXXXXX/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. 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.

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

201 with an empty body.

Terminal window
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"

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

FieldTypeNotes
ulidstring (ULID)Server-assigned unique identifier
namestring1–120 characters, unique within the organization
descriptionstring | nullOptional description, up to 500 characters
userCountnumberActive member count, derived from the group’s membership rows
projectCountnumberReal, unfiltered count of projects assigned to the group — see List assigned projects for the caller-filtered list of names
createdAtnumberUnix epoch milliseconds
updatedAtnumberUnix epoch milliseconds

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

ActionTrigger
user_group.createdGroup created
user_group.updatedName or description updated
user_group.deletedGroup deleted
user_group.member_addedMember added to the group
user_group.member_removedMember 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.