> ## Documentation Index
> Fetch the complete documentation index at: https://docs.virtuous.org/llms.txt
> Use this file to discover all available pages before exploring further.

# Create or Update a User

> 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](/volunteer/concepts/users) for the field reference and basic shape of `POST /users`.

## When to use this workflow

| Scenario                                                              | This 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 email                       | What happens       | Response      |
| ------------------------------------- | ------------------ | ------------- |
| Email doesn't match any existing User | Creates a new User | `201 Created` |
| Email matches an existing User        | Updates that User  | `200 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.

<Note>
  **⚠️ 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.
</Note>

***

## The minimal upsert

```bash cURL theme={null}
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:

| Field        | Type   | Notes                                        |
| ------------ | ------ | -------------------------------------------- |
| `first_name` | string | Required                                     |
| `last_name`  | string | Required                                     |
| `email`      | string | Required — the matching key                  |
| `phone`      | string | Optional — phone number                      |
| `birthday`   | string | Optional — ISO 8601 date (`YYYY-MM-DD`)      |
| `gender`     | string | Optional — one of `M`, `F`, `N`              |
| `role`       | string | Optional — `VOLUNTEER`, `ORGANIZER`, `ADMIN` |
| `address`    | string | Optional — street address                    |

See the [Users concept page](/volunteer/concepts/users#request-body-fields) for the full request body reference.

***

## Detecting create vs. update

The HTTP status code distinguishes the two outcomes:

```javascript JavaScript theme={null}
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 JavaScript theme={null}
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 JavaScript theme={null}
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 |
| Update           | Refresh the mapping with current `syncedAt`                          |
| Validation error | Record 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](#the-email-change-problem) below)

```javascript JavaScript theme={null}
// 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 JavaScript theme={null}
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](/volunteer/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 JavaScript theme={null}
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 JavaScript theme={null}
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](/volunteer/workflows/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:

```json theme={null}
{
  "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 JavaScript theme={null}
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 JavaScript theme={null}
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](/volunteer/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 JavaScript theme={null}
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

| Capability                                   | Status                                                            |
| -------------------------------------------- | ----------------------------------------------------------------- |
| Update a user by VOMO ID without their email | Not possible — upsert matches on email only                       |
| Delete a user                                | Not exposed — admin UI only                                       |
| Change a user's email                        | Not exposed — would create duplicate per the email-change problem |
| Merge two duplicate users                    | Not exposed — admin UI only                                       |
| Restore a deleted user                       | Not exposed                                                       |
| Bulk upsert in one request                   | Not 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 JavaScript theme={null}
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

<CardGroup cols={2}>
  <Card title="Find a User by Email" icon="envelope" href="/volunteer/workflows/find-a-user-by-email">
    The lookup workflow that pairs with this upsert pattern.
  </Card>

  <Card title="List Users with Filters" icon="filter" href="/volunteer/workflows/list-users-with-filters">
    The bulk-read workflow for incremental sync.
  </Card>

  <Card title="Users" icon="user" href="/volunteer/concepts/users">
    The reference page for User fields and endpoints.
  </Card>

  <Card title="Sync Users to External System" icon="arrows-rotate" href="/volunteer/recipes/sync-users-to-external-system">
    The end-to-end recipe combining this upsert with broader sync architecture.
  </Card>
</CardGroup>
