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

# Manage Groups and Members

> The full operational walkthrough for Groups — the only resource family with full CRUD plus dedicated member management. Covers create, update, member sync, hierarchies, deletion, and the dual-write patterns.

Groups are the most write-heavy resource in the Volunteer API. They're the canonical path for programmatically organizing volunteers into stable collections — teams, affinity groups, departmental cohorts — and they have full CRUD plus a dedicated member-management surface. If your integration needs to manage who's part of what, Groups are where most of the work happens.

This workflow page walks through the operational patterns: creating Groups (with or without initial members), updating metadata, syncing membership rosters, managing parent-child hierarchies, and the dual-write patterns for keeping Group state and member state in sync.

If you haven't yet, skim the [Groups concept page](/volunteer/concepts/groups) for the field reference and endpoint inventory.

## When to use this workflow

| Scenario                                                                       | This workflow fits                                                                                   |
| ------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------- |
| Reflect an external team structure in VOMO                                     | ✓                                                                                                    |
| Build Groups from query results (e.g., "all volunteers who served in 2024")    | ✓                                                                                                    |
| Maintain corporate volunteer cohorts (e.g., "Marketing Department Volunteers") | ✓                                                                                                    |
| Programmatically clean up old Groups                                           | ✓ Use DELETE with care                                                                               |
| Add a user to a Group                                                          | ✓ Use the GET-then-PUT membership pattern                                                            |
| Remove a user from a Group                                                     | ✓ Same pattern                                                                                       |
| Group volunteers by Project participation                                      | Partially — Groups are stable; Project-participation grouping is more naturally a query, not a Group |
| Create a "self-updating" Group based on criteria                               | ✗ Groups are snapshots, not dynamic queries — rebuild periodically                                   |

***

## The seven endpoints

| Endpoint               | Method | What it does                         |
| ---------------------- | ------ | ------------------------------------ |
| `/groups`              | GET    | List Groups                          |
| `/groups`              | POST   | Create a Group                       |
| `/groups/{id}`         | GET    | Get a single Group                   |
| `/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 full member list |

The split between Group endpoints (`/groups/{id}`) and member endpoints (`/groups/{id}/members`) lets you manage metadata and membership independently — useful when these change at different cadences or come from different sources.

***

## Lifecycle: create → update → manage members → delete

The four operational phases for a typical Group's lifetime:

```mermaid theme={null}
graph LR
  Create["1. Create"]
  Update["2. Update metadata"]
  Members["3. Manage members"]
  Delete["4. Delete"]

  Create --> Update
  Update --> Members
  Members --> Update
  Update --> Delete
```

In practice, Groups spend most of their lifetime in the "manage members" phase — adding new volunteers, removing departed ones, periodically syncing rosters. Create and delete happen rarely; member sync is the most common operation.

***

## Phase 1: Create a Group

```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 serve at the weekly food bank shifts",
    "member_moniker": "Helper",
    "subgroup_moniker": "Team",
    "members": [12345, 12346, 12347]
  }'
```

<Warning>
  **⚠️ Spec gap (audit #21):** `POST /groups` returns `200 OK` on success — not `201 Created` per HTTP convention for resource creation. Check for `200` (or any 2xx) as success.
</Warning>

### Creating with initial members

The `members` array in the request body lets you populate the Group at creation time:

```javascript JavaScript theme={null}
async function createGroupWithMembers(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,
      parent_id: groupData.parentId,
      members: groupData.memberUserIds, // array of VOMO User IDs
    }),
  });

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

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

This is the most efficient way to set up a Group from scratch — one request handles both the Group creation and the initial roster, rather than create-then-PUT-members.

### Creating a child Group

To create a Group as a child of an existing one, provide the parent's ID:

```javascript JavaScript theme={null}
const newChildGroup = await createGroupWithMembers({
  name: 'Saturday Food Bank Crew',
  description: 'Volunteers for the Saturday morning food bank shift',
  parentId: 100, // ID of the parent Group
  memberUserIds: [12345, 12346],
});
```

