Skip to main content
A common Volunteer integration pattern: define a group of users by some criteria rather than by manual selection — “all volunteers who served in 2024,” “everyone from the Marketing department,” “users who completed the food handler certification,” “first-time volunteers from the past 90 days.” VOMO Groups are perfect destinations for these query-derived collections, but Groups are snapshots, not self-updating dynamic queries. This recipe walks through the pattern: query criteria → user IDs → Group creation/update → ongoing maintenance. The recipe combines listing users with filters, Group management, and reconciliation into a complete query-to-Group workflow.

What you’ll build

A pattern for query-driven Groups that:
  • Accepts a query specification (criteria for membership)
  • Resolves the criteria to a set of VOMO User IDs
  • Creates a Group populated with those Users (or updates an existing one)
  • Maintains the Group over time via scheduled refreshes
  • Tracks what changed between refreshes (audit + monitoring)
  • Handles failure cases (deleted users, criteria changes, API failures)

When this recipe fits

ScenarioThis recipe fits
”Volunteers who served in 2024” Group for reporting
“Marketing department” Group mirrored from HR system✓ With external roster as the query source
”Recent first-time volunteers” Group for onboarding workflows
“Holders of the Food Handler certification” Group for eligibility✓ With Certificate data
Truly dynamic “users matching X right now” Group✗ Groups are snapshots — see the dynamic-query trap

The fundamental tension: snapshot vs. dynamic

Groups in VOMO are persistent collections — once you set members, those members stay until you explicitly change them. They’re not dynamic queries that re-evaluate on read. This means a “Group from a query” pattern has to choose:
PatternRefresh model
Snapshot GroupBuild once, accept that it ages
Periodically refreshed GroupRe-run the query daily/weekly/monthly, update members to match
Event-driven refreshRe-evaluate membership when triggering events occur
For most production integrations, periodically refreshed is the right pattern. Snapshots become stale; event-driven is hard to keep correct as edge cases accumulate.

The dynamic-query trap

A common request from customers: “Can we have a Group that automatically includes anyone who serves in 2025?” The answer: yes, but it’s a Group that you maintain on a schedule — not a Group that VOMO automatically updates. The integration’s value-add is the schedule + criteria evaluation. The customer sees “the Group is always current,” but under the hood your integration is doing periodic re-population. Set this expectation explicitly. A “self-updating Group” implies the integration is in the loop, not that VOMO has dynamic group functionality.

Architecture

Five components:
ComponentPurpose
Query specificationThe criteria definition + target Group ID + refresh cadence
Refresh schedulerTriggers re-evaluation on a schedule
Query resolverTranslates criteria into a current set of User IDs
Group managerCreates the Group if needed; replaces members on each refresh
Diff calculatorCompares previous and current member sets; produces an audit trail

Step 1: define the query specification

Each query-driven Group has a stored specification:
JavaScript
// Schema for query specifications
{
  id: 'spec-12345',
  customerId: 'cust-001',
  name: '2024 Active Volunteers',
  description: 'Anyone who served in 2024',
  criteria: {
    type: 'served_during',
    startDate: '2024-01-01',
    endDate: '2024-12-31',
  },
  vomoGroupId: 5678,       // Created on first refresh; updated on subsequent
  refreshCadence: 'daily', // or 'weekly', 'monthly', 'manual'
  createdAt: '2024-01-15T...',
  lastRefreshedAt: '2025-04-19T03:00:00Z',
  lastMemberCount: 247,
  enabled: true,
}
The criteria is structured — different criteria types call different resolver paths:
JavaScript
const CRITERIA_TYPES = {
  served_during: {
    description: 'Users who served between two dates',
    fields: ['startDate', 'endDate'],
  },
  created_after: {
    description: 'Users created after a date',
    fields: ['date'],
  },
  email_domain: {
    description: 'Users with emails in a specific domain',
    fields: ['domain'],
  },
  has_certificate: {
    description: 'Users who have earned a specific Certificate',
    fields: ['certificateId'],
  },
  external_roster: {
    description: 'Users matching an external HR roster',
    fields: ['rosterSource'],
  },
  combined: {
    description: 'AND/OR of multiple criteria',
    fields: ['subCriteria', 'operator'],
  },
};
Each criterion type maps to a resolver function.

