Skip to main content
This page walks through a more substantive API call than the bare-minimum Quickstart — fetching a single user by ID and parsing the rich detail response that comes back. By the end, you’ll understand the difference between list-shape and detail-shape resources, how nested fields like participations and profile_field_values work, and the practical pattern for handling responses where the spec and live API don’t perfectly agree. If you haven’t completed the Quickstart, start there. This page assumes you have a working token and have made at least one successful request.

What we’re building

A function that fetches a single user with full detail — name, contact info, participation history, profile field responses — and parses the response into a clean object the rest of the integration can use.
JavaScript
const user = await getUserDetail(userId);
console.log(`${user.fullName} has ${user.participations.length} participations`);
The pattern applies to most “fetch a single record” workflows across the API. Once you understand it for users, it transfers directly to projects, groups, and other resources.

List shape vs. detail shape

A core pattern in the Volunteer API: list endpoints return abbreviated resources; single-resource endpoints return fuller ones. For users specifically:
EndpointReturnsSchema
GET /users (list)An array of UserResource — the basic profile fieldsEach user has core identity fields
GET /users/{id} (single)A UserDetailResource — the basic fields plus participation history and profile field responsesIncludes everything in UserResource plus extensions
This pattern is common in REST APIs designed around resource efficiency — list endpoints stay fast by returning only the fields most callers need, and single-resource endpoints provide the deep dive when needed.
⚠️ Spec gap: The OpenAPI spec’s UserDetailResource schema only formally documents two properties (participations and profile_field_values). In practice, the live API likely also returns the base UserResource fields alongside these — id, first_name, email, etc. — making UserDetailResource a superset of UserResource, not a replacement for it.The code patterns on this page assume the superset shape. Confirm against the live API for production-critical workflows.

Step 1: get a user ID

Most “fetch single user” workflows start by knowing the ID. Common ways to get one:
SourceNotes
Previous list callGET /users returns each user’s id. Capture and use it.
Search by name or emailGET /users?name_like=wayne or ?email_like=bruce@wayne.example returns matching users. Take the first match (or surface for disambiguation if multiple).
External systemSome integrations get the VOMO user ID by some other path — a previous sync, a stored mapping, a webhook from another system.
For this walkthrough, assume you have a user ID — say 12345.

Step 2: fetch the user

cURL
curl https://api.vomo.org/v1/users/12345 \
  -H "Authorization: Bearer $VOMO_API_TOKEN" \
  -H "Accept: application/json"
A typical successful response (truncated):
{
  "data": {
    "type": "user",
    "id": 12345,
    "first_name": "Bruce",
    "last_name": "Wayne",
    "full_name": "Bruce Wayne",
    "email": "bruce@wayne.example",
    "address": "1007 Mountain Drive, Gotham",
    "phone": "+15551234567",
    "birthday": "1972-02-19",
    "gender": "M",
    "updated_at": "2025-03-15T14:22:10Z",
    "created_at": "2024-08-01T09:00:00Z",
    "user_status": "VERIFIED",
    "membership_status": "ACCEPTED",
    "membership_role": "VOLUNTEER",
    "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"
      }
    ],
    "profile_field_values": [
      {
        "field_id": 42,
        "field_label": "T-shirt size",
        "value": "Medium"
      }
    ]
  }
}
Three things to notice about the response:
ElementDescription
data is an object (not an array)Single-resource endpoints return a single object inside data, not a single-element array
Base user fields are presentfirst_name, email, etc. are at the top level of data alongside the detail-specific fields
Nested arrays for participations and profile field valuesThe detail response includes related records embedded in the main response
The OpenAPI spec documents data for GET /users/{id} as type: array (per audit finding #50), but the live API returns a single object as shown above. Code should expect an object, not an array. If your code is generated from the spec, it may need manual adjustment.

Step 3: parse the response

A parsing helper that handles the differences between spec and live API:
JavaScript
async function getUserDetail(userId) {
  const response = await fetch(
    `https://api.vomo.org/v1/users/${userId}`,
    {
      headers: {
        Authorization: `Bearer ${process.env.VOMO_API_TOKEN}`,
        Accept: 'application/json',
      },
    }
  );

  if (response.status === 404) {
    return null; // User doesn't exist
  }

  if (!response.ok) {
    throw new Error(`Failed to fetch user ${userId}: ${response.status}`);
  }

  const result = await response.json();
  const raw = result.data;

  // Parse into a clean, integration-friendly shape
  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,
    participations: (raw.participations ?? []).map(parseParticipation),
    profileFieldValues: (raw.profile_field_values ?? []).map(parseProfileFieldValue),
  };
}

function 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: typed integer in spec but live returns fractional values
    hours: typeof raw.hours === 'string' ? parseFloat(raw.hours) : raw.hours,
    verified: raw.verified ?? false,
    role: raw.role ?? null,
    // project_id and project_date_id: typed string in spec; parse to integer for joins
    projectId: raw.project_id ? parseInt(raw.project_id, 10) : null,
    projectDateId: raw.project_date_id ? parseInt(raw.project_date_id, 10) : null,
  };
}

function parseProfileFieldValue(raw) {
  return {
    fieldId: raw.field_id,
    fieldLabel: raw.field_label,
    value: raw.value,
  };
}
Three patterns this parser gets right:
PatternWhy it matters
?? null for optional fieldsTolerates missing fields rather than crashing on undefined.something
hours parsing as floatSpec types it integer but live returns "4.00" — see audit finding #40
project_id / project_date_id parsing to intSpec types them string but they’re IDs that join to integer-typed records elsewhere
new Date() wrapping for ISO stringsNative Date objects are easier to work with than ISO strings in downstream code

