Skip to main content
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

EndpointMethodWhat it does
/groupsGETList Groups (paginated, filterable)
/groupsPOSTCreate a Group
/groups/{id}GETGet a Group’s details
/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 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:
FieldTypeDescription
idintegerThe Group’s stable ID
namestringThe Group’s display name
descriptionstringThe Group’s description
has_subgroupsbooleanWhether this Group contains child groups
parent_idintegerThe parent Group’s ID (or null if top-level)
updated_atstringISO 8601 datetime
created_atstringISO 8601 datetime
⚠️ 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.

Parent-child group hierarchy

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:
WorkflowApproach
Find top-level GroupsQuery for Groups with parent_id = null (or filter ?parent_id= blank)
Find children of a GroupGET /groups?parent_id={id}
Build a tree viewRecursively fetch children for each parent
Detect leaf Groupshas_subgroups: false

Listing groups

cURL
curl "https://api.vomo.org/v1/groups?name_like=outreach" \
  -H "Authorization: Bearer $VOMO_API_TOKEN"

Available filters

ParameterWhat it filters by
name_likeSubstring match against Group name
parent_idGroups with a specific parent ID
created_beforeGroups created on or before a date
created_afterGroups created on or after a date
updated_beforeGroups updated on or before a date
updated_afterGroups 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
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
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.

Fetching a single group

cURL
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
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:
FieldTypeRequiredDescription
namestringYesThe Group’s display name
descriptionstringNoThe Group’s description
member_monikerstringNoCustom term for a member of this Group (e.g., “Captain”, “Buddy”)
subgroup_monikerstringNoCustom term for child Groups (e.g., “Squad”, “Team”)
created_by_user_idintegerNoThe User ID of the Group’s creator
parent_idintegerNoThe parent Group’s ID (creates a child Group)
membersarrayNoArray 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.

The moniker fields

VOMO Groups support custom monikers — display labels that override the default “member” and “subgroup” wording for that Group:
FieldExample 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.
cURL
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}:
FieldNotes
nameRequired (per HTTP PUT replacement semantics)
descriptionNew description
member_monikerUpdate the member display label
subgroup_monikerUpdate the subgroup display label
parent_idChange the Group’s parent (moves the Group in the hierarchy)
membersReplace 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.

Deleting a group

cURL
curl -X DELETE https://api.vomo.org/v1/groups/123 \
  -H "Authorization: Bearer $VOMO_API_TOKEN"
⚠️ 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:
ConsiderationDescription
Deleting a parent GroupThe 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 membersRemoves the Group’s member records; doesn’t delete the Users themselves
Restoring a deleted GroupNot exposed via the API — coordinate with VOMO support if recovery is needed
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.ok) throw new Error(`Delete failed: ${response.status}`);

  return true; // Deleted
}

Listing group members

cURL
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
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:
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]
  }'
Request body:
FieldTypeDescription
membersarray of integersThe complete new list of User IDs to be members
⚠️ 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.

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

Users

The User resource — Group members are Users.

Organizations and Org Family

The Organization hierarchy that Groups belong to.

Manage Groups and Members

The workflow-page walkthrough for Group management patterns.

The Volunteer Data Model

The full data model context for Groups.
Last modified on May 22, 2026