Step 2: resolve the criteria to User IDs

JavaScript
async function resolveCriteria(customerId, criteria) {
  switch (criteria.type) {
    case 'served_during':   return resolveServedDuring(customerId, criteria);
    case 'created_after':   return resolveCreatedAfter(customerId, criteria);
    case 'email_domain':    return resolveEmailDomain(customerId, criteria);
    case 'has_certificate': return resolveHasCertificate(customerId, criteria);
    case 'external_roster': return resolveExternalRoster(customerId, criteria);
    case 'combined':        return resolveCombined(customerId, criteria);
    default: throw new Error(`Unknown criteria type: ${criteria.type}`);
  }
}

Resolver: “served during a time window”

The most common — and hardest, since participations aren’t directly queryable. Walk active Projects and check each Project Date’s participants:
JavaScript
async function resolveServedDuring(customerId, criteria) {
  const startDate = new Date(criteria.startDate);
  const endDate = new Date(criteria.endDate);

  const userIds = new Set();

  // 1. Find all Projects with Dates in the window
  const params = new URLSearchParams({
    dates_after: startDate.toISOString(),
    dates_before: endDate.toISOString(),
    published: 'true',
  });

  const projects = await paginate(
    `https://api.vomo.org/v1/projects?${params}`,
    customerId
  );

  // 2. For each Project, walk its Dates in the window
  for (const projectSummary of projects) {
    const project = await getProjectDetail(projectSummary.id, customerId);
    const relevantDates = (project.all_dates ?? []).filter((d) => {
      const dStart = new Date(d.starts_at);
      return dStart >= startDate && dStart <= endDate;
    });

    for (const date of relevantDates) {
      const dateDetail = await getProjectDate(date.id, customerId);
      for (const participant of dateDetail.data?.participants ?? []) {
        userIds.add(participant.id);
      }
    }
  }

  return [...userIds];
}
Cost: Substantial. For an active customer, this is many API requests per resolution. Cache the intermediate results (Project detail, Project Date detail) and pace the resolver.

Resolver: “users created after a date”

Much cheaper — single filter on /users:
JavaScript
async function resolveCreatedAfter(customerId, criteria) {
  const params = new URLSearchParams({ created_after: criteria.date });
  const users = await paginate(
    `https://api.vomo.org/v1/users?${params}`,
    customerId
  );
  return users.map((u) => u.id);
}

Resolver: “email domain”

JavaScript
async function resolveEmailDomain(customerId, criteria) {
  const params = new URLSearchParams({ email_like: `@${criteria.domain}` });
  const matches = await paginate(
    `https://api.vomo.org/v1/users?${params}`,
    customerId
  );

  // Secondary filter — email_like is substring; we want exact domain match
  return matches
    .filter((u) => u.email?.toLowerCase().endsWith(`@${criteria.domain.toLowerCase()}`))
    .map((u) => u.id);
}

Resolver: “has certificate”

The earned-certificate data shape on UserDetailResource is undocumented in the OpenAPI spec. The resolver below assumes an earned_certificates array on the detail response. Confirm against live data before relying on it.
JavaScript
async function resolveHasCertificate(customerId, criteria) {
  const allUsers = await paginate('https://api.vomo.org/v1/users', customerId);

  const matched = [];
  for (const user of allUsers) {
    const detail = await getUserDetail(user.id, customerId);
    const earned = detail.earned_certificates ?? []; // confirm field name
    if (earned.some((c) => c.certificate_id === criteria.certificateId)) {
      matched.push(user.id);
    }
  }
  return matched;
}
This is N+1. For large accounts, consider caching the certificate-by-user mapping in your integration’s state DB.

