Skip to main content
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

ScenarioThis workflow fits
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.

How the upsert works

The matching key is email:
Submitted emailWhat happensResponse
Email doesn’t match any existing UserCreates a new User201 Created
Email matches an existing UserUpdates that User200 OK
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
curl -X POST https://api.vomo.org/v1/users \
  -H "Authorization: Bearer $VOMO_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "first_name": "Bruce",
    "last_name": "Wayne",
    "email": "bruce@wayne.example"
  }'
The minimum required fields are typically first_name, last_name, and email. Most other fields are optional but accepted:
FieldTypeNotes
first_namestringRequired
last_namestringRequired
emailstringRequired — the matching key
phonestringOptional — phone number
birthdaystringOptional — ISO 8601 date (YYYY-MM-DD)
genderstringOptional — one of M, F, N
rolestringOptional — VOLUNTEER, ORGANIZER, ADMIN
addressstringOptional — street address
See the Users concept page for the full request body reference.

Detecting create vs. update

The HTTP status code distinguishes the two outcomes:
JavaScript
async function upsertUser(userData) {
  const response = await fetch('https://api.vomo.org/v1/users', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.VOMO_API_TOKEN}`,
      'Content-Type': 'application/json',
      Accept: 'application/json',
    },
    body: JSON.stringify({
      first_name: userData.firstName,
      last_name: userData.lastName,
      email: userData.email,
      phone: userData.phone,
      birthday: userData.birthday,
      gender: userData.gender,
      role: userData.role,
    }),
  });

  if (response.status === 422) {
    const problem = await response.json();
    throw new ValidationError(problem.message, problem.errors ?? {});
  }

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

  const created = response.status === 201;
  const result = await response.json();

  return { user: result.data, created };
}
The returned created boolean is what downstream code branches on. A common pattern:
JavaScript
const { user, created } = await upsertUser({
  firstName: 'Bruce',
  lastName: 'Wayne',
  email: 'bruce@wayne.example',
});

if (created) {
  console.log(`✨ Created new user: ${user.id}`);
  await sendWelcomeEmail(user);
  await logAuditEvent({ action: 'user_created', vomoId: user.id });
} else {
  console.log(`✏️ Updated existing user: ${user.id}`);
  // No welcome email — user already exists
  await logAuditEvent({ action: 'user_updated', vomoId: user.id });
}
Welcome emails should only fire on creation. Audit logs typically benefit from knowing which operation occurred. The 200/201 distinction is the canonical signal.

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
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:
OutcomeAction
CreateWelcome flow (if applicable); record external_id ↔ vomo_id mapping
UpdateRefresh the mapping with current syncedAt
Validation errorRecord for review; don’t retry blindly

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
// External system fires "user X updated" event
async 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);
}

Scenario 2: Bulk import

For one-time imports of a volunteer roster — typically when onboarding a new customer or migrating from another system:
JavaScript
async function bulkImportRoster(roster) {
  const client = new ThrottledVomoClient({ token, requestsPerSecond: 3 });
  const results = {
    created: [],
    updated: [],
    failed: [],
  };

  for (let i = 0; i < roster.length; i++) {
    const record = roster[i];

    try {
      const { user, created } = await upsertUserVia(client, record);
      (created ? results.created : results.updated).push({
        externalId: record.externalId,
        vomoId: user.id,
        email: user.email,
      });
    } catch (err) {
      results.failed.push({
        externalId: record.externalId,
        email: record.email,
        error: err.message,
        fields: err.fields ?? null,
      });
    }

    // Progress reporting every 100 records
    if ((i + 1) % 100 === 0) {
      console.log(`Imported ${i + 1}/${roster.length}: ` +
                  `${results.created.length} created, ` +
                  `${results.updated.length} updated, ` +
                  `${results.failed.length} failed`);
    }
  }

  return results;
}

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
async function resumableImport(roster, customerId) {
  const checkpoint = await getImportCheckpoint(customerId) ?? 0;
  const remaining = roster.slice(checkpoint);

  for (let i = 0; i < remaining.length; i++) {
    const record = remaining[i];
    await upsertUserVia(client, record);

    // Advance checkpoint every 50 records
    if ((i + 1) % 50 === 0) {
      await setImportCheckpoint(customerId, checkpoint + i + 1);
    }
  }

  await setImportCheckpoint(customerId, roster.length);
}
The checkpoint advances incrementally — a crash at record 7,500 of 10,000 lets the next run resume at ~7,500 rather than starting over.

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

Handling validation errors

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."]
  }
}
Handle these with a structured error class:
JavaScript
class ValidationError extends Error {
  constructor(message, fields) {
    super(message);
    this.fields = fields; // { fieldName: ["error message", ...] }
  }
}

try {
  await upsertUser(userData);
} catch (err) {
  if (err instanceof ValidationError) {
    Object.entries(err.fields).forEach(([field, messages]) => {
      messages.forEach((msg) => {
        ui.showFieldError(field, msg);
      });
    });
    return;
  }
  throw err;
}
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:
JavaScript
const API_TO_UI_FIELDS = {
  email: 'emailAddress',
  first_name: 'firstName',
  last_name: 'lastName',
  phone: 'phoneNumber',
};

function translateApiErrors(apiErrors) {
  return Object.fromEntries(
    Object.entries(apiErrors).map(([apiField, msgs]) => [
      API_TO_UI_FIELDS[apiField] ?? apiField,
      msgs,
    ])
  );
}
See Error Handling for the broader error classification.

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

  1. Bruce Wayne signs up in your external system as bruce@wayne.example
  2. You upsert into VOMO → creates VOMO user #12345
  3. Bruce updates his email to bruce.wayne@wayne.example in your external system
  4. 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.

The mitigation

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.

Why not “just push the new email”

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:
  1. Detect the email change in your external system
  2. Pause sync for this user until resolved
  3. Coordinate with the customer’s admin team to update the email in the VOMO admin UI
  4. Update your external-side mapping table to reflect the new email
  5. Resume sync
It’s a friction point, but the alternative (silent duplicate creation) is worse.

What can’t be done via the API

CapabilityStatus
Update a user by VOMO ID without their emailNot possible — upsert matches on email only
Delete a userNot exposed — admin UI only
Change a user’s emailNot exposed — would create duplicate per the email-change problem
Merge two duplicate usersNot exposed — admin UI only
Restore a deleted userNot exposed
Bulk upsert in one requestNot exposed — one request per user
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
class VomoUserUpsert {
  constructor({ token }) {
    this.token = token;
    this.baseUrl = 'https://api.vomo.org/v1';
  }

  async upsert(userData) {
    const response = await fetch(`${this.baseUrl}/users`, {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${this.token}`,
        'Content-Type': 'application/json',
        Accept: 'application/json',
      },
      body: JSON.stringify(this._buildBody(userData)),
    });

    if (response.status === 422) {
      const problem = await response.json();
      throw new ValidationError(
        problem.message ?? 'Validation failed',
        problem.errors ?? {}
      );
    }

    if (response.status === 401) {
      throw new AuthError('VOMO token is invalid');
    }

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

    const created = response.status === 201;
    const result = await response.json();

    return { user: this._parseUser(result.data), created };
  }

  _buildBody(data) {
    const body = {
      first_name: data.firstName,
      last_name: data.lastName,
      email: data.email,
    };

    // Only include optional fields if provided (avoid sending empty strings)
    if (data.phone) body.phone = data.phone;
    if (data.birthday) body.birthday = data.birthday;
    if (data.gender) body.gender = data.gender;
    if (data.role) body.role = data.role;
    if (data.address) body.address = data.address;

    return body;
  }

  _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 _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.
Last modified on May 22, 2026