Skip to main content
The most common Volunteer integration workflow is reading Users — for reporting, sync to external systems, or driving partner-built features. GET /users supports six filter parameters that cover most real-world scenarios. This page walks through the practical patterns: when to use which filter, how to combine them, and how to pair them with pagination and throttling for production-grade reads. If you haven’t yet, skim the Users concept page first — this workflow page builds on the field shapes and endpoint structure documented there.

When to use this workflow

ScenarioThis workflow fits
Build a “recently active volunteers” dashboard✓ Use updated_after
Pull monthly new-volunteer reports✓ Combine created_before and created_after
Search users by partial name in a UI✓ Use name_like
Filter to users with specific email domains✓ Use email_like (with care — see below)
Sync the full user dataset to an external system✓ No filter + pagination
Find a single user by exact emailUse the Find a User by Email workflow instead
Find a user when you have their IDUse GET /users/{id} instead — single-request, full detail

The six filter parameters

ParameterWhat it filters by
name_likeSubstring match against first OR last name (case-insensitive)
email_likeSubstring match against email (case-insensitive)
created_beforeUsers created on or before a date (ISO 8601)
created_afterUsers created on or after a date
updated_beforeUsers updated on or before a date
updated_afterUsers updated on or after a date
pagePage number for pagination (default 1)
All parameters are optional. Combining multiple parameters narrows the result set (AND logic — a user must match all filters).

Scenario 1: Recently active volunteers

Goal: find users whose record has been updated in the last 30 days.
JavaScript
async function recentlyActiveUsers() {
  const thirtyDaysAgo = new Date();
  thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);

  const params = new URLSearchParams({
    updated_after: thirtyDaysAgo.toISOString(),
  });

  const users = [];
  let url = `https://api.vomo.org/v1/users?${params}`;

  while (url) {
    const response = await fetch(url, {
      headers: { Authorization: `Bearer ${process.env.VOMO_API_TOKEN}` },
    });
    if (!response.ok) throw new Error(`Failed: ${response.status}`);

    const page = await response.json();
    users.push(...page.data);
    url = page.links.next;
  }

  return users;
}

What “updated” captures

The updated_at timestamp changes when the User record is modified — including:
  • Profile updates (name, email, phone, address, etc.)
  • Membership status changes
  • Form completions (likely — confirm against live behavior)
  • Profile field value updates
Note: updated_at does NOT necessarily change when the user participates in a Project Date. Participation creates a Participation record, not a direct User modification. To detect “users who participated recently,” query Project Dates and follow back to users, not users?updated_after.

Use this filter for change detection

This is the primary filter for incremental sync patterns:
JavaScript
async function incrementalUserSync(customerId) {
  const lastSync = await getCheckpoint(customerId, 'user_sync');

  const params = new URLSearchParams({
    updated_after: lastSync.toISOString(),
  });

  let url = `https://api.vomo.org/v1/users?${params}`;
  let lastUpdatedSeen = lastSync;

  while (url) {
    const page = await fetchPage(url);

    for (const user of page.data) {
      await externalSystem.upsertUser(user);
      const userUpdated = new Date(user.updated_at);
      if (userUpdated > lastUpdatedSeen) lastUpdatedSeen = userUpdated;
    }

    url = page.links.next;
  }

  await advanceCheckpoint(customerId, 'user_sync', lastUpdatedSeen);
}
Advance the checkpoint to the latest updated_at actually seen — not to new Date() — so an interrupted sync resumes correctly. See Pagination: Advancing the checkpoint correctly.

Scenario 2: Users created in a time window

Goal: users created within a specific calendar month.
JavaScript
async function newUsersForMonth(year, month) {
  const start = new Date(year, month - 1, 1);
  const end = new Date(year, month, 1); // first of next month

  const params = new URLSearchParams({
    created_after: start.toISOString(),
    created_before: end.toISOString(),
  });

  const users = [];
  let url = `https://api.vomo.org/v1/users?${params}`;

  while (url) {
    const response = await fetch(url, {
      headers: { Authorization: `Bearer ${token}` },
    });
    const page = await response.json();
    users.push(...page.data);
    url = page.links.next;
  }

  return users;
}

// Usage
const marchUsers = await newUsersForMonth(2025, 3);

The before vs after semantics

FilterBoundary inclusion
created_after: "2025-03-01T00:00:00Z"Users created on or after March 1
created_before: "2025-04-01T00:00:00Z"Users created on or before April 1
The exact inclusion of the boundary (≥ vs > on after, ≤ vs < on before) isn’t strictly specified. Using exclusive next-month-start for created_before (e.g., April 1 for “March users”) prevents accidentally including April records.