Resolver: “external roster”

JavaScript
async function resolveExternalRoster(customerId, criteria) {
  const externalRecords = await fetchExternalRoster(customerId, criteria.rosterSource);

  const vomoIds = [];
  for (const externalRecord of externalRecords) {
    const user = await findUserByEmail(externalRecord.email, customerId);
    if (user) {
      vomoIds.push(user.id);
    } else {
      // Optionally upsert into VOMO
      const result = await upsertVomoUser(customerId, externalRecord);
      vomoIds.push(result.user.id);
    }
  }
  return vomoIds;
}
The find-or-create-then-add pattern: external system is authoritative for membership; VOMO is the destination.

Resolver: combined criteria

JavaScript
async function resolveCombined(customerId, criteria) {
  const sets = await Promise.all(
    criteria.subCriteria.map((sub) => resolveCriteria(customerId, sub))
  );

  if (criteria.operator === 'AND') {
    return sets.reduce((acc, ids) => {
      const idSet = new Set(ids);
      return acc.filter((id) => idSet.has(id));
    });
  }

  if (criteria.operator === 'OR') {
    const union = new Set();
    for (const ids of sets) for (const id of ids) union.add(id);
    return [...union];
  }

  throw new Error(`Unknown combine operator: ${criteria.operator}`);
}
AND and OR cover most needs. NOT is typically expressed as “in set A but not in set B” — a derived combination.

Step 3: create or update the Group

JavaScript
async function refreshGroupFromQuery(customerId, specId) {
  const spec = await db.getQuerySpec(customerId, specId);
  if (!spec.enabled) return { skipped: true, reason: 'disabled' };

  const traceId = generateTraceId();
  await jobLog.start(customerId, 'group_refresh', { specId, traceId });

  try {
    // 1. Resolve the criteria
    const targetUserIds = await resolveCriteria(customerId, spec.criteria);

    // 2. Determine create vs. update
    let previousMembers = [];
    if (!spec.vomoGroupId) {
      // First refresh — create the Group
      const created = await createGroup(customerId, {
        name: spec.name,
        description: spec.description,
        members: targetUserIds,
      });
      spec.vomoGroupId = created.data.id;
      await db.saveQuerySpec(customerId, spec);
    } else {
      // Existing Group — get current members for diff
      previousMembers = (await getGroupMembers(spec.vomoGroupId, customerId))
        .map((m) => m.id);

      await replaceGroupMembers(customerId, spec.vomoGroupId, targetUserIds);
    }

    // 3. Compute diff
    const previousSet = new Set(previousMembers);
    const currentSet = new Set(targetUserIds);
    const added = targetUserIds.filter((id) => !previousSet.has(id));
    const removed = previousMembers.filter((id) => !currentSet.has(id));

    // 4. Audit log
    await db.insert('group_refresh_audit', {
      customer_id: customerId,
      spec_id: specId,
      vomo_group_id: spec.vomoGroupId,
      previous_count: previousMembers.length,
      new_count: targetUserIds.length,
      added_count: added.length,
      removed_count: removed.length,
      added_user_ids: added,
      removed_user_ids: removed,
      trace_id: traceId,
      refreshed_at: new Date(),
    });

    // 5. Update spec
    await db.upsert('query_specs', {
      id: specId,
      last_refreshed_at: new Date(),
      last_member_count: targetUserIds.length,
    });

    await jobLog.complete(customerId, 'group_refresh', {
      specId,
      traceId,
      result: {
        memberCount: targetUserIds.length,
        addedCount: added.length,
        removedCount: removed.length,
      },
    });

    return {
      vomoGroupId: spec.vomoGroupId,
      memberCount: targetUserIds.length,
      added: added.length,
      removed: removed.length,
    };
  } catch (err) {
    await jobLog.fail(customerId, 'group_refresh', { specId, traceId, error: err.message });
    throw err;
  }
}
The audit log captures what changed at each refresh — useful for customer-facing transparency and debugging.

