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 for the field reference and endpoint inventory.
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.
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.
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.
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.
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:
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:
For dedicated membership changes — separate from metadata — use PUT /groups/{id}/members. This endpoint takes only a members array (no metadata fields).
⚠️ 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.
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:
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.
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.
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.
⚠️ 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.
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).
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 PUTconst 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-tripsfor (const userId of newUserIds) { await addMember(groupId, userId); // Each = 1 GET + 1 PUT}// ✅ Batch — 1 GET + 1 PUTawait replaceMembers(groupId, [...currentIds, ...newUserIds]);