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

# Users & Volunteers

> The User resource reference — fields, filtering, the list vs. detail shape distinction, the upsert behavior on POST /users, and the practical patterns for finding, creating, and updating volunteer records.

A User in the Volunteer API represents an individual person — typically a volunteer, but also organizers, administrators, and anyone else with a VOMO account. Users are the most commonly accessed resource for partner integrations: they're queried for reporting, looked up by email for sync workflows, and created or updated when external systems push volunteer data into VOMO.

This page covers the User resource in depth — the fields, the three endpoints, the list-vs-detail shape distinction, and the upsert behavior that makes `POST /users` distinctive.

## The three endpoints

| Endpoint      | Method | What it does                       |
| ------------- | ------ | ---------------------------------- |
| `/users`      | GET    | List Users (paginated, filterable) |
| `/users`      | POST   | Create or update a User (upsert)   |
| `/users/{id}` | GET    | Get a single User with full detail |

That's the entire User write surface: one upsert endpoint that handles both create and update. There's no separate `PUT /users/{id}` for updates.

***

## The User resource

### List shape (`UserResource`)

`GET /users` returns an array of `UserResource` objects — the abbreviated profile fields suitable for list display and filtering:

| Field               | Type    | Description                                                        |
| ------------------- | ------- | ------------------------------------------------------------------ |
| `type`              | string  | `"user"` — the VOMO object type identifier                         |
| `id`                | integer | The User's stable VOMO ID                                          |
| `first_name`        | string  | First name                                                         |
| `last_name`         | string  | Last name                                                          |
| `full_name`         | string  | Concatenated first + last (provided as a convenience)              |
| `email`             | string  | Email address (primary identifier for matching)                    |
| `address`           | string  | Street address                                                     |
| `phone`             | string  | Phone number                                                       |
| `birthday`          | string  | Birth date (ISO 8601 format expected, e.g., `1972-02-19`)          |
| `gender`            | string  | One of `M`, `F`, `N`                                               |
| `updated_at`        | string  | ISO 8601 datetime of last modification                             |
| `created_at`        | string  | ISO 8601 datetime of account creation                              |
| `user_status`       | string  | The User's account status (e.g., `VERIFIED`)                       |
| `membership_status` | string  | The User's membership status in the current org (e.g., `ACCEPTED`) |
| `membership_role`   | string  | The User's role in the current org (e.g., `VOLUNTEER`)             |

### Detail shape (`UserDetailResource`)

`GET /users/{id}` returns a `UserDetailResource` — a superset of `UserResource` plus two additional fields:

| Field                  | Type  | Description                                                              |
| ---------------------- | ----- | ------------------------------------------------------------------------ |
| `participations`       | array | The User's participation history (see [Participations](#participations)) |
| `profile_field_values` | array | The User's responses to custom profile fields                            |

The `UserDetailResource` is what you get when you have a specific User and want the full picture. The `UserResource` is what you get when iterating through many Users for filtering or display.

<Note>
  **⚠️ Spec gap:** The OpenAPI spec's `UserDetailResource` formally documents only `participations` and `profile_field_values` (audit #50 confirms the schema is sparse). In practice, the live API likely returns the base `UserResource` fields alongside these — making `UserDetailResource` a true superset, not a replacement. Confirm against actual responses for production-critical paths.
</Note>

### Membership concepts

Three status fields capture the User's relationship to the customer's organization:

| Field               | What it represents                              | Example values                                                 |
| ------------------- | ----------------------------------------------- | -------------------------------------------------------------- |
| `user_status`       | The User's overall account status               | `VERIFIED`, `UNVERIFIED`, `BANNED` (other values likely exist) |
| `membership_status` | The User's membership status in the current org | `ACCEPTED`, `PENDING`, `REJECTED` (other values likely exist)  |
| `membership_role`   | The User's role in the current org              | `VOLUNTEER`, `ORGANIZER`, `ADMIN` (other values likely exist)  |