Step 4: schedule the refresh

JavaScript
async function scheduleGroupRefreshes() {
  const allActiveSpecs = await db.getAllActiveQuerySpecs();

  for (const spec of allActiveSpecs) {
    switch (spec.refreshCadence) {
      case 'hourly':
        await scheduler.cron(`group_refresh:${spec.id}`, '0 * * * *',
          () => refreshGroupFromQuery(spec.customerId, spec.id));
        break;
      case 'daily':
        await scheduler.cron(`group_refresh:${spec.id}`, '0 3 * * *',
          () => refreshGroupFromQuery(spec.customerId, spec.id));
        break;
      case 'weekly':
        await scheduler.cron(`group_refresh:${spec.id}`, '0 3 * * 0',
          () => refreshGroupFromQuery(spec.customerId, spec.id));
        break;
      case 'monthly':
        await scheduler.cron(`group_refresh:${spec.id}`, '0 3 1 * *',
          () => refreshGroupFromQuery(spec.customerId, spec.id));
        break;
      case 'manual':
        // No schedule — refresh only on explicit trigger
        break;
    }
  }
}

Right-sizing cadence by criteria type

CriteriaSuggested cadence
”Created after X” (static date)Daily — new users added; never removed
”Email domain”Daily — emails change rarely
”Served during 2024” (historical window)Weekly — window is closed
”Served in the last 90 days” (rolling window)Daily — window moves forward each day
”Has Certificate X”Daily — certificates earned periodically
”External roster”Hourly to daily depending on source frequency
Match cadence to how often the underlying data actually changes. Hourly refresh of “users with @wayne.example email” is wasteful.

Step 5: customer-facing visibility

JavaScript
async function getGroupHealth(customerId, specId) {
  const spec = await db.getQuerySpec(customerId, specId);
  const recent = await db.getRecentRefreshes(customerId, specId, 30);

  return {
    spec: {
      name: spec.name,
      description: spec.description,
      criteria: spec.criteria,
      refreshCadence: spec.refreshCadence,
    },
    currentState: {
      vomoGroupId: spec.vomoGroupId,
      memberCount: spec.lastMemberCount,
      lastRefreshedAt: spec.lastRefreshedAt,
      ageMinutes: Math.round((Date.now() - spec.lastRefreshedAt.getTime()) / 60000),
    },
    recentHistory: recent.map((r) => ({
      refreshedAt: r.refreshed_at,
      memberCount: r.new_count,
      delta: r.new_count - r.previous_count,
      added: r.added_count,
      removed: r.removed_count,
    })),
  };
}
The history shows “47 yesterday; 52 today; +6 new; -1 removed” — a clear activity signal.

Common queries with full specifications

Volunteers who served in the last 90 days

JavaScript
const spec = {
  name: 'Recent Active Volunteers',
  description: 'Anyone who served in the past 90 days',
  criteria: {
    type: 'served_during',
    startDate: () => daysAgo(90).toISOString(),
    endDate: () => new Date().toISOString(),
  },
  refreshCadence: 'daily',
};
The dynamic startDate re-evaluates at each refresh — the window moves forward each day.

First-time volunteers in 2025

JavaScript
const spec = {
  name: 'First-Time Volunteers (2025)',
  description: 'Users created in 2025 with at least one participation',
  criteria: {
    type: 'combined',
    operator: 'AND',
    subCriteria: [
      { type: 'created_after', date: '2025-01-01' },
      {
        type: 'served_during',
        startDate: '2025-01-01',
        endDate: () => new Date().toISOString(),
      },
    ],
  },
  refreshCadence: 'daily',
};

External HR roster sync

