> ## Documentation Index
> Fetch the complete documentation index at: https://docs.virtuous.org/llms.txt
> Use this file to discover all available pages before exploring further.

# Groups

> The Group resource reference — the most write-heavy concept in the Volunteer API. Covers fields, the seven endpoints, parent-child group hierarchies, and the member-management patterns.

Groups are how Users get organized into collections — teams, affinity groups, departmental cohorts, or any other stable grouping. Unlike Projects (which represent volunteer opportunities) or Project Dates (specific occurrences), Groups persist independently of any particular event. They're long-lived "this person is part of this team" records.

Groups are also the most write-heavy resource in the Volunteer API. The seven Group endpoints support full CRUD plus a dedicated member-management pattern — making Groups the canonical path for programmatically organizing volunteers.

## The seven endpoints

| Endpoint               | Method | What it does                        |
| ---------------------- | ------ | ----------------------------------- |
| `/groups`              | GET    | List Groups (paginated, filterable) |
| `/groups`              | POST   | Create a Group                      |
| `/groups/{id}`         | GET    | Get a Group's details               |
| `/groups/{id}`         | PUT    | Update a Group (full replacement)   |
| `/groups/{id}`         | DELETE | Delete a Group                      |
| `/groups/{id}/members` | GET    | List the Users in a Group           |
| `/groups/{id}/members` | PUT    | Replace the Group's member list     |

These seven endpoints are the only write-heavy surface in the API. If your integration needs to organize Users into collections programmatically, Groups are the path.

***

## The Group resource

The `GroupResource` schema documents these fields:

| Field           | Type    | Description                                  |
| --------------- | ------- | -------------------------------------------- |
| `id`            | integer | The Group's stable ID                        |
| `name`          | string  | The Group's display name                     |
| `description`   | string  | The Group's description                      |
| `has_subgroups` | boolean | Whether this Group contains child groups     |
| `parent_id`     | integer | The parent Group's ID (or null if top-level) |
| `updated_at`    | string  | ISO 8601 datetime                            |
| `created_at`    | string  | ISO 8601 datetime                            |