<Warning>
  **⚠️ Spec gap (audit #45, #46):** The spec types `user_status`, `membership_status`, and `membership_role` as `string` with no `enum` declared. The example values give hints (`VERIFIED`, `ACCEPTED`, `VOLUNTEER`), but the complete set of valid values is not documented.

  Code that switches on these values should handle unknown values gracefully — log them but don't crash. The values are stable for known states but new states may be added over time.
</Warning>

***

## Listing users

```bash cURL theme={null}
curl "https://api.vomo.org/v1/users?page=1&name_like=wayne" \
  -H "Authorization: Bearer $VOMO_API_TOKEN"
```

### Available filters

| Parameter        | What it filters by                           |
| ---------------- | -------------------------------------------- |
| `page`           | Page number (default 1)                      |
| `name_like`      | Substring match against first AND last name  |
| `email_like`     | Substring match against email                |
| `created_before` | Users created on or before a date (ISO 8601) |
| `created_after`  | Users created on or after a date             |
| `updated_before` | Users updated on or before a date            |
| `updated_after`  | Users updated on or after a date             |

All filters use snake\_case. The `*_like` filters are case-insensitive substring matches. The `*_before` / `*_after` filters work on the corresponding timestamp fields.

### Common list patterns

**Recently active users:**

```javascript JavaScript theme={null}
async function recentlyActiveUsers(sinceDate) {
  const params = new URLSearchParams({
    updated_after: sinceDate.toISOString(),
  });
  let url = `https://api.vomo.org/v1/users?${params}`;

  const users = [];
  while (url) {
    const page = await fetchPage(url);
    users.push(...page.data);
    url = page.links.next;
  }
  return users;
}
```

**Users created in a specific time window:**

```javascript JavaScript theme={null}
async function newUsersForMonth(year, month) {
  const start = new Date(year, month - 1, 1);
  const end = new Date(year, month, 1);

  const params = new URLSearchParams({
    created_after: start.toISOString(),
    created_before: end.toISOString(),
  });

  return paginate(`https://api.vomo.org/v1/users?${params}`);
}
```

**Find a user by email (one-shot):**

```javascript JavaScript theme={null}
async function findUserByEmail(email) {
  const params = new URLSearchParams({ email_like: email });
  const response = await fetch(
    `https://api.vomo.org/v1/users?${params}`,
    { headers: { Authorization: `Bearer ${token}` } }
  );
  const page = await response.json();

  return page.data.find((u) => u.email?.toLowerCase() === email.toLowerCase()) ?? null;
}
```

Note the explicit equality check — `email_like` is a substring match, so a search for `bruce@wayne.example` might match `bruce@wayne.example.com` if such an address exists. Confirm with strict equality after the API call.

***

## Fetching a single user

```bash cURL theme={null}
curl https://api.vomo.org/v1/users/12345 \
  -H "Authorization: Bearer $VOMO_API_TOKEN"