JavaScript
const spec = {
  name: 'Marketing Department Volunteers',
  description: 'Marketing dept employees, mirrored from Workday',
  criteria: {
    type: 'external_roster',
    rosterSource: 'workday_marketing_department',
  },
  refreshCadence: 'hourly',
};

Handling failures

Resolver failures

JavaScript
async function refreshGroupFromQueryWithFailureHandling(customerId, specId) {
  try {
    return await refreshGroupFromQuery(customerId, specId);
  } catch (err) {
    await db.upsert('query_specs', {
      id: specId,
      last_refresh_failed_at: new Date(),
      last_refresh_error: err.message,
    });

    await alertOps({
      severity: 'medium',
      customerId,
      specId,
      type: 'group_refresh_failed',
      error: err.message,
    });

    // Don't auto-retry — queries may be expensive; surface for investigation
    throw err;
  }
}

Detecting deleted users in resolved sets

If the resolver returns User IDs that no longer exist in VOMO, the PUT to Group members will fail validation. Filter before pushing:
JavaScript
async function filterValidUserIds(customerId, userIds) {
  const valid = [];
  const invalid = [];

  for (const id of userIds) {
    const exists = await checkUserExists(customerId, id);
    if (exists) valid.push(id);
    else invalid.push(id);
  }

  if (invalid.length > 0) {
    await alertOps({
      severity: 'low',
      customerId,
      type: 'deleted_users_in_query',
      invalidCount: invalid.length,
    });
  }

  return valid;
}
For large user lists, this is expensive. Alternative: don’t pre-filter; let the PUT fail; capture errors and retry without the missing IDs.

Things to watch for

The resolver determines the cost

“users created after 2024-01-01” = one paginated query. “served during 2024” = many Project Date fetches. Choose the criteria type with the cost in mind.

Member churn signals issues

If a Group’s member count swings dramatically between refreshes (247 yesterday, 12 today), something is wrong:
  • The query criteria changed unexpectedly
  • The underlying data changed (mass deletion, organization restructure)
  • The resolver has a bug
Alert on >50% swing in member count between consecutive refreshes.

Group hierarchy considerations

If the target Group has a parent_id (is part of a hierarchy), the refresh shouldn’t disturb that. The PUT to /groups/{id}/members replaces only the member list — but PUT to /groups/{id} (for metadata) requires preserving parent_id. Use the GET-then-PUT pattern.

Email-based external rosters and the email-change problem

For external-roster criteria, email matches users. If an email changes externally but not in VOMO (or vice versa), the user may disappear from the Group on the next refresh. Track external IDs separately from emails. See The email-change problem.

Rate-limit pressure during resolution

Resolvers like served_during make many API calls. For partner integrations refreshing many specs simultaneously, stagger the schedule:
JavaScript
async function staggeredRefreshAllSpecs() {
  const specs = await db.getActiveSpecs();
  for (let i = 0; i < specs.length; i++) {
    setTimeout(
      () => refreshGroupFromQuery(specs[i].customerId, specs[i].id),
      i * 30 * 1000 // 30 seconds apart
    );
  }
}

What you’ve built

After this recipe:
  • ✅ A query specification model (criteria + target Group + cadence)
  • ✅ Resolver functions for common criteria types
  • ✅ A refresh pipeline that creates/updates Groups idempotently
  • ✅ A diff calculator producing per-refresh audit data
  • ✅ Scheduled refresh with right-sized cadences
  • ✅ Customer-facing visibility into refresh history
  • ✅ Failure handling that surfaces problems for review
This is the foundation for any “Group from query” workflow — historical-volunteer Groups, eligibility Groups, department-mirror Groups.

Where to go next

Report on Volunteer Hours

The reporting recipe using participation data.

Combine Volunteer Data with CRM+ Data

The cross-API recipe stitching Volunteer with CRM+.

Manage Groups and Members

The workflow page this recipe builds on.

Sync Users to External System

The companion recipe — user sync as the foundation.
Last modified on May 22, 2026