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 for the field reference and the Create or Update a User workflow for the upsert pattern this lookup often pairs with.
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:
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:
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.
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.
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.
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 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 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 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.
async function ensureUserExists(email) { const user = await findUserByEmail(email); if (!user) { throw new Error(`User ${email} not in VOMO — onboard them first`); } return user;}// Usageconst 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.).
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.
For workflows that process many records, batching beats per-record lookup:
JavaScript
// ❌ Anti-pattern — one lookup per recordfor (const record of externalRecords) { const user = await findUserByEmail(record.email); // ...}// ✅ Bulk-load and join in memoryconst 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.