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

# Find a User by Email

> The focused workflow for looking up a User by email — handling the email_like substring match correctly, the find-vs-upsert decision, edge cases like multiple matches and no-match, and the patterns for production-grade lookups.

Email is the Volunteer API's primary key for Users — the upsert endpoint matches on email, the substring filter on lists is the primary lookup path, and most external-system integrations key off email as the canonical identifier. This workflow page covers the lookup patterns: how to do an exact-email lookup correctly using the substring filter, when to use this versus a blind upsert, and how to handle the edge cases.

If you haven't yet, skim the [Users concept page](/volunteer/concepts/users) for the field reference and the [Create or Update a User](/volunteer/workflows/create-or-update-a-user) workflow for the upsert pattern this lookup often pairs with.

## When to use this workflow

| Scenario                                                                       | This workflow fits                                                                                                         |
| ------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------- |
| You have an email and want to check if a User exists in VOMO                   | ✓                                                                                                                          |
| You're implementing a find-or-create pattern                                   | ✓ (find first, then upsert if missing)                                                                                     |
| You're routing data to an existing User by their email                         | ✓                                                                                                                          |
| You want to verify a user exists before showing UI / running logic             | ✓                                                                                                                          |
| You want to push an update and don't need to know if it was a create or update | ✗ Use the [Create or Update a User](/volunteer/workflows/create-or-update-a-user) workflow's `200`/`201` detection instead |
| You have the User ID                                                           | ✗ Use `GET /users/{id}` — single request, full detail                                                                      |
| You need to search by partial name or other criteria                           | ✗ Use [List Users with Filters](/volunteer/workflows/list-users-with-filters) instead                                      |

***

## The lookup pattern

The Volunteer API has no `GET /users?email={exact}` endpoint — there's only `email_like`, which does a **case-insensitive substring match**. To do an exact-email lookup, query with `email_like` and then verify the match explicitly:

```javascript JavaScript theme={null}
async function findUserByEmail(email) {
  const normalizedEmail = email.trim().toLowerCase();

  const params = new URLSearchParams({ email_like: normalizedEmail });
  const response = await fetch(
    `https://api.vomo.org/v1/users?${params}`,
    { headers: { Authorization: `Bearer ${process.env.VOMO_API_TOKEN}` } }
  );

  if (!response.ok) {
    throw new Error(`Lookup failed: ${response.status}`);
  }

  const page = await response.json();

  // Filter to exact match (case-insensitive)
  const exactMatch = page.data.find(
    (u) => u.email?.toLowerCase() === normalizedEmail
  );

  return exactMatch ?? null;
}
```

Two reasons the secondary equality check matters:

| Reason                              | Example                                                                                                   |
| ----------------------------------- | --------------------------------------------------------------------------------------------------------- |
| **Substring matches are not exact** | `email_like=bruce@wayne.example` matches `bruce@wayne.example.com` if that address exists                 |
| **Multiple results possible**       | An organization may have multiple emails sharing a substring; the explicit check prevents the wrong match |

***

## Why `email_like` and not exact match?

There's no `?email=` (exact) parameter — only `?email_like=` (substring). For most email lookups this is fine — a substring of the full email matches uniquely in practice — but the pattern that handles edge cases is to always do a secondary equality check.

Three real edge cases to be aware of:

### Edge case 1: substring collision

`bruce@wayne.example` and `bruce@wayne.example.com` are different addresses but `email_like=bruce@wayne.example` matches both:

```javascript JavaScript theme={null}
const params = new URLSearchParams({ email_like: 'bruce@wayne.example' });
// Response data could contain:
// [
//   { id: 12345, email: 'bruce@wayne.example' },
//   { id: 67890, email: 'bruce@wayne.example.com' }
// ]
```

The exact-equality filter selects the right one.

### Edge case 2: case sensitivity

```javascript JavaScript theme={null}
const params = new URLSearchParams({ email_like: 'bruce@WAYNE.example' });
// Response data could contain:
// [
//   { id: 12345, email: 'bruce@wayne.example' }
// ]
```

The API matches case-insensitively (typical for email matching), but your exact-match check should also be case-insensitive to align. The pattern always lowercases both sides.

### Edge case 3: leading/trailing whitespace

User-supplied emails often have stray whitespace from copy-paste. Normalize before the API call:

```javascript JavaScript theme={null}
const normalizedEmail = userInput.trim().toLowerCase();
```

This is doubly important since the API's substring match would match `" bruce@wayne.example "` (with spaces) against `bruce@wayne.example`. The lookup might "succeed" with the unnormalized input but return surprising data.

***

## When `findUserByEmail` returns multiple results

Email is *expected* to be unique per VOMO organization, but defensive code handles the case of multiple matches:

```javascript JavaScript theme={null}
async function findUserByEmailWithDuplicateDetection(email) {
  const normalizedEmail = email.trim().toLowerCase();
  const params = new URLSearchParams({ email_like: normalizedEmail });

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

  const exactMatches = page.data.filter(
    (u) => u.email?.toLowerCase() === normalizedEmail
  );

  if (exactMatches.length === 0) {
    return null;
  }

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

  // Unexpected — log and surface
  console.warn(`Multiple users found for ${email}: ${exactMatches.map((u) => u.id).join(', ')}`);
  await alertOps({
    severity: 'medium',
    message: 'Duplicate user records detected',
    email,
    userIds: exactMatches.map((u) => u.id),
  });

  // Return the most recently updated as the "canonical" one
  return exactMatches.sort(
    (a, b) => new Date(b.updated_at) - new Date(a.updated_at)
  )[0];
}
```

Duplicates shouldn't exist but can arise from:

* Migration bugs from a prior system
* Data import errors
* The [email-change problem](/volunteer/workflows/create-or-update-a-user#the-email-change-problem) where an old record was never reconciled

Detecting and alerting on them is more useful than crashing. The "most recently updated" tiebreaker is a reasonable default but the right resolution typically requires admin team review.

***

## When the lookup spans multiple pages

The `email_like` substring filter may return more results than fit on one page. For an exact-email lookup, the pattern is to find the match on the first page or accept that more searching is needed:

```javascript JavaScript theme={null}
async function findUserByEmailAcrossPages(email) {
  const normalizedEmail = email.trim().toLowerCase();
  const params = new URLSearchParams({ email_like: normalizedEmail });

  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();

    const match = page.data.find(
      (u) => u.email?.toLowerCase() === normalizedEmail
    );
    if (match) return match; // Found — stop reading

    url = page.links.next;
  }

  return null; // Not found across all pages
}
```

For most email lookups, the result is on page 1 — the substring filter narrows aggressively. But for very common email substrings, the match may not be on the first page; iterating is the safe pattern.

***

## The find-or-create pattern

Often the use case isn't "does this user exist?" but "get me this user — create if needed":

```javascript JavaScript theme={null}
async function findOrCreateUser(email, defaults = {}) {
  // 1. Try to find the user
  const existing = await findUserByEmail(email);
  if (existing) {
    return { user: existing, created: false };
  }

  // 2. Not found — create via upsert
  const response = await fetch('https://api.vomo.org/v1/users', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${token}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      email: email.trim().toLowerCase(),
      first_name: defaults.firstName ?? 'Volunteer',
      last_name: defaults.lastName ?? '',
      phone: defaults.phone,
    }),
  });

  if (!response.ok) throw new Error(`Create failed: ${response.status}`);

  const result = await response.json();
  return { user: result.data, created: response.status === 201 };
}
```

### Why use find-or-create vs blind upsert?

The blind upsert handles both cases in one request, which is operationally simpler. Use find-or-create when:

| Reason                                                          | Description                                                                                            |
| --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| **You want to avoid overwriting user data on existing records** | The blind upsert would update name/phone/etc. — find-or-create skips the update if the user exists     |
| **The "create" case has expensive side effects**                | Welcome emails, integration provisioning, audit events you want to make sure happen exactly once       |
| **You want explicit logging of the operation**                  | Find-or-create gives you cleaner audit trail of "found existing" vs "created new"                      |
| **You're using a minimal create payload**                       | Find-or-create lets you create with just an email + defaults, without overwriting a real user's fields |

For most sync workflows from a system-of-record, blind upsert is the right choice. For partner integrations that defer to VOMO as the source of truth for user data, find-or-create is safer.

***

## A defensive lookup helper

A reference implementation that handles the common gotchas:

```javascript JavaScript theme={null}
class VomoUserLookup {
  constructor({ token }) {
    this.token = token;
    this.baseUrl = 'https://api.vomo.org/v1';
  }