The new Group becomes a child of Group 100. See [Phase 5: Managing hierarchies](#phase-5-managing-hierarchies) for working with parent-child relationships.

***

## Phase 2: Update Group metadata

`PUT /groups/{id}` is full-record replacement. Use the GET-then-PUT pattern to avoid clearing fields by omission:

```javascript JavaScript theme={null}
async function updateGroupDescription(groupId, newDescription) {
  // 1. Fetch current state
  const currentGroup = await getGroupById(groupId);
  if (!currentGroup) throw new Error(`Group ${groupId} not found`);

  // 2. Fetch current members (needed for the PUT body)
  const currentMembers = await getGroupMembers(groupId);

  // 3. PUT the full record with the description change
  const response = await fetch(
    `https://api.vomo.org/v1/groups/${groupId}`,
    {
      method: 'PUT',
      headers: {
        Authorization: `Bearer ${token}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        name: currentGroup.name,
        description: newDescription, // The changed field
        member_moniker: currentGroup.member_moniker,
        subgroup_moniker: currentGroup.subgroup_moniker,
        parent_id: currentGroup.parent_id,
        members: currentMembers.map((m) => m.id),
      }),
    }
  );

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

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

### Updating metadata only (without re-PUT-ing members)

If you want to update metadata but the Group's member list is large or unstable, two options:

**Option A: separate the metadata update from the members update.**

The `members` field is **required** on `PUT /groups/{id}` — you can't omit it. But you can re-PUT the current members (effectively a no-op for membership) while changing metadata:

```javascript JavaScript theme={null}
async function updateGroupMetadata(groupId, metadataChanges) {
  const current = await getGroupById(groupId);
  const members = await getGroupMembers(groupId);

  return fetch(`https://api.vomo.org/v1/groups/${groupId}`, {
    method: 'PUT',
    headers: {
      Authorization: `Bearer ${token}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      ...current,
      ...metadataChanges,
      members: members.map((m) => m.id),
    }),
  });
}
```

**Option B: cache the members for the duration of the update window.**

For workflows doing multiple metadata updates in a row, cache the members list to avoid re-fetching:

```javascript JavaScript theme={null}
class GroupUpdater {
  constructor({ token }) {
    this.token = token;
    this.memberCache = new Map(); // groupId → members[]
  }

  async getMembersCached(groupId) {
    if (this.memberCache.has(groupId)) {
      return this.memberCache.get(groupId);
    }
    const members = await getGroupMembers(groupId);
    this.memberCache.set(groupId, members);
    return members;
  }

  async updateMetadata(groupId, metadataChanges) {
    const current = await getGroupById(groupId);
    const members = await this.getMembersCached(groupId);

    const response = await fetch(`https://api.vomo.org/v1/groups/${groupId}`, {
      method: 'PUT',
      headers: { /* ... */ },
      body: JSON.stringify({
        ...current,
        ...metadataChanges,
        members: members.map((m) => m.id),
      }),
    });

    return response.json();
  }

  invalidateCache(groupId) {
    this.memberCache.delete(groupId);
  }
}
```

Invalidate the cache after any operation that changes membership (a member-list PUT, a single add/remove, etc.).

***

## Phase 3: Manage members

For dedicated membership changes — separate from metadata — use `PUT /groups/{id}/members`. This endpoint takes only a `members` array (no metadata fields).

```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] }'
```

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

### Replacement semantics — what this PUT really does

The PUT **replaces the entire member list with what you submit**. Implications:

| Operation                            | What the PUT looks like                        |
| ------------------------------------ | ---------------------------------------------- |
| Add user `99999` to existing members | PUT the existing IDs plus `99999`              |
| Remove user `12347`                  | PUT the existing IDs minus `12347`             |
| Replace the whole roster             | PUT only the new IDs (old members are removed) |
| Clear all members                    | PUT `{ "members": [] }`                        |

This is **not** an "add these members" PUT — it's a "the Group's members are now these IDs" PUT.

### Add a single member (GET-then-PUT 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. Check if already a member (idempotency)
  if (currentIds.includes(newUserId)) {
    return { changed: false, reason: 'already_member' };
  }

  // 3. Build the new list (union)
  const newList = [...currentIds, newUserId];

  // 4. PUT
  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}`);

  return { changed: true, newMemberCount: newList.length };
}
```

### Remove a single member (GET-then-PUT subset)

```javascript JavaScript theme={null}
async function removeMemberFromGroup(groupId, userIdToRemove) {
  const current = await getGroupMembers(groupId);
  const currentIds = current.map((m) => m.id);

  if (!currentIds.includes(userIdToRemove)) {
    return { changed: false, reason: 'not_member' };
  }

  const newList = currentIds.filter((id) => id !== userIdToRemove);

  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}`);
  return { changed: true, newMemberCount: newList.length };
}
```