Combining with name or email filters

Filter parameters AND together — adding more narrows the result:
JavaScript
async function newWayneVolunteersForMonth(year, month) {
  const start = new Date(year, month - 1, 1);
  const end = new Date(year, month, 1);

  const params = new URLSearchParams({
    created_after: start.toISOString(),
    created_before: end.toISOString(),
    name_like: 'wayne',
  });

  return paginate(`https://api.vomo.org/v1/users?${params}`);
}
Useful for narrow targeted reports — “new Wayne family members onboarded in Q1.”

Scenario 3: Search users by name

Goal: find users matching a name fragment, typically for an interactive search box.
JavaScript
async function searchUsersByName(searchTerm) {
  if (searchTerm.length < 2) return []; // Avoid huge result sets

  const params = new URLSearchParams({ name_like: searchTerm });

  const response = await fetch(
    `https://api.vomo.org/v1/users?${params}`,
    { headers: { Authorization: `Bearer ${token}` } }
  );

  const page = await response.json();
  return page.data; // Just the first page for interactive search
}
For interactive search, returning only the first page is usually right — users searching for “Bruce” want top matches, not all 12 pages.

name_like matches first OR last name

name_like=wayne matches:
  • first_name: "Wayne", last_name: "Smith"
  • first_name: "Bruce", last_name: "Wayne"
  • first_name: "Wayland", last_name: "Smith" ✓ (substring match)
The match is case-insensitive substring search. There’s no way to restrict to first-only or last-only matching via the API.

Handling ambiguous results

For interactive UIs, present multiple matches for user selection:
JavaScript
async function presentNameSearchResults(searchTerm, ui) {
  const matches = await searchUsersByName(searchTerm);

  if (matches.length === 0) {
    ui.showMessage('No users found.');
    return null;
  }

  if (matches.length === 1) {
    return matches[0];
  }

  // Multiple matches — let user pick
  return ui.chooseFromList(matches.map((u) => ({
    label: `${u.full_name} (${u.email})`,
    value: u.id,
  })));
}

Scenario 4: Filter by email domain

Goal: find users with emails from a specific domain (often for organizational segmentation).
JavaScript
async function usersWithEmailDomain(domain) {
  // email_like is substring match — works for domain matching
  const params = new URLSearchParams({ email_like: `@${domain}` });
  const allMatches = await paginate(`https://api.vomo.org/v1/users?${params}`);

  // Substring match may include partial domain matches — filter strictly
  return allMatches.filter((u) =>
    u.email?.toLowerCase().endsWith(`@${domain.toLowerCase()}`)
  );
}

// Usage
const wayneFoundationStaff = await usersWithEmailDomain('wayne.example');

Why the secondary filter

email_like=@wayne.example is a substring match — it would also match @wayne.example.com if such an address exists. The client-side endsWith filter ensures the match is actually at the end of the email. For most domains this isn’t an issue, but for short or common domain prefixes (e.g., @vol.com could match @volunteer-orgs.com), the secondary filter prevents false positives.

Scenario 5: Full dataset read

Goal: pull every user — typically for a one-time backfill or daily reconciliation.
JavaScript
async function listAllUsers(customerId) {
  const allUsers = [];
  let url = 'https://api.vomo.org/v1/users';
  let pagesRead = 0;

  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();
    allUsers.push(...page.data);
    pagesRead++;

    if (pagesRead % 10 === 0) {
      console.log(`Loaded ${allUsers.length}/${page.meta.total} users (${pagesRead} pages)`);
    }

    url = page.links.next;
  }

  return allUsers;
}

Page size considerations

Volunteer’s page size is platform-controlled (default 15) — not partner-controllable via per_page. A customer with 10,000 users requires ~667 page requests for a full read. For large customers:
StrategyDescription
Throttle aggressively2–3 req/sec during full backfills, not 10+
Cache the resultDaily backfills can re-use yesterday’s cache as a starting point
Combine with updated_afterAfter the initial backfill, switch to incremental
Run during off-peak hoursReduces collision with the customer’s interactive use
See Rate Limits for the throttling pattern and Sync Architecture Patterns for the broader backfill design.

Scenario 6: Filters that don’t exist