<Note>
  **⚠️ Spec gap (audit #39):** `GroupResource.description` reads `"List of Groups"` — a copy-paste error. The schema describes a single Group, not a list. The description will be corrected in a future spec revision.
</Note>

### Parent-child group hierarchy

Groups can have parent-child relationships:

```mermaid theme={null}
graph TD
  Top["Wayne Foundation Volunteers<br/>(top-level)"]
  Sub1["Gotham Outreach Team<br/>(parent_id = Top)"]
  Sub2["Manor Programs Team<br/>(parent_id = Top)"]
  Sub3["Food Bank Crew<br/>(parent_id = Sub1)"]
  Sub4["Park Cleanup Crew<br/>(parent_id = Sub1)"]

  Top --> Sub1
  Top --> Sub2
  Sub1 --> Sub3
  Sub1 --> Sub4
```

The `parent_id` field on each Group identifies its parent. The `has_subgroups` boolean tells you whether a Group is itself a parent.

This lets the customer build organizational structures that match their actual team structure — a top-level "all volunteers" Group, regional sub-groups, and event-specific crews beneath those.

For partner integrations:

| Workflow                 | Approach                                                                 |
| ------------------------ | ------------------------------------------------------------------------ |
| Find top-level Groups    | Query for Groups with `parent_id = null` (or filter `?parent_id=` blank) |
| Find children of a Group | `GET /groups?parent_id={id}`                                             |
| Build a tree view        | Recursively fetch children for each parent                               |
| Detect leaf Groups       | `has_subgroups: false`                                                   |

***

## Listing groups

```bash cURL theme={null}
curl "https://api.vomo.org/v1/groups?name_like=outreach" \
  -H "Authorization: Bearer $VOMO_API_TOKEN"
```

### Available filters

| Parameter        | What it filters by                 |
| ---------------- | ---------------------------------- |
| `name_like`      | Substring match against Group name |
| `parent_id`      | Groups with a specific parent ID   |
| `created_before` | Groups created on or before a date |
| `created_after`  | Groups created on or after a date  |
| `updated_before` | Groups updated on or before a date |
| `updated_after`  | Groups updated on or after a date  |

Note: the spec does not document `page` as a parameter on `/groups`, but the response uses the standard `data`/`links`/`meta` envelope. Pagination almost certainly works the same as other list endpoints — follow `links.next`.

### Common list patterns

**Top-level Groups only:**

```javascript JavaScript theme={null}
async function topLevelGroups() {
  const allGroups = await paginate('https://api.vomo.org/v1/groups');
  return allGroups.filter((g) => !g.parent_id);
}
```

If the `parent_id` query parameter accepts a blank value to mean "null", this could be done server-side; otherwise filter client-side.

**Children of a specific Group:**

```javascript JavaScript theme={null}
async function childGroups(parentId) {
  const params = new URLSearchParams({ parent_id: parentId.toString() });
  return paginate(`https://api.vomo.org/v1/groups?${params}`);
}
```

**Build a full tree:**

```javascript JavaScript theme={null}
async function buildGroupTree() {
  const all = await paginate('https://api.vomo.org/v1/groups');
  const byParent = new Map();

  for (const group of all) {
    const parentId = group.parent_id ?? 'root';
    if (!byParent.has(parentId)) byParent.set(parentId, []);
    byParent.get(parentId).push(group);
  }

  function attachChildren(group) {
    const children = byParent.get(group.id) ?? [];
    return { ...group, children: children.map(attachChildren) };
  }

  return (byParent.get('root') ?? []).map(attachChildren);
}
```

For very deep hierarchies this is cheaper than recursively fetching children for each parent — one pass through all Groups, then assemble in memory.

***

## Fetching a single group

```bash cURL theme={null}
curl https://api.vomo.org/v1/groups/123 \
  -H "Authorization: Bearer $VOMO_API_TOKEN"
```

Returns the `GroupResource` for the specified ID wrapped in `data`.

```javascript JavaScript theme={null}
async function getGroupById(groupId) {
  const response = await fetch(
    `https://api.vomo.org/v1/groups/${groupId}`,
    { headers: { Authorization: `Bearer ${token}` } }
  );

  if (response.status === 404) return null;
  if (!response.ok) throw new Error(`Failed: ${response.status}`);

  const result = await response.json();
  return parseGroup(result.data);
}
```

The single-Group response doesn't include the members — those come from `GET /groups/{id}/members`.

***

## Creating a group

`POST /groups` creates a new Group. The request body accepts:

| Field                | Type    | Required | Description                                                       |
| -------------------- | ------- | -------- | ----------------------------------------------------------------- |
| `name`               | string  | Yes      | The Group's display name                                          |
| `description`        | string  | No       | The Group's description                                           |
| `member_moniker`     | string  | No       | Custom term for a member of this Group (e.g., "Captain", "Buddy") |
| `subgroup_moniker`   | string  | No       | Custom term for child Groups (e.g., "Squad", "Team")              |
| `created_by_user_id` | integer | No       | The User ID of the Group's creator                                |
| `parent_id`          | integer | No       | The parent Group's ID (creates a child Group)                     |
| `members`            | array   | No       | Array of User IDs to add as initial members                       |

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST https://api.vomo.org/v1/groups \
    -H "Authorization: Bearer $VOMO_API_TOKEN" \
    -H "Content-Type: application/json" \
    -d '{
      "name": "Food Bank Volunteers",
      "description": "Volunteers who help at the Saturday food bank",
      "member_moniker": "Helper",
      "subgroup_moniker": "Team",
      "parent_id": 100,
      "members": [12345, 12346, 12347]
    }'
  ```

  ```javascript JavaScript theme={null}
  async function createGroup(groupData) {
    const response = await fetch('https://api.vomo.org/v1/groups', {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${token}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        name: groupData.name,
        description: groupData.description,
        member_moniker: groupData.memberMoniker,
        subgroup_moniker: groupData.subgroupMoniker,
        created_by_user_id: groupData.createdByUserId,
        parent_id: groupData.parentId,
        members: groupData.memberUserIds,
      }),
    });

    if (response.status === 422) {
      const problem = await response.json();
      throw new ValidationError(problem.errors);
    }

    if (!response.ok) throw new Error(`Create failed: ${response.status}`);

    return response.json();
  }
  ```
