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.
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.
⚠️ 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.
Groups can have parent-child relationships: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)
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.
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
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
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.
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
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] }'
⚠️ 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.
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.
Change the Group’s parent (moves the Group in the hierarchy)
members
Replace the member list (members not in this array are removed)
⚠️ 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.
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
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.
⚠️ 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.
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
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
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.
⚠️ 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.
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
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.
For partner integrations syncing an external roster (e.g., a corporate volunteer program tracking who’s eligible to participate), batch operations work well:
JavaScript
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.
For dynamically-built Groups (e.g., “all volunteers who participated in 2024”):
JavaScript
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.
For organizations with existing team structures in HR or other systems:
JavaScript
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.
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
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.