### Batch multiple changes

For workflows that need to add and remove multiple members in one operation:

```javascript JavaScript theme={null}
async function applyMemberChanges(groupId, toAdd = [], toRemove = []) {
  const current = await getGroupMembers(groupId);
  const currentIds = new Set(current.map((m) => m.id));

  // Apply removes
  for (const id of toRemove) currentIds.delete(id);

  // Apply adds
  for (const id of toAdd) currentIds.add(id);

  const newList = Array.from(currentIds);

  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}`);
  return { membersAfter: newList };
}
```

This is more efficient than separate add/remove calls — one GET, one PUT, all changes applied atomically.

***

## Phase 4: Sync rosters from an external system

The most common operational pattern: a partner integration mirrors a team roster from an external HR/CRM system into a VOMO Group.

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

  // 2. For each external roster entry, find or create the corresponding VOMO user
  const desiredUserIds = [];
  for (const externalRecord of externalRoster) {
    const email = externalRecord.email.toLowerCase();

    const existing = currentByEmail.get(email);
    if (existing) {
      // Already a member — keep
      desiredUserIds.push(existing.id);
      continue;
    }

    // Not currently a member — 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 new member list — atomically replaces all members
  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: desiredUserIds }),
    }
  );

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

  return {
    membersBefore: currentMembers.length,
    membersAfter: desiredUserIds.length,
    added: desiredUserIds.filter((id) => !currentByEmail.has(getEmailFor(id))).length,
    removed: currentMembers.filter((m) => !desiredUserIds.includes(m.id)).length,
  };
}
```

This single sync handles three cases atomically:

| Case                                    | Behavior                           |
| --------------------------------------- | ---------------------------------- |
| External record matches current member  | Keep                               |
| External record is new                  | Add (creating VOMO user if needed) |
| Current member isn't in external roster | Remove                             |

The PUT is atomic from the API's perspective — partial failure is unlikely, and the resulting state matches the external roster exactly.

### Scheduled roster sync

For ongoing maintenance (run nightly or hourly):

```javascript JavaScript theme={null}
async function scheduledRosterSync(customerId) {
  const config = await getRosterSyncConfig(customerId);

  for (const groupConfig of config.groups) {
    try {
      const externalRoster = await fetchExternalRoster(groupConfig.externalRosterId);
      const result = await syncRosterToGroup(groupConfig.vomoGroupId, externalRoster);

      await recordSyncResult({
        customerId,
        groupId: groupConfig.vomoGroupId,
        result,
        syncedAt: new Date(),
      });
    } catch (err) {
      console.error(`Sync failed for Group ${groupConfig.vomoGroupId}:`, err);
      await recordSyncFailure({
        customerId,
        groupId: groupConfig.vomoGroupId,
        error: err.message,
      });
    }
  }
}
```

Per-Group failures don't stop the whole sync — log them and continue.

***

## Phase 5: Managing hierarchies

For organizations with structured team hierarchies, build parent-child Groups that mirror the external structure:

```javascript JavaScript theme={null}
async function syncTeamHierarchy(customerId, externalTeams) {
  // 1. Sort teams so parents come before children (topological sort)
  const sorted = topologicalSort(externalTeams);

  // 2. Track external → VOMO ID mapping as we go
  const externalToVomoId = new Map();

  // 3. Create or update each team
  for (const team of sorted) {
    const parentVomoId = team.parentExternalId
      ? externalToVomoId.get(team.parentExternalId)
      : null;

    const existingVomoId = await getExistingVomoId(customerId, team.externalId);

    if (existingVomoId) {
      // Update existing
      await updateGroup(existingVomoId, {
        name: team.name,
        description: team.description,
        parent_id: parentVomoId,
        members: team.memberUserIds,
      });
      externalToVomoId.set(team.externalId, existingVomoId);
    } else {
      // Create new
      const result = await createGroupWithMembers({
        name: team.name,
        description: team.description,
        parentId: parentVomoId,
        memberUserIds: team.memberUserIds,
      });
      externalToVomoId.set(team.externalId, result.data.id);

      // Record the mapping for future syncs
      await recordGroupMapping({
        customerId,
        externalId: team.externalId,
        vomoGroupId: result.data.id,
      });
    }
  }
}

function topologicalSort(teams) {
  // Returns teams ordered so parents come before children
  const byId = new Map(teams.map((t) => [t.externalId, t]));
  const visited = new Set();
  const result = [];

  function visit(team) {
    if (visited.has(team.externalId)) return;
    visited.add(team.externalId);

    if (team.parentExternalId && byId.has(team.parentExternalId)) {
      visit(byId.get(team.parentExternalId));
    }

    result.push(team);
  }

  teams.forEach(visit);
  return result;
}
```

The topological sort matters because creating a child Group requires its parent to already exist in VOMO. Without sorting, you'd get "parent not found" errors.

### Moving a Group to a different parent

```javascript JavaScript theme={null}
async function moveGroupToNewParent(groupId, newParentId) {
  return updateGroupField(groupId, 'parent_id', newParentId);
}
```

Changing `parent_id` moves the Group in the hierarchy. This affects:

* The Group's location in tree displays
* Any reporting that aggregates by Group hierarchy
* Potentially the Group's accessibility (if access is hierarchical)

The exact downstream behavior isn't documented in the spec — confirm against live behavior for production workflows that depend on hierarchical access.

***

## Phase 6: Deletion

`DELETE /groups/{id}` removes the Group:

```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.status !== 200 && response.status !== 204) {
    throw new Error(`Delete failed: ${response.status}`);
  }
  return true;
}
```

