Skip to main content
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 for the field reference and endpoint inventory.

When to use this workflow

ScenarioThis 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 participationPartially — 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

EndpointMethodWhat it does
/groupsGETList Groups
/groupsPOSTCreate a Group
/groups/{id}GETGet a single Group
/groups/{id}PUTUpdate a Group (full replacement)
/groups/{id}DELETEDelete a Group
/groups/{id}/membersGETList the Users in a Group
/groups/{id}/membersPUTReplace 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: 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

cURL
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]
  }'
⚠️ 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.

Creating with initial members

The members array in the request body lets you populate the Group at creation time:
JavaScript
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
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 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
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();
}
⚠️ 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.

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
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
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).
cURL
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] }'
⚠️ 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.

Replacement semantics — what this PUT really does

The PUT replaces the entire member list with what you submit. Implications:
OperationWhat the PUT looks like
Add user 99999 to existing membersPUT the existing IDs plus 99999
Remove user 12347PUT the existing IDs minus 12347
Replace the whole rosterPUT only the new IDs (old members are removed)
Clear all membersPUT { "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
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
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
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
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:
CaseBehavior
External record matches current memberKeep
External record is newAdd (creating VOMO user if needed)
Current member isn’t in external rosterRemove
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
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
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
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
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;
}
⚠️ 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.

Deletion considerations

ConsiderationDescription
Deleting a Group with membersRemoves the membership records; doesn’t delete the Users themselves
Deleting a parent GroupBehavior on child Groups isn’t documented — may delete, may orphan, may reject
Deletion is irreversibleNo “restore” via the API; coordinate with VOMO support if recovery is needed
AuditingLog every delete operation; the API doesn’t preserve deleted Group history
For production workflows, prefer archival to deletion when possible:
JavaScript
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
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
// 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
// ❌ 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

Build a Group from a Query

The end-to-end recipe that combines user-querying with Group population.

Read a Project's Schedule

The companion read workflow for Projects.

Groups

The reference page for Group resource fields and relationships.

Understand Write Limitations

What can and can’t be done with Groups (and other resources).
Last modified on May 22, 2026