> ## Documentation Index
> Fetch the complete documentation index at: https://docs.virtuous.org/llms.txt
> Use this file to discover all available pages before exploring further.

# List Users with Filters

> Walk through GET /users with the six filter parameters — the practical patterns for recently-active queries, time-window filtering, name and email search, and full-dataset reads with pagination.

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](/volunteer/concepts/users) first — this workflow page builds on the field shapes and endpoint structure documented there.

## When to use this workflow

| Scenario                                         | This 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 email                | Use the [Find a User by Email](/volunteer/workflows/find-a-user-by-email) workflow instead |
| Find a user when you have their ID               | Use `GET /users/{id}` instead — single-request, full detail                                |

***

## The six filter parameters

| Parameter        | What it filters by                                            |
| ---------------- | ------------------------------------------------------------- |
| `name_like`      | Substring match against first OR last name (case-insensitive) |
| `email_like`     | Substring match against email (case-insensitive)              |
| `created_before` | Users created on or before a date (ISO 8601)                  |
| `created_after`  | Users created on or after a date                              |
| `updated_before` | Users updated on or before a date                             |
| `updated_after`  | Users updated on or after a date                              |
| `page`           | Page 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 JavaScript theme={null}
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 JavaScript theme={null}
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](/volunteer/pagination#performance-considerations).

***

## Scenario 2: Users created in a time window

Goal: users created within a specific calendar month.

```javascript JavaScript theme={null}
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

| Filter                                   | Boundary 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 JavaScript theme={null}
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 JavaScript theme={null}
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 JavaScript theme={null}
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 JavaScript theme={null}
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 JavaScript theme={null}
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:

| Strategy                         | Description                                                      |
| -------------------------------- | ---------------------------------------------------------------- |
| **Throttle aggressively**        | 2–3 req/sec during full backfills, not 10+                       |
| **Cache the result**             | Daily backfills can re-use yesterday's cache as a starting point |
| **Combine with `updated_after`** | After the initial backfill, switch to incremental                |
| **Run during off-peak hours**    | Reduces collision with the customer's interactive use            |

See [Rate Limits](/volunteer/rate-limits) for the throttling pattern and [Sync Architecture Patterns](/volunteer/best-practices/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 filter                        | What to do instead                                                    |
| ------------------------------------- | --------------------------------------------------------------------- |
| `phone_like`                          | Not supported — paginate all users and filter client-side             |
| `birthday_before` / `birthday_after`  | Not 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_slug`        | Not supported on `/users` — User scope is determined by the token     |
| `participated_in_project`             | Not supported — fetch participations through the User detail endpoint |
| `belongs_to_group`                    | Not supported — use `GET /groups/{id}/members` instead                |

For any of these, the pattern is:

```javascript JavaScript theme={null}
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 JavaScript theme={null}
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:

| Concern                                 | Practice                                                            |
| --------------------------------------- | ------------------------------------------------------------------- |
| Page size is small (default 15)         | Plan for many requests on large datasets                            |
| Each request consumes rate-limit budget | Throttle to a conservative rate (3-5 req/sec for backfills)         |
| Filters reduce total pages — use them   | `updated_after` for incremental beats reading everything every time |
| Cache where appropriate                 | Daily reads of the same data hint at over-fetching                  |
| Process pages as they arrive            | Don't accumulate 100,000+ users in memory before processing         |

See [API Performance Tips](/volunteer/best-practices/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](/volunteer/pagination).

### 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 JavaScript theme={null}
// ✅ 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 JavaScript theme={null}
// ✅ 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

<CardGroup cols={2}>
  <Card title="Create or Update a User" icon="user-plus" href="/volunteer/workflows/create-or-update-a-user">
    Once you've found (or haven't found) the user, the upsert workflow.
  </Card>

  <Card title="Find a User by Email" icon="envelope" href="/volunteer/workflows/find-a-user-by-email">
    The focused workflow for email-based lookups.
  </Card>

  <Card title="Pagination" icon="list" href="/volunteer/pagination">
    The full pagination pattern these workflows depend on.
  </Card>

  <Card title="Rate Limits" icon="gauge" href="/volunteer/rate-limits">
    The throttling pattern for bulk reads.
  </Card>
</CardGroup>