  async byEmail(email, { allowMultiple = false } = {}) {
    if (!email || typeof email !== 'string') {
      return allowMultiple ? [] : null;
    }

    const normalized = email.trim().toLowerCase();
    if (!normalized || !normalized.includes('@')) {
      return allowMultiple ? [] : null;
    }

    const params = new URLSearchParams({ email_like: normalized });
    const matches = [];

    let url = `${this.baseUrl}/users?${params}`;
    while (url) {
      const response = await this._fetch(url);
      if (!response.ok) throw new Error(`Lookup failed: ${response.status}`);

      const page = await response.json();
      for (const u of page.data) {
        if (u.email?.toLowerCase() === normalized) {
          matches.push(this._parseUser(u));
        }
      }

      url = page.links.next;
    }

    if (allowMultiple) return matches;

    if (matches.length > 1) {
      console.warn(`Multiple users found for ${email}: ${matches.map((u) => u.id).join(', ')}`);
      // Return the most recently updated as canonical
      return matches.sort((a, b) => b.updatedAt - a.updatedAt)[0];
    }

    return matches[0] ?? null;
  }

  async byId(userId) {
    const response = await this._fetch(`${this.baseUrl}/users/${userId}`);
    if (response.status === 404) return null;
    if (!response.ok) throw new Error(`Lookup failed: ${response.status}`);
    const result = await response.json();
    return this._parseUser(result.data);
  }

  _fetch(url, options = {}) {
    return fetch(url, {
      ...options,
      headers: {
        Authorization: `Bearer ${this.token}`,
        Accept: 'application/json',
        ...options.headers,
      },
    });
  }

  _parseUser(raw) {
    return {
      id: raw.id,
      firstName: raw.first_name,
      lastName: raw.last_name,
      fullName: raw.full_name,
      email: raw.email,
      phone: raw.phone ?? null,
      createdAt: new Date(raw.created_at),
      updatedAt: new Date(raw.updated_at),
      userStatus: raw.user_status,
      membershipStatus: raw.membership_status,
      membershipRole: raw.membership_role,
    };
  }
}
```

The helper:

* Validates the input is a non-empty string containing an `@`
* Normalizes (trim, lowercase) before the API call
* Iterates pages if needed
* Filters to exact match
* Allows opt-in to "return all matches" for defensive workflows
* Falls back to most-recent-updated on unexpected duplicates

***

## Performance and caching

For partner integrations that look up users frequently, the lookup cost adds up:

| Operation                                  | Cost                                              |
| ------------------------------------------ | ------------------------------------------------- |
| Single email lookup                        | 1 API request (typical, when match on first page) |
| Lookup followed by upsert (find-or-create) | 1–2 API requests                                  |
| Repeated lookups of the same emails        | N requests — wasteful                             |

### When to cache

Cache the lookup result in two places:

```javascript JavaScript theme={null}
class CachingUserLookup {
  constructor({ underlying, ttlSeconds = 300 }) {
    this.underlying = underlying;
    this.ttlMs = ttlSeconds * 1000;
    this.cache = new Map();
  }

  async byEmail(email) {
    const key = email.trim().toLowerCase();
    const cached = this.cache.get(key);

    if (cached && Date.now() - cached.cachedAt < this.ttlMs) {
      return cached.user;
    }

    const user = await this.underlying.byEmail(key);
    this.cache.set(key, { user, cachedAt: Date.now() });
    return user;
  }

