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

EndpointMethodWhat it does
/usersGETList Users (paginated, filterable)
/usersPOSTCreate or update a User (upsert)
/users/{id}GETGet 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:
FieldTypeDescription
typestring"user" — the VOMO object type identifier
idintegerThe User’s stable VOMO ID
first_namestringFirst name
last_namestringLast name
full_namestringConcatenated first + last (provided as a convenience)
emailstringEmail address (primary identifier for matching)
addressstringStreet address
phonestringPhone number
birthdaystringBirth date (ISO 8601 format expected, e.g., 1972-02-19)
genderstringOne of M, F, N
updated_atstringISO 8601 datetime of last modification
created_atstringISO 8601 datetime of account creation
user_statusstringThe User’s account status (e.g., VERIFIED)
membership_statusstringThe User’s membership status in the current org (e.g., ACCEPTED)
membership_rolestringThe 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:
FieldTypeDescription
participationsarrayThe User’s participation history (see Participations)
profile_field_valuesarrayThe 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.
⚠️ 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.

Membership concepts

Three status fields capture the User’s relationship to the customer’s organization:
FieldWhat it representsExample values
user_statusThe User’s overall account statusVERIFIED, UNVERIFIED, BANNED (other values likely exist)
membership_statusThe User’s membership status in the current orgACCEPTED, PENDING, REJECTED (other values likely exist)
membership_roleThe User’s role in the current orgVOLUNTEER, ORGANIZER, ADMIN (other values likely exist)
⚠️ 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.

Listing users

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

Available filters

ParameterWhat it filters by
pagePage number (default 1)
name_likeSubstring match against first AND last name
email_likeSubstring match against email
created_beforeUsers created on or before a date (ISO 8601)
created_afterUsers created on or after a date
updated_beforeUsers updated on or before a date
updated_afterUsers 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
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
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
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

cURL
curl https://api.vomo.org/v1/users/12345 \
  -H "Authorization: Bearer $VOMO_API_TOKEN"
Returns a UserDetailResource wrapped in data:
{
  "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.
⚠️ 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.

Participations

The participations field on UserDetailResource is an array of the User’s participation records:
{
  "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.
⚠️ 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.

Profile field values

The profile_field_values field captures the User’s responses to custom profile fields configured in the customer’s VOMO account:
{
  "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 emailAPI behaviorResponse
Doesn’t match any existing UserCreates a new User201 Created
Matches an existing UserUpdates that User200 OK
The match is on email. There’s no other way to identify the User for an update — no separate PUT /users/{id} exists.
⚠️ 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.

The request

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"
  }'

Detecting create vs. update

The response status code distinguishes the two cases:
JavaScript
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):
FieldTypeNotes
first_namestringRequired
last_namestringRequired
emailstringRequired — used for matching
Optional fields:
FieldTypeNotes
phonestringPhone number
birthdaystringISO 8601 date (YYYY-MM-DD)
genderstringOne of M, F, N
rolestringOne of VOLUNTEER, ORGANIZER, ADMIN (per audit #46)
addressstringStreet address
⚠️ 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.

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

Find or create

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

CapabilitySpec status
Delete a UserNot exposed in the API (deletion happens in admin UI)
Look up by phone number directlyFilter email_like only — no phone_like filter exists
Modify a User by ID without knowing the emailNot possible — POST /users matches by email only
Bulk-update many Users in one requestNot exposed (one request per User)
Create a participation for a UserNot exposed — participations are admin-UI-managed
Add a User to a GroupUse PUT /groups/{id}/members to manage Group membership
Issue a Certificate to a UserNot exposed (admin UI only)
Trigger a password reset or invitation emailNot exposed
For most of these, the customer’s admin team handles the action through the VOMO UI. See 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
// 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 infoApproach
EmailGET /users?email_like=<email>, filter to exact match
NameGET /users?name_like=<name>, surface ambiguous matches for review
PhoneNo direct filter; would need to paginate and filter client-side
Partial infoCombine 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
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

Projects and Project Dates

The volunteer opportunities Users participate in.

The Volunteer Data Model

The full data model context — how Users relate to other resources.

List Users with Filters

The workflow walkthrough for filtered User reads.

Create or Update a User

The upsert workflow in workflow-page depth.
Last modified on May 22, 2026