Skip to main content
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 for the field reference and the Create or Update a User workflow for the upsert pattern this lookup often pairs with.

When to use this workflow

ScenarioThis 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 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 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
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:
ReasonExample
Substring matches are not exactemail_like=bruce@wayne.example matches bruce@wayne.example.com if that address exists
Multiple results possibleAn 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
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
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
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
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 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
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
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:
ReasonDescription
You want to avoid overwriting user data on existing recordsThe blind upsert would update name/phone/etc. — find-or-create skips the update if the user exists
The “create” case has expensive side effectsWelcome emails, integration provisioning, audit events you want to make sure happen exactly once
You want explicit logging of the operationFind-or-create gives you cleaner audit trail of “found existing” vs “created new”
You’re using a minimal create payloadFind-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
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:
OperationCost
Single email lookup1 API request (typical, when match on first page)
Lookup followed by upsert (find-or-create)1–2 API requests
Repeated lookups of the same emailsN requests — wasteful

When to cache

Cache the lookup result in two places:
JavaScript
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

ScenarioWhy no cache
One-off interactive lookupsCaching 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
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
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
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
// ✅ 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
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
// ❌ 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

Create or Update a User

The upsert workflow that pairs naturally with lookup-then-create patterns.

List Users with Filters

The bulk-read workflow useful for in-memory joins.

Users

The reference page with the full field shape and endpoint details.

Sync Users to External System

The end-to-end recipe that uses lookup, upsert, and full sync together.
Last modified on May 22, 2026