  invalidate(email) {
    this.cache.delete(email.trim().toLowerCase());
  }
}
```

A short TTL (5 minutes) is reasonable for production — long enough to avoid redundant lookups in tight loops, short enough that the cache doesn't drift far from reality. Invalidate when you do an upsert for that email.

### When NOT to cache

| Scenario                                                                             | Why no cache                                   |
| ------------------------------------------------------------------------------------ | ---------------------------------------------- |
| One-off interactive lookups                                                          | Caching adds complexity for negligible benefit |
| Lookups where freshness matters (e.g., "did the user get verified just now?")        | Cache lag would mislead the workflow           |
| Lookups that should hit the API to track activity (e.g., audit logging requirements) | Cache bypasses the API entirely                |

***

## Common workflow patterns

### Pattern 1: route data to an existing user

When external data arrives keyed by email and you need to find the VOMO user to attach it to:

```javascript JavaScript theme={null}
async function attachDataToUser(externalEvent) {
  const lookup = new VomoUserLookup({ token });
  const user = await lookup.byEmail(externalEvent.userEmail);

  if (!user) {
    console.warn(`No VOMO user found for ${externalEvent.userEmail}`);
    await externalDb.queueForReview(externalEvent);
    return;
  }

  await processEventForUser(user.id, externalEvent);
}
```

The "no match" case is typically queued for human review rather than ignored — a missing user usually indicates a sync gap that needs investigation.

### Pattern 2: precondition for downstream work

```javascript JavaScript theme={null}
async function ensureUserExists(email) {
  const user = await findUserByEmail(email);
  if (!user) {
    throw new Error(`User ${email} not in VOMO — onboard them first`);
  }
  return user;
}

// Usage
const user = await ensureUserExists('bruce@wayne.example');
await addUserToGroup(user.id, groupId);
```

Common in workflows where the user is expected to already exist (assigning a verified volunteer to a Group, recording metadata, etc.).

### Pattern 3: deduplication before push

For external systems that may attempt to push the same user twice:

```javascript JavaScript theme={null}
async function pushUserWithDedup(externalRecord) {
  const existing = await findUserByEmail(externalRecord.email);

  if (existing) {
    const externalUpdatedAt = new Date(externalRecord.updatedAt);
    const vomoUpdatedAt = existing.updatedAt;

    if (externalUpdatedAt <= vomoUpdatedAt) {
      // VOMO has a newer version — skip the push
      return { skipped: true, reason: 'vomo_has_newer' };
    }
  }

  // Either no existing user, or external is newer
  return upsertUser(externalRecord);
}
```

This prevents stale external data from overwriting fresher VOMO data — useful when the external system isn't strictly the source of truth.

***

## Things to watch for

A few subtle issues that surface in production:

### `email_like` matching is sensitive to special characters

Emails with `+` (e.g., `bruce+volunteer@wayne.example`) need URL encoding handled correctly. `URLSearchParams` handles this; manual string concatenation doesn't:

```javascript JavaScript theme={null}
// ✅ URLSearchParams handles + correctly
const params = new URLSearchParams({ email_like: 'bruce+volunteer@wayne.example' });

// ❌ Manual concatenation produces wrong URL
const url = `?email_like=${'bruce+volunteer@wayne.example'}`;
// Result: ?email_like=bruce+volunteer@wayne.example
// Which the server may interpret as ?email_like=bruce volunteer@wayne.example
```

### Email field can be missing or null

Defensive code checks for `user.email` before calling `.toLowerCase()`:

```javascript JavaScript theme={null}
const exactMatch = page.data.find(
  (u) => u.email?.toLowerCase() === normalizedEmail
);
```

The `?.` optional chaining handles the rare case where a User record has no email (edge cases from data migration, etc.).

### Don't look up by email in tight loops

For workflows that process many records, batching beats per-record lookup:

```javascript JavaScript theme={null}
// ❌ Anti-pattern — one lookup per record
for (const record of externalRecords) {
  const user = await findUserByEmail(record.email);
  // ...
}

// ✅ Bulk-load and join in memory
const allUsers = await listAllUsers();
const byEmail = new Map(allUsers.map((u) => [u.email.toLowerCase(), u]));
for (const record of externalRecords) {
  const user = byEmail.get(record.email.toLowerCase());
  // ...
}
```

The bulk-load + in-memory join is one large request instead of N small ones — and the API rate-limit cost is far lower. For accounts of any meaningful size, this is the right pattern.

***

## 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">
    The upsert workflow that pairs naturally with lookup-then-create patterns.
  </Card>

  <Card title="List Users with Filters" icon="filter" href="/volunteer/workflows/list-users-with-filters">
    The bulk-read workflow useful for in-memory joins.
  </Card>

  <Card title="Users" icon="user" href="/volunteer/concepts/users">
    The reference page with the full field shape and endpoint details.
  </Card>

  <Card title="Sync Users to External System" icon="arrows-rotate" href="/volunteer/recipes/sync-users-to-external-system">
    The end-to-end recipe that uses lookup, upsert, and full sync together.
  </Card>
</CardGroup>