<Warning>
  **⚠️ Spec gap (audit #22):** `DELETE /groups/{id}` returns `200 OK`, not the HTTP-conventional `204 No Content` for empty-body deletes. Code should accept either as success.
</Warning>

### Deletion considerations

| Consideration                 | Description                                                                    |
| ----------------------------- | ------------------------------------------------------------------------------ |
| Deleting a Group with members | Removes the membership records; doesn't delete the Users themselves            |
| Deleting a parent Group       | Behavior on child Groups isn't documented — may delete, may orphan, may reject |
| Deletion is irreversible      | No "restore" via the API; coordinate with VOMO support if recovery is needed   |
| Auditing                      | Log every delete operation; the API doesn't preserve deleted Group history     |

For production workflows, prefer **archival to deletion** when possible:

```javascript JavaScript theme={null}
async function archiveGroup(groupId) {
  // Approach: rename the Group to indicate archival, clear members
  const current = await getGroupById(groupId);

  return fetch(`https://api.vomo.org/v1/groups/${groupId}`, {
    method: 'PUT',
    headers: { /* ... */ },
    body: JSON.stringify({
      ...current,
      name: `[ARCHIVED] ${current.name}`,
      members: [], // Clear membership
    }),
  });
}
```

This keeps the Group visible in reports (so historical data isn't lost) but functionally inactive.

***

## A reference Groups workflow client

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

  async createWithMembers(data) {
    const response = await this._fetch(`${this.baseUrl}/groups`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        name: data.name,
        description: data.description ?? '',
        member_moniker: data.memberMoniker,
        subgroup_moniker: data.subgroupMoniker,
        parent_id: data.parentId,
        created_by_user_id: data.createdByUserId,
        members: data.memberUserIds ?? [],
      }),
    });

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

  async updateMetadata(groupId, changes) {
    const current = await this._fetchGroup(groupId);
    const members = await this._fetchMembers(groupId);

    const body = {
      name: changes.name ?? current.name,
      description: changes.description ?? current.description,
      member_moniker: changes.memberMoniker ?? current.member_moniker,
      subgroup_moniker: changes.subgroupMoniker ?? current.subgroup_moniker,
      parent_id: changes.parentId ?? current.parent_id,
      members: members.map((m) => m.id), // Preserve membership
    };

    const response = await this._fetch(`${this.baseUrl}/groups/${groupId}`, {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(body),
    });

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

  async addMember(groupId, userId) {
    const members = await this._fetchMembers(groupId);
    const ids = members.map((m) => m.id);

    if (ids.includes(userId)) return { changed: false };

    return this._putMembers(groupId, [...ids, userId]);
  }

  async removeMember(groupId, userId) {
    const members = await this._fetchMembers(groupId);
    const ids = members.map((m) => m.id);

    if (!ids.includes(userId)) return { changed: false };

    return this._putMembers(groupId, ids.filter((id) => id !== userId));
  }

  async replaceMembers(groupId, userIds) {
    return this._putMembers(groupId, userIds);
  }

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

  async _putMembers(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 { changed: true, count: userIds.length };
  }

  async _fetchGroup(groupId) {
    const response = await this._fetch(`${this.baseUrl}/groups/${groupId}`);
    if (!response.ok) throw new Error(`Fetch failed: ${response.status}`);
    const result = await response.json();
    return result.data;
  }

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

    while (url) {
      const response = await this._fetch(url);
      if (!response.ok) throw new Error(`Fetch failed: ${response.status}`);
      const page = await response.json();
      members.push(...page.data);
      url = page.links.next;
    }
    return members;
  }

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

***

## Common bugs to avoid

A few patterns that cause subtle issues:

### Don't omit `members` from `PUT /groups/{id}` thinking it preserves membership

The members field is part of the request body for `PUT /groups/{id}`. Omitting it means submitting an undefined value — which may be interpreted as "clear all members" or rejected as a validation error. Always include the current member IDs (or use the dedicated `/members` endpoint).

### Don't use member emails instead of IDs

The `members` array in PUT bodies expects **integer User IDs**, not emails. If your external data is keyed by email, convert to IDs before submitting:

```javascript JavaScript theme={null}
// Get IDs for emails before the PUT
const userMap = await getUserIdsByEmails(['bruce@wayne.example', /* ... */]);
const memberIds = Array.from(userMap.values());

await replaceMembers(groupId, memberIds);
```

### Don't add members one at a time in a loop without throttling

Sequential single-add operations against a Group create N requests, each requiring a GET + PUT. For batches of changes, use `applyMemberChanges` or `replaceMembers` to consolidate into one PUT.

```javascript JavaScript theme={null}
// ❌ Anti-pattern — N round-trips
for (const userId of newUserIds) {
  await addMember(groupId, userId); // Each = 1 GET + 1 PUT
}

// ✅ Batch — 1 GET + 1 PUT
await replaceMembers(groupId, [...currentIds, ...newUserIds]);
```

***

## Where to go next

<CardGroup cols={2}>
  <Card title="Build a Group from a Query" icon="users" href="/volunteer/recipes/build-a-group-from-a-query">
    The end-to-end recipe that combines user-querying with Group population.
  </Card>

  <Card title="Read a Project's Schedule" icon="calendar-days" href="/volunteer/workflows/read-a-projects-schedule">
    The companion read workflow for Projects.
  </Card>

  <Card title="Groups" icon="users" href="/volunteer/concepts/groups">
    The reference page for Group resource fields and relationships.
  </Card>

  <Card title="Understand Write Limitations" icon="ban" href="/volunteer/workflows/understand-write-limitations">
    What can and can't be done with Groups (and other resources).
  </Card>
</CardGroup>