```

Returns a `UserDetailResource` wrapped in `data`:

```json theme={null}
{
  "data": {
    "type": "user",
    "id": 12345,
    "first_name": "Bruce",
    /* ... other UserResource fields ... */
    "participations": [/* ... */],
    "profile_field_values": [/* ... */]
  }
}
```

For workflows that already have the user ID, this is more efficient than filtering the list — one request, full detail, no pagination.

<Warning>
  **⚠️ Spec gap (audit #50):** The spec types `data` on `GET /users/{id}` as `type: array`, but the live API returns a single object. Code should expect an object, not a single-element array.
</Warning>

### Participations

The `participations` field on `UserDetailResource` is an array of the User's participation records:

```json theme={null}
{
  "participations": [
    {
      "guests": 0,
      "checked_in_at": "2025-02-15T09:00:00Z",
      "checked_out_at": "2025-02-15T13:00:00Z",
      "signed_up_at": "2025-02-01T10:30:00Z",
      "hours": "4.00",
      "verified": true,
      "role": "Volunteer",
      "project_id": "789",
      "project_date_id": "1011"
    }
  ]
}
```

Each participation captures one User's attendance at one Project Date. See [The Volunteer Data Model: User ↔ Participation ↔ Project Date](/volunteer/concepts/data-model#the-central-pattern-user--participation--project-date).

<Warning>
  **⚠️ Spec gap (audit #40, #41):** `hours` is typed `integer` in the spec but returns fractional values (e.g., `"4.00"`). `project_id` and `project_date_id` are typed `string` but represent integer IDs. Parse `hours` as a float and the IDs as integers.
</Warning>

### Profile field values

The `profile_field_values` field captures the User's responses to custom profile fields configured in the customer's VOMO account:

```json theme={null}
{
  "profile_field_values": [
    {
      "field_id": 42,
      "field_label": "T-shirt size",
      "value": "Medium"
    },
    {
      "field_id": 43,
      "field_label": "Dietary restrictions",
      "value": "Vegetarian"
    }
  ]
}
```

Each entry has a `field_id` (stable identifier), `field_label` (the human-readable name set in the admin UI), and `value` (the User's response).

For partner integrations that need to surface or filter on custom data, this is the access path. Look up by `field_label` for readability or by `field_id` for stability.

***

## Creating and updating users (the upsert)

`POST /users` is unusual — it creates OR updates a User, with the behavior determined by whether the submitted email already exists.

### How matching works

| Submitted email                 | API behavior       | Response      |
| ------------------------------- | ------------------ | ------------- |
| Doesn't match any existing User | Creates a new User | `201 Created` |
| Matches an existing User        | Updates that User  | `200 OK`      |

The match is on `email`. There's no other way to identify the User for an update — no separate `PUT /users/{id}` exists.

<Note>
  **⚠️ Spec gap (audit #47):** The `operationId` is `createUser` but the endpoint is functionally an upsert. The spec correctly documents both `200` (updated) and `201` (created) response codes, but the operation name doesn't reflect the upsert behavior. Future spec revisions may rename this to `upsertUser` and/or split into separate create and update endpoints.
</Note>

### The request

<CodeGroup>
  ```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",
      "phone": "+15551234567",
      "birthday": "1972-02-19",
      "gender": "M",
      "role": "VOLUNTEER"
    }'
  ```

  ```javascript JavaScript theme={null}
  async function upsertUser(userData) {
    const response = await fetch('https://api.vomo.org/v1/users', {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${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.ok) throw new Error(`Upsert failed: ${response.status}`);

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

    return { user: result.data, created };
  }
  ```
</CodeGroup>

### Detecting create vs. update

The response status code distinguishes the two cases:

```javascript JavaScript theme={null}
const { user, created } = await upsertUser({
  email: 'bruce@wayne.example',
  firstName: 'Bruce',
  lastName: 'Wayne',
});

if (created) {
  console.log(`Created new user: ${user.id}`);
  await sendWelcomeEmail(user);
} else {
  console.log(`Updated existing user: ${user.id}`);
  // No welcome email — user already exists
}
```

Many integrations care about the distinction — welcome emails should only fire on creation, sync logic may behave differently for new vs. existing users. The status code is the canonical signal.

### Request body fields

Required fields for an upsert (typically — confirm against live API for the exact set):

| Field        | Type   | Notes                        |
| ------------ | ------ | ---------------------------- |
| `first_name` | string | Required                     |
| `last_name`  | string | Required                     |
| `email`      | string | Required — used for matching |

Optional fields:

| Field      | Type   | Notes                                                    |
| ---------- | ------ | -------------------------------------------------------- |
| `phone`    | string | Phone number                                             |
| `birthday` | string | ISO 8601 date (`YYYY-MM-DD`)                             |
| `gender`   | string | One of `M`, `F`, `N`                                     |
| `role`     | string | One of `VOLUNTEER`, `ORGANIZER`, `ADMIN` (per audit #46) |
| `address`  | string | Street address                                           |

<Warning>
  **⚠️ Spec gap (audit #44):** The spec's `POST /users` request body documents `birthday` with `format: "YYYY-MM-DD"` (not a valid OpenAPI format) and `gender` with `format: "M|F|N"` (not how enums are typically declared). The intent is clear (date in ISO format; gender enum), but SDK generators may struggle with these. Send ISO-8601 date strings for `birthday` and the documented enum values for `gender`.
</Warning>

### Validation errors

If the request body fails validation (missing required fields, invalid email format, etc.), the API returns `422 Unprocessable Entity` with a structured error response. See [Error Handling: 422 Validation Error](/volunteer/error-handling#422-validation-error-the-documented-case).

```javascript JavaScript theme={null}
try {
  await upsertUser(userData);
} catch (err) {
  if (err.status === 422) {
    const fieldErrors = err.body?.errors ?? {};
    Object.entries(fieldErrors).forEach(([field, messages]) => {
      console.error(`${field}: ${messages.join(', ')}`);
    });
    return;
  }
  throw err;
}
```

***

## Common workflows

### Sync from an external system

For partner integrations syncing volunteer records from an external CRM or HR system:

```javascript JavaScript theme={null}
async function syncVolunteerFromExternal(externalRecord) {
  const result = await upsertUser({
    email: externalRecord.emailAddress,
    firstName: externalRecord.firstName,
    lastName: externalRecord.lastName,
    phone: externalRecord.phoneNumber,
    birthday: externalRecord.dateOfBirth,
    role: mapRole(externalRecord.role),
  });

  await externalDb.recordSync({
    externalId: externalRecord.id,
    vomoUserId: result.user.id,
    syncedAt: new Date(),
    operation: result.created ? 'create' : 'update',
  });

  return result;
}
```

The same upsert call handles both new external records (creates the VOMO User) and updates to existing ones (updates the User). The integration doesn't need separate code paths.

### Bulk import

For one-time imports of volunteer rosters, throttle to stay within rate limits:

```javascript JavaScript theme={null}
async function importVolunteers(roster) {
  const client = new ThrottledVomoClient({ token, requestsPerSecond: 3 });
  const results = [];

  for (const record of roster) {
    try {
      const result = await client.upsertUser(record);
      results.push({ status: 'ok', record, result });
    } catch (err) {
      results.push({ status: 'failed', record, error: err.message });
    }
  }

  const created = results.filter((r) => r.status === 'ok' && r.result.created).length;
  const updated = results.filter((r) => r.status === 'ok' && !r.result.created).length;
  const failed = results.filter((r) => r.status === 'failed').length;

  console.log(`Import complete: ${created} created, ${updated} updated, ${failed} failed`);
  return results;
}
```

Throttling matters for bulk imports — see [Rate Limits](/volunteer/rate-limits).

### Find or create

```javascript JavaScript theme={null}
async function findOrCreateUser(email, defaults = {}) {
  // Try to find first
  const existing = await findUserByEmail(email);
  if (existing) return { user: existing, created: false };

  // Not found — create
  const result = await upsertUser({ email, ...defaults });
  return result;
}
```

This pattern is sometimes preferred over a blind upsert when you want explicit control over whether you're creating or updating.

### Calculate volunteer hours

```javascript JavaScript theme={null}
async function totalHoursForUser(userId) {
  const user = await getUserDetail(userId);
  return user.participations.reduce(
    (sum, p) => sum + (parseFloat(p.hours) || 0),
    0
  );
}
```

Note `parseFloat(p.hours)` rather than treating it as an integer — see the audit-flagged type issue.

***

## What can't be done via the API

| Capability                                    | Spec status                                               |
| --------------------------------------------- | --------------------------------------------------------- |
| Delete a User                                 | Not exposed in the API (deletion happens in admin UI)     |
| Look up by phone number directly              | Filter `email_like` only — no `phone_like` filter exists  |
| Modify a User by ID without knowing the email | Not possible — `POST /users` matches by email only        |
| Bulk-update many Users in one request         | Not exposed (one request per User)                        |
| Create a participation for a User             | Not exposed — participations are admin-UI-managed         |
| Add a User to a Group                         | Use `PUT /groups/{id}/members` to manage Group membership |
| Issue a Certificate to a User                 | Not exposed (admin UI only)                               |
| Trigger a password reset or invitation email  | Not exposed                                               |

For most of these, the customer's admin team handles the action through the VOMO UI. See [Understand Write Limitations](/volunteer/workflows/understand-write-limitations) for the broader picture.

***

## ID and matching considerations

A few practical patterns for partner integrations:

### Email is the matching key

Volunteer matches Users by email for upsert. Implications:

* **Email changes break matching.** If a User changes their email in your external system, a subsequent upsert will create a new VOMO User rather than update the existing one. Track email history in your integration to handle this case.
* **Email case is normalized.** Searches and matches are case-insensitive. Submit emails in any case; the API handles it.
* **Email is treated as the canonical identifier.** Two records with the same email are considered the same person — there's no way to have two Users with the same email.

### Map between systems by both ID and email

For partner integrations that sync Users across systems, maintain a mapping table:

```javascript JavaScript theme={null}
// partner_db.user_mappings table
// columns: external_id, vomo_user_id, email_at_sync, last_synced_at

async function getVomoUserForExternal(externalId) {
  const mapping = await partnerDb.userMappings.findByExternalId(externalId);
  return mapping?.vomoUserId ?? null;
}
```

Map by external ID → VOMO ID after the first successful upsert. Use the mapping for subsequent updates so email changes don't break the linkage.

### When IDs are unknown

If your integration needs to find a User but doesn't have a VOMO ID:

| Available info | Approach                                                              |
| -------------- | --------------------------------------------------------------------- |
| Email          | `GET /users?email_like=<email>`, filter to exact match                |
| Name           | `GET /users?name_like=<name>`, surface ambiguous matches for review   |
| Phone          | No direct filter; would need to paginate and filter client-side       |
| Partial info   | Combine `name_like` and `email_like` if both partial values are known |

For programmatic flows where ambiguity is unacceptable, fall back to a surfaced-for-human-review path rather than guessing.

***

## A reference user client

A minimal, well-organized User client:

```javascript JavaScript theme={null}
class VomoUsers {
  constructor({ token }) {
    this.token = token;
    this.baseUrl = 'https://api.vomo.org/v1';
  }

  async list(filters = {}) {
    const params = new URLSearchParams(filters);
    const allUsers = [];
    let url = `${this.baseUrl}/users?${params}`;

    while (url) {
      const response = await this._fetch(url);
      const page = await response.json();
      allUsers.push(...page.data.map(this._parseUser));
      url = page.links.next;
    }

    return allUsers;
  }

  async getById(userId) {
    const response = await this._fetch(`${this.baseUrl}/users/${userId}`);

    if (response.status === 404) return null;
    if (!response.ok) throw new Error(`Failed: ${response.status}`);

    const result = await response.json();
    return this._parseUserDetail(result.data);
  }

  async upsert(userData) {
    const response = await this._fetch(`${this.baseUrl}/users`, {
      method: 'POST',
      headers: { 'Content-Type': '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.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 };
  }

  async findByEmail(email) {
    const params = new URLSearchParams({ email_like: email });
    const url = `${this.baseUrl}/users?${params}`;
    const response = await this._fetch(url);
    const page = await response.json();

    return page.data
      .map(this._parseUser)
      .find((u) => u.email?.toLowerCase() === email.toLowerCase()) ?? null;
  }

  _fetch(url, options = {}) {
    return fetch(url, {
      ...options,
      headers: {
        Authorization: `Bearer ${this.token}`,
        Accept: 'application/json',
        ...options.headers,
      },
    });
  }

  _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,
      address: raw.address ?? null,
      birthday: raw.birthday ? new Date(raw.birthday) : null,
      gender: raw.gender ?? 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,
    };
  }

  _parseUserDetail(raw) {
    return {
      ...this._parseUser(raw),
      participations: (raw.participations ?? []).map(this._parseParticipation),
      profileFieldValues: (raw.profile_field_values ?? []).map(this._parseFieldValue),
    };
  }

  _parseParticipation(raw) {
    return {
      guests: raw.guests ?? 0,
      checkedInAt: raw.checked_in_at ? new Date(raw.checked_in_at) : null,
      checkedOutAt: raw.checked_out_at ? new Date(raw.checked_out_at) : null,
      signedUpAt: raw.signed_up_at ? new Date(raw.signed_up_at) : null,
      hours: typeof raw.hours === 'string' ? parseFloat(raw.hours) : raw.hours,
      verified: raw.verified ?? false,
      role: raw.role ?? null,
      projectId: raw.project_id ? parseInt(raw.project_id, 10) : null,
      projectDateId: raw.project_date_id ? parseInt(raw.project_date_id, 10) : null,
    };
  }

  _parseFieldValue(raw) {
    return {
      fieldId: raw.field_id,
      fieldLabel: raw.field_label,
      value: raw.value,
    };
  }
}
```

***

## Where to go next

<CardGroup cols={2}>
  <Card title="Projects and Project Dates" icon="calendar-days" href="/volunteer/concepts/projects-and-project-dates">
    The volunteer opportunities Users participate in.
  </Card>

  <Card title="The Volunteer Data Model" icon="diagram-project" href="/volunteer/concepts/data-model">
    The full data model context — how Users relate to other resources.
  </Card>

  <Card title="List Users with Filters" icon="filter" href="/volunteer/workflows/list-users-with-filters">
    The workflow walkthrough for filtered User reads.
  </Card>

  <Card title="Create or Update a User" icon="user-plus" href="/volunteer/workflows/create-or-update-a-user">
    The upsert workflow in workflow-page depth.
  </Card>
</CardGroup>
