Walk through POST /users — the upsert behavior, the 200/201 detection pattern, syncing from external systems, bulk imports, and handling the email-as-primary-key reality.
POST /users is the only write path for Users in the Volunteer API — and it’s an upsert, not a create. The same endpoint handles both “create a new user” and “update an existing user”, with the behavior determined entirely by whether the submitted email matches an existing record.This workflow page covers the upsert in practical detail: when to use it, how to detect whether the API created or updated, how to handle validation errors, and the patterns for the most common upstream scenarios (external system sync, bulk imports, find-or-create flows).If you haven’t yet, skim the Users concept page for the field reference and basic shape of POST /users.
Sync user records from an external CRM or HR system
✓ Upsert each external record
Bulk-import a roster of volunteers
✓ Iterate with throttling
Maintain a single source-of-truth user record across systems
✓ Push updates as they happen
Find-or-create flow (you want explicit control over which case it is)
✓ Use this with the 200/201 detection
You already know the User ID and want to update by ID
✗ Not possible — no PUT /users/{id} exists
You need to delete a User
✗ Not exposed via the API
The “find or create” framing is intentional. Some workflows benefit from explicit control over create vs. update; this workflow’s 200/201 detection pattern lets you have that control even though the API endpoint is monolithic.
The match is case-insensitive. bruce@wayne.example and BRUCE@WAYNE.EXAMPLE resolve to the same user.The match is exact substring match on the full email address — no fuzzy matching, no domain-only matching, no name-based fallback. If the email differs by even one character, the API treats it as a new User.
⚠️ Spec gap (audit #47): The endpoint’s operationId is createUser but its actual behavior is upsert. The spec correctly documents both 200 and 201 response codes, but the operation name doesn’t reflect the upsert reality. A future spec revision may rename this to upsertUser or split it into separate create and update endpoints.
Welcome emails should only fire on creation. Audit logs typically benefit from knowing which operation occurred. The 200/201 distinction is the canonical signal.
The most common upsert use case: an external CRM (or HR system, or volunteer management tool) is the source of truth, and changes there should propagate into VOMO.
JavaScript
async function syncVolunteerFromExternal(externalRecord) { try { const { user, created } = await upsertUser({ firstName: externalRecord.firstName, lastName: externalRecord.lastName, email: externalRecord.emailAddress, phone: externalRecord.phoneNumber, birthday: externalRecord.dateOfBirth, role: mapRole(externalRecord.role), }); // Record the linkage between external and VOMO IDs await externalDb.recordSync({ externalId: externalRecord.id, vomoUserId: user.id, syncedAt: new Date(), operation: created ? 'create' : 'update', }); return { ok: true, vomoId: user.id, created }; } catch (err) { if (err instanceof ValidationError) { // Surface the field-level errors for review await externalDb.recordSyncFailure({ externalId: externalRecord.id, error: 'validation', fields: err.fields, }); return { ok: false, error: 'validation', fields: err.fields }; } // Other errors propagate throw err; }}
The pattern handles three outcomes:
Outcome
Action
Create
Welcome flow (if applicable); record external_id ↔ vomo_id mapping
// External system fires "user X updated" eventasync function handleExternalUserUpdate(externalRecord) { const mapping = await externalDb.findMappingByExternalId(externalRecord.id); if (!mapping) { // First time seeing this external record — sync as new return syncVolunteerFromExternal(externalRecord); } // Existing mapping — upsert (the email lookup will find the right VOMO user // unless the email has changed; see the email-change discussion below) return syncVolunteerFromExternal(externalRecord);}
A 10,000-record import at 3 req/sec takes ~55 minutes — a steady pace that avoids triggering rate limits and stays well within the conservative defaults. At 10 req/sec, the same import takes ~17 minutes but risks hitting rate limits and producing inconsistent results. The slower pace is worth it for a one-time operation.See Rate Limits for the broader throttling discussion.
When the integration wants explicit control over which case occurred — and is willing to do an extra lookup to be certain:
JavaScript
async function findOrCreateUser(email, defaults = {}) { // 1. Try to find the user first const existing = await findUserByEmail(email); if (existing) { return { user: existing, created: false }; } // 2. Not found — create with minimal data const { user, created } = await upsertUser({ email, firstName: defaults.firstName ?? 'Volunteer', lastName: defaults.lastName ?? '', phone: defaults.phone, }); return { user, created };}
This pattern is preferred when:
The integration shouldn’t update existing users blindly (e.g., the external system has stale data)
The create case has expensive side effects (welcome emails, provisioning, etc.) that you want to make absolutely sure happen only once
You want a defensive logging/auditing trail of which case occurred
The cost: an extra lookup before the upsert. For most workflows, the upsert-with-detection pattern from Scenario 1 is enough. Use find-or-create when the explicit branching matters.See the dedicated Find a User by Email workflow for the lookup pattern.
When the request body fails server-side validation, the API returns 422 Unprocessable Entity with a structured error body:
{ "message": "The given data was invalid.", "errors": { "email": ["The email field is required."], "first_name": ["The first name must be at least 1 character."] }}
The field-level errors come back keyed by the API’s field name (email, first_name, etc.). If your UI uses different field names, build a translation layer:
The most subtle reality of email-based upsert: if a user’s email changes in your external system, a subsequent upsert creates a new VOMO user rather than updating the existing one.
Bruce Wayne signs up in your external system as bruce@wayne.example
You upsert into VOMO → creates VOMO user #12345
Bruce updates his email to bruce.wayne@wayne.example in your external system
You upsert into VOMO → creates a new VOMO user #99999 (the email doesn’t match #12345)
You now have two VOMO records for the same person. The original (#12345) still has the old email; the new one (#99999) has the updated email but no participation history, no group memberships, no profile data.
Track external IDs in your external-side mapping table, not in VOMO. When an email changes:
JavaScript
async function syncWithEmailChangeDetection(externalRecord) { const mapping = await externalDb.findMappingByExternalId(externalRecord.id); if (mapping && mapping.lastSyncedEmail !== externalRecord.emailAddress) { // Email has changed — flag for manual review await alertOps({ severity: 'medium', message: 'Email changed in external system', externalId: externalRecord.id, vomoUserId: mapping.vomoUserId, oldEmail: mapping.lastSyncedEmail, newEmail: externalRecord.emailAddress, }); // Don't upsert with the new email — would create a duplicate return { ok: false, error: 'email_changed_review_required' }; } // Email unchanged — safe to upsert return syncVolunteerFromExternal(externalRecord);}
The right resolution for an email change typically requires human review — the admin team merges the records in the VOMO admin UI, or coordinates with VOMO support for a programmatic merge.
Because the upsert matches on email, pushing bruce.wayne@wayne.example for a user previously known by bruce@wayne.example creates a new user rather than updating the old one. The old user’s email isn’t updated — there’s just a new record alongside it.For partner integrations to update an existing user’s email, the typical path is:
Detect the email change in your external system
Pause sync for this user until resolved
Coordinate with the customer’s admin team to update the email in the VOMO admin UI
Update your external-side mapping table to reflect the new email
Resume sync
It’s a friction point, but the alternative (silent duplicate creation) is worse.