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.
When to use this workflow
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.
How the upsert works
The matching key isemail:
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.The minimal upsert
cURL
first_name, last_name, and email. Most other fields are optional but accepted:
See the Users concept page for the full request body reference.
Detecting create vs. update
The HTTP status code distinguishes the two outcomes:JavaScript
created boolean is what downstream code branches on. A common pattern:
JavaScript
Scenario 1: Sync from an external system
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
Why record the mapping
External systems and VOMO have their own IDs. The mapping table (external_id → vomo_id) lets your integration:
- Detect “this external record corresponds to this VOMO user”
- Resolve subsequent updates from external system to the right VOMO User
- Recover when email changes break the email-based upsert (see The email-change problem below)
JavaScript
Scenario 2: Bulk import
For one-time imports of a volunteer roster — typically when onboarding a new customer or migrating from another system:JavaScript
Why throttle aggressively for bulk imports
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.Resumable imports
For large imports, build resumability so a failure partway through doesn’t restart from scratch:JavaScript
Scenario 3: Find-or-create
When the integration wants explicit control over which case occurred — and is willing to do an extra lookup to be certain:JavaScript
- 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
Handling validation errors
When the request body fails server-side validation, the API returns422 Unprocessable Entity with a structured error body:
JavaScript
email, first_name, etc.). If your UI uses different field names, build a translation layer:
JavaScript
The email-change problem
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.The scenario
- 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.examplein your external system - You upsert into VOMO → creates a new VOMO user #99999 (the email doesn’t match #12345)
The mitigation
Track external IDs in your external-side mapping table, not in VOMO. When an email changes:JavaScript
Why not “just push the new email”
Because the upsert matches on email, pushingbruce.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
What can’t be done via the API
For the merge case especially, coordinate with the customer’s admin team — the VOMO admin UI has merge tools that aren’t exposed in the API.
A reference upsert client
A minimal, well-organized client:JavaScript
_buildBody method strips out empty optional fields — preventing the API from interpreting an empty string as “set this field to empty.”
Where to go next
Find a User by Email
The lookup workflow that pairs with this upsert pattern.
List Users with Filters
The bulk-read workflow for incremental sync.
Users
The reference page for User fields and endpoints.
Sync Users to External System
The end-to-end recipe combining this upsert with broader sync architecture.