A few useful filters that the API does not expose:
Desired filterWhat to do instead
phone_likeNot supported — paginate all users and filter client-side
birthday_before / birthday_afterNot supported — same
user_status (filter by status enum)Not supported — fetch and filter client-side
membership_role (filter by role)Not supported — same
organization_id / org_slugNot supported on /users — User scope is determined by the token
participated_in_projectNot supported — fetch participations through the User detail endpoint
belongs_to_groupNot supported — use GET /groups/{id}/members instead
For any of these, the pattern is:
JavaScript
async function usersByPhonePrefix(prefix) {
  const allUsers = await listAllUsers(); // expensive
  return allUsers.filter((u) => u.phone?.startsWith(prefix));
}
The cost is a full dataset read. For small accounts this is fine; for large accounts, it’s better to capture the data once into an external store and query there.

Putting it together

A reference function that handles the common cases robustly:
JavaScript
class VomoUserQuery {
  constructor({ token, throttle = 5 }) {
    this.token = token;
    this.client = new ThrottledClient({ requestsPerSecond: throttle });
  }

  async list(filters = {}) {
    const params = new URLSearchParams(filters);
    const users = [];
    let url = `https://api.vomo.org/v1/users?${params}`;

    while (url) {
      const response = await this.client.fetch(url, {
        headers: {
          Authorization: `Bearer ${this.token}`,
          Accept: 'application/json',
        },
      });
      if (!response.ok) throw new Error(`Failed: ${response.status}`);

      const page = await response.json();
      users.push(...page.data);
      url = page.links.next;
    }

    return users;
  }

  async recentlyActive(sinceDate) {
    return this.list({ updated_after: sinceDate.toISOString() });
  }

  async createdInWindow(startDate, endDate) {
    return this.list({
      created_after: startDate.toISOString(),
      created_before: endDate.toISOString(),
    });
  }

  async searchByName(term) {
    if (term.length < 2) return [];
    return this.list({ name_like: term });
  }

  async withEmailDomain(domain) {
    const matches = await this.list({ email_like: `@${domain}` });
    return matches.filter((u) =>
      u.email?.toLowerCase().endsWith(`@${domain.toLowerCase()}`)
    );
  }

  async all() {
    return this.list({});
  }
}

// Usage
const query = new VomoUserQuery({ token, throttle: 3 });
const recent = await query.recentlyActive(thirtyDaysAgo);
const wayne = await query.withEmailDomain('wayne.example');
const allUsers = await query.all();
This encapsulates the common patterns while keeping the underlying flexibility — you can pass arbitrary filters via list({...}) for cases the named methods don’t cover.

Performance and rate-limit considerations

A few practical notes for production-scale workloads:
ConcernPractice
Page size is small (default 15)Plan for many requests on large datasets
Each request consumes rate-limit budgetThrottle to a conservative rate (3-5 req/sec for backfills)
Filters reduce total pages — use themupdated_after for incremental beats reading everything every time
Cache where appropriateDaily reads of the same data hint at over-fetching
Process pages as they arriveDon’t accumulate 100,000+ users in memory before processing
See API Performance Tips for the broader patterns.

Common bugs to avoid

A few patterns that look right but produce subtle issues:

Treating meta.total as a constant

meta.total is the count at the time of the request — if users are being added during your iteration, the count can change. For “did I get them all?” checks, use links.next === null as the truth, not “I read meta.total records.”

Ignoring null in prev/next

Use simple null-checks (if (page.links.next)), not string comparison. See Pagination: the "null" vs null warning.

Date format inconsistency

All datetime filter parameters expect ISO 8601 strings. Sending a Unix timestamp, a Date.toString() value, or a non-UTC ISO string can produce silent failures (returning everything because the date didn’t parse) or wrong results.
JavaScript
// ✅ Correct
params.set('updated_after', date.toISOString()); // → "2025-03-15T14:22:10.000Z"

// ❌ Don't
params.set('updated_after', date.toString()); // → "Sat Mar 15 2025 14:22:10 GMT-0700 (Pacific Daylight Time)"
params.set('updated_after', date.getTime().toString()); // → "1742076130000"

Forgetting URL encoding

URLSearchParams handles encoding for you. Manual concatenation breaks on spaces, ampersands, and other special characters:
JavaScript
// ✅ URLSearchParams handles encoding
const params = new URLSearchParams({ name_like: 'O\'Brien' });

// ❌ Manual concatenation
const url = `?name_like=${searchTerm}`; // Breaks on apostrophes, spaces, etc.

Where to go next

Create or Update a User

Once you’ve found (or haven’t found) the user, the upsert workflow.

Find a User by Email

The focused workflow for email-based lookups.

Pagination

The full pagination pattern these workflows depend on.

Rate Limits

The throttling pattern for bulk reads.
Last modified on May 22, 2026