</CodeGroup>

<Warning>
  **⚠️ Spec gap (audit #21):** The `POST /groups` endpoint returns `200 OK` on success, not `201 Created` (the HTTP-conventional code for resource creation). Code should expect `200` for a successful Group create.
</Warning>

The `members` array in the request lets you populate the Group at creation time. Without it, the Group is created empty and you'd separately call `PUT /groups/{id}/members` to add members.

### The moniker fields

VOMO Groups support custom monikers — display labels that override the default "member" and "subgroup" wording for that Group:

| Field              | Example values                              |
| ------------------ | ------------------------------------------- |
| `member_moniker`   | "Captain", "Buddy", "Crew Member", "Mentor" |
| `subgroup_moniker` | "Squad", "Team", "Chapter", "Cell"          |

When set, these labels appear in the VOMO admin UI in place of the generic terms. Useful for organizations with established team vocabulary.

***

## Updating a group

`PUT /groups/{id}` is a **full replacement** — submit the entire Group's data, not just the fields you want to change.

```bash cURL theme={null}
curl -X PUT https://api.vomo.org/v1/groups/123 \
  -H "Authorization: Bearer $VOMO_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Food Bank Volunteers (renamed)",
    "description": "Updated description",
    "member_moniker": "Helper",
    "subgroup_moniker": "Team",
    "parent_id": 100,
    "members": [12345, 12346, 12347, 12348]
  }'
```

Body fields accepted on `PUT /groups/{id}`:

| Field              | Notes                                                           |
| ------------------ | --------------------------------------------------------------- |
| `name`             | Required (per HTTP PUT replacement semantics)                   |
| `description`      | New description                                                 |
| `member_moniker`   | Update the member display label                                 |
| `subgroup_moniker` | Update the subgroup display label                               |
| `parent_id`        | Change the Group's parent (moves the Group in the hierarchy)    |
| `members`          | Replace the member list (members not in this array are removed) |

<Warning>
  **⚠️ Spec gap (audit #23):** The `PUT /groups/{id}` request body description in the spec reads `"Group to create"` — a copy-paste error from the POST endpoint. The actual behavior is full-record update, not creation.
</Warning>

Notice that `members` is accepted on `PUT /groups/{id}` — meaning a single update call can change both the Group's metadata AND its member list. This is convenient for full Group migrations.

For partial updates (modify only the description, leave everything else alone), use the GET-then-PUT pattern:

```javascript JavaScript theme={null}
async function renameGroup(groupId, newName) {
  // 1. Fetch current state
  const current = await getGroupById(groupId);
  const currentMembers = await getGroupMembers(groupId);

  // 2. Apply changes
  const updated = {
    ...current,
    name: newName,
    members: currentMembers.map((m) => m.id),
  };

  // 3. PUT the full record
  const response = await fetch(
    `https://api.vomo.org/v1/groups/${groupId}`,
    {
      method: 'PUT',
      headers: {
        Authorization: `Bearer ${token}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(updated),
    }
  );

  if (!response.ok) throw new Error(`Update failed: ${response.status}`);
  return response.json();
}
```

The GET-then-PUT pattern adds a round-trip per update but avoids accidentally clearing fields by omission.

***

## Deleting a group

```bash cURL theme={null}
curl -X DELETE https://api.vomo.org/v1/groups/123 \
  -H "Authorization: Bearer $VOMO_API_TOKEN"
```

<Warning>
  **⚠️ Spec gap (audit #22):** The `DELETE /groups/{id}` endpoint documents a `200 OK` response with no body, not the HTTP-conventional `204 No Content`. Code should check for `200` (or any 2xx) as success.
</Warning>

Deletes are typically irreversible. Practical considerations:

| Consideration                 | Description                                                                                                                                                              |
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Deleting a parent Group       | The behavior on children isn't documented in the spec — may delete children, may orphan them, may reject the delete. Confirm against live behavior before relying on it. |
| Deleting a Group with members | Removes the Group's member records; doesn't delete the Users themselves                                                                                                  |
| Restoring a deleted Group     | Not exposed via the API — coordinate with VOMO support if recovery is needed                                                                                             |

```javascript JavaScript theme={null}
async function deleteGroup(groupId) {
  const response = await fetch(
    `https://api.vomo.org/v1/groups/${groupId}`,
    {
      method: 'DELETE',
      headers: { Authorization: `Bearer ${token}` },
    }
  );

  if (response.status === 404) {
    return false; // Already gone
  }

  if (!response.ok) throw new Error(`Delete failed: ${response.status}`);

  return true; // Deleted
}
```

***

## Listing group members

```bash cURL theme={null}
curl https://api.vomo.org/v1/groups/123/members \
  -H "Authorization: Bearer $VOMO_API_TOKEN"
```

Returns the User records who are members of the Group. The response uses the standard `data`/`links`/`meta` envelope; each member entry is a User-shaped object.

```javascript JavaScript theme={null}
async function getGroupMembers(groupId) {
  const allMembers = [];
  let url = `https://api.vomo.org/v1/groups/${groupId}/members`;

  while (url) {
    const response = await fetch(url, {
      headers: { Authorization: `Bearer ${token}` },
    });
    if (!response.ok) throw new Error(`Failed: ${response.status}`);

    const page = await response.json();
    allMembers.push(...page.data);
    url = page.links.next;
  }

  return allMembers;
}
```

For Groups with many members, paginate the same way as other list endpoints.

***

## Replacing group members

`PUT /groups/{id}/members` replaces the Group's entire member list:

```bash cURL theme={null}
curl -X PUT https://api.vomo.org/v1/groups/123/members \
  -H "Authorization: Bearer $VOMO_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "members": [12345, 12346, 12347, 99999]
  }'
```

Request body:

| Field     | Type              | Description                                     |
| --------- | ----------------- | ----------------------------------------------- |
| `members` | array of integers | The complete new list of User IDs to be members |

<Warning>
  **⚠️ Spec gap (audit #24):** The `PUT /groups/{id}/members` request body description in the spec reads `"Group to create"` — another copy-paste error. The actual behavior is member list replacement.
</Warning>

### Replacement semantics

The PUT replaces the entire member list. Members not in the submitted array are removed from the Group. To add a single member to a Group without removing others, first GET the current members, then PUT the union:

```javascript JavaScript theme={null}
async function addMemberToGroup(groupId, newUserId) {
  // 1. Get current members
  const current = await getGroupMembers(groupId);
  const currentIds = current.map((m) => m.id);

  // 2. Build the new list (preserving existing)
  if (currentIds.includes(newUserId)) {
    return; // Already a member
  }
  const newList = [...currentIds, newUserId];

  // 3. PUT the full new list
  const response = await fetch(
    `https://api.vomo.org/v1/groups/${groupId}/members`,
    {
      method: 'PUT',
      headers: {
        Authorization: `Bearer ${token}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ members: newList }),
    }
  );

  if (!response.ok) throw new Error(`Update failed: ${response.status}`);
}

async function removeMemberFromGroup(groupId, userIdToRemove) {
  const current = await getGroupMembers(groupId);
  const newList = current
    .filter((m) => m.id !== userIdToRemove)
    .map((m) => m.id);

  const response = await fetch(
    `https://api.vomo.org/v1/groups/${groupId}/members`,
    {
      method: 'PUT',
      headers: {
        Authorization: `Bearer ${token}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ members: newList }),
    }
  );

  if (!response.ok) throw new Error(`Update failed: ${response.status}`);
}
```

The cost: every member change is a GET + PUT pair. For high-frequency membership changes, batch them — collect adds/removes over a window, then issue a single PUT with the final desired state.

### Bulk roster sync

For partner integrations syncing an external roster (e.g., a corporate volunteer program tracking who's eligible to participate), batch operations work well:

```javascript JavaScript theme={null}
async function syncRosterToGroup(groupId, externalRoster) {
  // 1. Get current members
  const current = await getGroupMembers(groupId);
  const currentEmails = new Map(current.map((m) => [m.email.toLowerCase(), m.id]));

  // 2. Look up VOMO User IDs for each roster entry
  const desiredUserIds = [];
  for (const externalRecord of externalRoster) {
    const email = externalRecord.email.toLowerCase();
    if (currentEmails.has(email)) {
      desiredUserIds.push(currentEmails.get(email));
    } else {
      // User doesn't exist as a Group member yet — find by email or create
      const user = await findUserByEmail(email);
      if (user) {
        desiredUserIds.push(user.id);
      } else {
        const result = await upsertUser({
          email,
          firstName: externalRecord.firstName,
          lastName: externalRecord.lastName,
        });
        desiredUserIds.push(result.user.id);
      }
    }
  }

  // 3. PUT the desired member list
  await fetch(`https://api.vomo.org/v1/groups/${groupId}/members`, {
    method: 'PUT',
    headers: {
      Authorization: `Bearer ${token}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ members: desiredUserIds }),
  });
}
```

This single PUT correctly handles all three cases: members staying, members being added, and members being removed. The synchronization is atomic from the API's perspective.

***

## Common workflows

### Build a Group from a query

For dynamically-built Groups (e.g., "all volunteers who participated in 2024"):

```javascript JavaScript theme={null}
async function buildAnnualVolunteerGroup(year, groupName) {
  // 1. Find all users with participations in the year
  const startDate = new Date(`${year}-01-01`);
  const endDate = new Date(`${year + 1}-01-01`);

  const activeUsers = await usersWithParticipationsInRange(startDate, endDate);

  // 2. Create the Group with these users as initial members
  const response = await fetch('https://api.vomo.org/v1/groups', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${token}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      name: groupName,
      description: `Volunteers who participated in ${year}`,
      members: activeUsers.map((u) => u.id),
    }),
  });

  return response.json();
}
```

The Group is a snapshot — building it from a query doesn't make it "self-updating." For ongoing maintenance, re-run periodically.

### Reflect external team structure in VOMO

For organizations with existing team structures in HR or other systems:

```javascript JavaScript theme={null}
async function syncTeamHierarchy(externalTeams) {
  const vomoGroupsByName = new Map();

  // 1. Create/update parent groups first
  for (const team of externalTeams.filter((t) => !t.parentId)) {
    const result = await upsertGroup(team.name, { description: team.description });
    vomoGroupsByName.set(team.name, result.id);
  }

  // 2. Create/update child groups, linking to parents
  for (const team of externalTeams.filter((t) => t.parentId)) {
    const parentName = externalTeams.find((t) => t.id === team.parentId)?.name;
    const parentVomoId = vomoGroupsByName.get(parentName);

    const result = await upsertGroup(team.name, {
      description: team.description,
      parent_id: parentVomoId,
    });
    vomoGroupsByName.set(team.name, result.id);
  }

  // 3. Sync members for each team
  for (const team of externalTeams) {
    const vomoGroupId = vomoGroupsByName.get(team.name);
    await syncRosterToGroup(vomoGroupId, team.members);
  }
}
```

A common pattern for partner integrations whose customers want their internal structure reflected in VOMO.

### Find Groups containing a specific User

There's no direct "Groups for User" endpoint — you fetch the User's group memberships through the User detail or by querying Groups and checking memberships:

```javascript JavaScript theme={null}
async function findGroupsContainingUser(userId) {
  const allGroups = await paginate('https://api.vomo.org/v1/groups');
  const groupsWithUser = [];

  for (const group of allGroups) {
    const members = await getGroupMembers(group.id);
    if (members.some((m) => m.id === userId)) {
      groupsWithUser.push(group);
    }
  }

  return groupsWithUser;
}
```

This is N+1 — one call per Group. For accounts with many Groups, it can be expensive. Consider whether the User-detail response already contains the Group memberships you need.

***

## A reference Groups client

```javascript JavaScript theme={null}
class VomoGroups {
  constructor({ token }) {
    this.token = token;
    this.baseUrl = 'https://api.vomo.org/v1';
  }

  async list(filters = {}) {
    const params = new URLSearchParams(filters);
    const groups = [];
    let url = `${this.baseUrl}/groups?${params}`;

    while (url) {
      const response = await this._fetch(url);
      const page = await response.json();
      groups.push(...page.data.map(this._parseGroup));
      url = page.links.next;
    }
    return groups;
  }

  async getById(groupId) {
    const response = await this._fetch(`${this.baseUrl}/groups/${groupId}`);
    if (response.status === 404) return null;
    if (!response.ok) throw new Error(`Failed: ${response.status}`);
    const result = await response.json();
    return this._parseGroup(result.data);
  }

  async create(data) {
    const response = await this._fetch(`${this.baseUrl}/groups`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(data),
    });
    if (!response.ok) throw new Error(`Create failed: ${response.status}`);
    return response.json();
  }

  async update(groupId, data) {
    const response = await this._fetch(`${this.baseUrl}/groups/${groupId}`, {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(data),
    });
    if (!response.ok) throw new Error(`Update failed: ${response.status}`);
    return response.json();
  }

  async delete(groupId) {
    const response = await this._fetch(`${this.baseUrl}/groups/${groupId}`, {
      method: 'DELETE',
    });
    return response.status === 200 || response.status === 204;
  }

  async getMembers(groupId) {
    const members = [];
    let url = `${this.baseUrl}/groups/${groupId}/members`;

    while (url) {
      const response = await this._fetch(url);
      const page = await response.json();
      members.push(...page.data);
      url = page.links.next;
    }
    return members;
  }

  async replaceMembers(groupId, userIds) {
    const response = await this._fetch(
      `${this.baseUrl}/groups/${groupId}/members`,
      {
        method: 'PUT',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ members: userIds }),
      }
    );
    if (!response.ok) throw new Error(`Update failed: ${response.status}`);
    return response.json();
  }

  _fetch(url, options = {}) {
    return fetch(url, {
      ...options,
      headers: {
        Authorization: `Bearer ${this.token}`,
        Accept: 'application/json',
        ...options.headers,
      },
    });
  }

  _parseGroup(raw) {
    return {
      id: raw.id,
      name: raw.name,
      description: raw.description ?? '',
      hasSubgroups: raw.has_subgroups ?? false,
      parentId: raw.parent_id ?? null,
      createdAt: new Date(raw.created_at),
      updatedAt: new Date(raw.updated_at),
    };
  }
}
```

***

## Where to go next

<CardGroup cols={2}>
  <Card title="Users" icon="user" href="/volunteer/concepts/users">
    The User resource — Group members are Users.
  </Card>

  <Card title="Organizations and Org Family" icon="sitemap" href="/volunteer/concepts/organizations-and-org-family">
    The Organization hierarchy that Groups belong to.
  </Card>

  <Card title="Manage Groups and Members" icon="users-gear" href="/volunteer/workflows/manage-groups-and-members">
    The workflow-page walkthrough for Group management patterns.
  </Card>

  <Card title="The Volunteer Data Model" icon="diagram-project" href="/volunteer/concepts/data-model">
    The full data model context for Groups.
  </Card>
</CardGroup>