Step 4: handle the four-case shape

When fetching a single resource, four cases can occur:
HTTP statusWhat it meansCode response
200 OKUser found and returnedParse and return
404 Not FoundUser with that ID doesn’t existReturn null; caller decides how to handle
401 UnauthorizedToken invalidDon’t retry; alert
5xxTransient server errorRetry with backoff
JavaScript
async function getUserDetailRobust(userId) {
  try {
    const response = await fetch(
      `https://api.vomo.org/v1/users/${userId}`,
      { headers: { Authorization: `Bearer ${token}` } }
    );

    switch (true) {
      case response.ok:
        return parseUser(await response.json());

      case response.status === 404:
        return null;

      case response.status === 401:
        await alertCredentialIssue(userId);
        throw new AuthError('VOMO token is invalid');

      case response.status === 403:
        throw new ForbiddenError(`No permission for user ${userId}`);

      case response.status >= 500:
        // Transient — caller should retry with backoff
        throw new TransientError(`Server error: ${response.status}`);

      default:
        throw new Error(`Unexpected status: ${response.status}`);
    }
  } catch (err) {
    if (err instanceof AuthError || err instanceof ForbiddenError) {
      throw err;
    }
    if (err.code === 'ECONNRESET' || err.code === 'ETIMEDOUT') {
      throw new TransientError(`Network error: ${err.message}`);
    }
    throw err;
  }
}
The classification matters because retry logic depends on it. See Error Handling for the broader pattern.

Step 5: do something useful with the parsed user

A few patterns for what to do once you have the user object:

Calculate total hours volunteered

JavaScript
const user = await getUserDetail(12345);

const totalHours = user.participations.reduce((sum, p) => sum + (p.hours ?? 0), 0);
console.log(`${user.fullName} has volunteered ${totalHours} hours total`);
The hours parsing as float (rather than integer) matters here — a user with three participations of 1.5, 2.0, and 0.75 hours sums to 4.25, not the truncated integer 4.

Find the user’s most recent participation

JavaScript
const user = await getUserDetail(12345);

const sortedParticipations = user.participations
  .filter((p) => p.checkedOutAt)
  .sort((a, b) => b.checkedOutAt - a.checkedOutAt);

const mostRecent = sortedParticipations[0];
if (mostRecent) {
  console.log(`Last volunteered: ${mostRecent.checkedOutAt.toISOString()}`);
}
This sorts the embedded participations by checked_out_at descending. For users with many participations, this is more efficient than a separate query.

Display profile field values

JavaScript
function getProfileFieldValue(user, label) {
  return user.profileFieldValues.find((v) => v.fieldLabel === label)?.value ?? null;
}

const tshirtSize = getProfileFieldValue(user, 'T-shirt size');
const dietaryRestrictions = getProfileFieldValue(user, 'Dietary restrictions');
The profile field values are keyed by label (the field’s display name) — useful for displaying or filtering on custom data the customer has set up.

Patterns for other detail-shape resources

The same pattern applies to other single-resource fetches in the Volunteer API:

GET /projects/{id} returns ProjectDetailResource

Returns project metadata plus embedded data like dates, owners, and other relationships. Parse similarly to user detail.

GET /projects/date/{id} returns Project Date detail

Returns a specific occurrence of a project with the embedded participations for that date.

GET /groups/{id} returns a single group

Returns the group with its metadata. Members are fetched separately via GET /groups/{id}/members.

GET /organizations/{id} returns organization detail

Returns the organization with its full address, logo, contact info, and parent/child organization relationships.
⚠️ Spec gap: Several detail-shape endpoints — GET /organizations, GET /organizations/{id}, GET /campaigns, GET /campaigns/{id}, GET /projects/{id}, PUT /projects/{id}, GET /projects/date/{id} — define their 200 responses with an empty schema: {} in the spec (per audit finding #4). The response shape is conveyed only through inline examples. Build parsers from the actual response shapes, treating the spec’s inline examples as a guide.

What about creating or updating a user?

POST /users creates or updates a user (it’s an upsert — see audit finding #47). The request body shape:
JavaScript
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,
      // Note: snake_case property names in the request body
    }),
  });

  // 200 = updated existing user; 201 = created new user
  if (!response.ok) throw new Error(`Upsert failed: ${response.status}`);

  return parseUser(await response.json());
}
Notable points:
  • The request body uses snake_case (consistent with the API’s overall casing).
  • The response distinguishes between 200 (updated) and 201 (created) — code can detect which behavior occurred.
  • Matching for the upsert is typically done by email — submitting a user with an existing email updates that user; submitting with a new email creates one.
See Create or Update a User for the full upsert workflow.

What’s now in your toolkit

After this walkthrough, you have:
  • A working “fetch user by ID” pattern with the detail-shape response handled correctly
  • A parser that translates the API’s snake_case fields and audit-flagged type quirks into a clean integration-friendly shape
  • An error-handling pattern for the four-case shape (200/404/401/5xx)
  • The pattern transferable to other single-resource fetches (GET /projects/{id}, GET /groups/{id}, etc.)
  • The upsert pattern for POST /users
This is enough to build most read-oriented integrations. The next step is wiring up multi-record reads with pagination, then choosing the right error-handling strategy.

Where to go next

Error Handling

The full error-classification pattern for production-grade integration code.

Pagination

The pattern for reading more than one page of users.

The Volunteer Data Model

What other resources are available and how they relate.

Common Workflows

Recipes for the common tasks built on the patterns introduced here.
Last modified on May 22, 2026