User Group API
People Groups and Calendar Sharing
Manage reusable people groups, invites, and group-shared calendars
User groups are people groups, not calendar sidebar groups. They let a user share one or more calendars with a stable set of members while preserving the existing calendar color, icon, visibility, and event behavior.
Authentication and Permissions
- All endpoints require authentication.
- Plan gating is per route, not per controller. Creating a group and everything that manages one - update, delete, add/update/remove members, create/list/withdraw invites, attach/detach calendars - needs the
user_groupsplan feature (@RequireFeature('user_groups'); a plan without it gets 403FEATURE_NOT_ENTITLED). Reading the groups you belong to (GET /user-groups,GET /user-groups/:id,GET /user-groups/:id/members), accepting or declining an invitation and leaving a group are open to every plan: a Free account must be able to be invited, say yes, see the group and leave it. A controller-wide gate once locked all of that. - Users can list groups they own or belong to.
- Group metadata, membership, invite, and calendar-link writes require owner or admin role.
- Deleting a group requires the group owner. The owner cannot leave their group (
403 GROUP_OWNER_CANNOT_LEAVE); everyone else can. - Calendar attachment also checks the actor's calendar rights before creating the group share.
- Invite creation is privacy-preserving: the response shape does not reveal whether the email belongs to an existing PrimeCal user.
- Invite accept and decline use an opaque token and authenticated user context. They answer only for the account the invitation is addressed to (
403 INVITATION_NOT_FOR_YOUotherwise); invalid, expired and already-used tokens answer404 INVITATION_NOT_FOUND/INVITATION_EXPIRED. The unified Invitations API lists and answers group and seat invitations together.
Endpoint Reference
Groups
| Method | Path | Purpose | Request or query | Auth |
|---|---|---|---|---|
GET | /api/user-groups | List groups owned by or shared with the current user. | None | JWT or user API key |
POST | /api/user-groups | Create a people group. | Body: name,slug,description,kind | JWT or user API key |
GET | /api/user-groups/:id | Get one group with members and shared calendar details where visible. | Path: id | JWT or user API key |
PATCH | /api/user-groups/:id | Update group metadata. | Path: id, body: partial group fields | JWT or user API key |
DELETE | /api/user-groups/:id | Delete a people group. | Path: id | JWT or user API key |
Members
| Method | Path | Purpose | Request or query | Auth |
|---|---|---|---|---|
GET | /api/user-groups/:id/members | List group members. | Path: id | JWT or user API key |
POST | /api/user-groups/:id/members | Add or update a member directly by user ID. | Path: id, body: userId,role | JWT or user API key |
PATCH | /api/user-groups/:id/members/:userId | Change a member role. | Path: id,userId, body: role | JWT or user API key |
DELETE | /api/user-groups/:id/members/:userId | Remove a member from the group. | Path: id,userId | JWT or user API key |
POST | /api/user-groups/:id/leave | Leave a group you belong to (open to every plan; 204). The owner cannot. | Path: id | JWT or user API key |
Invites
| Method | Path | Purpose | Request or query | Auth |
|---|---|---|---|---|
POST | /api/user-groups/:id/invites | Create an invite by email, optionally with a seat on the inviter's plan. Inviting the same address to the same group again, by the same sender, re-sends their pending invitation; another admin starts one of their own (a seat held for an invitation is on its sender's plan). | Path: id, body: email,message,addToMyAccount | JWT or user API key |
GET | /api/user-groups/:id/invites | List pending group invites for owner/admin review. | Path: id | JWT or user API key |
DELETE | /api/user-groups/:id/invites/:inviteId | Withdraw a pending invite and release the seat held for it (204). | Path: id,inviteId | JWT or user API key |
POST | /api/user-groups/invites/:token/accept | Accept an invite by opaque token. Returns the group plus seatActivated. Open to every plan. | Path: token | JWT or user API key |
POST | /api/user-groups/invites/:token/decline | Decline an invite by opaque token; releases its seat. Open to every plan. | Path: token | JWT or user API key |
Calendars
| Method | Path | Purpose | Request or query | Auth |
|---|---|---|---|---|
POST | /api/user-groups/:groupId/calendars | Attach one or more existing calendars to a people group. | Path: groupId, body: calendarIds,calendarId,permission | JWT or user API key |
POST | /api/user-groups/:groupId/calendars/create | Create a calendar primarily attached to a people group. | Path: groupId, body: calendar fields plus optional groupPermission | JWT or user API key |
DELETE | /api/user-groups/:groupId/calendars | Detach one or more calendars from a people group. | Path: groupId, body: calendarIds or calendarId | JWT or user API key |
DELETE | /api/user-groups/:groupId/calendars/:calendarId | Detach one calendar from a people group. | Path: groupId,calendarId | JWT or user API key |
DTO Shapes and Validation
CreateUserGroupDto
name: required string, 2 to 200 chars.slug: optional stable key, max 120 chars, pattern^[a-z0-9][a-z0-9_-]*$.description: optional string, max 500 chars.kind: optional enumfamily|friends|team|resource|custom.
UpdateUserGroupDto
- Same group metadata fields as create, all optional.
sluganddescriptionmay be cleared withnullwhere service behavior allows it.
AddUserGroupMemberDto and UpdateUserGroupMemberDto
userId: required integer greater than0for direct member additions.role: enumadmin|memberfor API callers.owneris reserved for the group owner record.
CreateUserGroupInviteDto
email: required email string, max 320 chars, trimmed and lowercased server-side.message: optional string, max 500 chars, trimmed server-side.addToMyAccount: optional boolean. Also give the invitee a seat on the inviter's own subscription (never anyone else's - a group admin who is not the plan owner gives a seat on their own plan). The seat is reserved first: if the plan has no seats (403 SEAT_MANAGEMENT_NOT_AVAILABLE), every seat is taken (400 SEAT_LIMIT_REACHED), the address already has one (409 SEAT_ALREADY_ASSIGNED) or is the inviter's own (400 CANNOT_INVITE_OWNER), nothing is created. The seat expires with the invitation (14 days), is activated when the invitation is accepted, declined when it is declined and released when it is withdrawn.
The response is { id, status, expiresAt, emailDeliveryPlanned, emailSent, seatReserved } - none of it depends on whether the address has an account. emailSent is false when no email provider is configured or sending failed; the invitation is saved either way.
Invite storage includes token, expiresAt, seatId (the seat held for it, ON DELETE SET NULL), emailSentAt and emailSendAttempts. UserGroupInviteStatus is pending, accepted, declined, expired or revoked (withdrawn by the sender).
GroupCalendarsDto
calendarIds: array of unique integers, 1 to 100 items.calendarId: legacy single-calendar field, accepted whencalendarIdsis absent.permission: optional enumread|write|admin, defaults toreadfor attach operations.
Example Calls
Create a group
curl -X POST "$PRIMECAL_API/api/user-groups" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Family Planning",
"slug": "family-planning",
"description": "Calendars shared with the household",
"kind": "family"
}'
Invite a user by email
curl -X POST "$PRIMECAL_API/api/user-groups/9/invites" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"email": "teammate@example.com",
"message": "Join the shared planning calendars."
}'
The response is intentionally generic. Do not use it to infer whether teammate@example.com has a PrimeCal account.
Invite a user and add them to your own plan
curl -X POST "$PRIMECAL_API/api/user-groups/9/invites" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "email": "wife@example.com", "addToMyAccount": true }'
Needs a plan with seats and one free - see GET /api/seats/capacity in the Invitations API.
Accept an invite
curl -X POST "$PRIMECAL_API/api/user-groups/invites/$INVITE_TOKEN/accept" \
-H "Authorization: Bearer $TOKEN"
The response is the group (as GET /api/user-groups/:id returns it) plus seatActivated: true|false. When it is true the account has the inviter's plan features from the next request.
Attach calendars to a group
curl -X POST "$PRIMECAL_API/api/user-groups/9/calendars" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"calendarIds": [5, 7],
"permission": "write"
}'
Response and Behavior Notes
- Group-shared calendars are represented by
CalendarSharerows withprincipalType: "group". - Direct user sharing uses the same
CalendarSharemodel withprincipalType: "user". - Effective calendar access includes owned calendars, direct user shares, and people-group shares.
- If multiple paths grant access to the same calendar, the highest permission applies:
admin>write>read. - Accepted group members see group-shared calendars through
GET /api/calendarswithout calling this API directly. - Every invitation is emailed to its address (an account or not) with Accept and Decline links to the app's Invitations page; an existing account also gets an in-app notification whose
data.deepLinkis/app/invitations. An address with no account yet finds the invitation waiting once it registers with that address (invites are matched bytargetUserId, else by the lower-cased email).
Best Practices
- Use people groups when several calendars should stay aligned under one member list and permission model.
- Keep direct user shares for one-off access.
- Build invite UIs around generic success states.
- Refresh both
/api/user-groupsand/api/calendarsafter accepting an invite. - Prefer
GET /api/notifications/catalogwhen building rule or preference editors for invite-related events.