Skip to main content
Users are the most commonly-polled resource in Volunteer integrations. Almost every partner integration that syncs data to an external system needs to detect “which users were created or modified since last time” — for sync to a CRM, for triggering follow-up workflows, for keeping reporting databases current. This page covers the User-specific polling pattern in production-grade detail. It builds on the foundation in Polling Overview with User-specific filters, edge cases, and the central caveat: participation changes don’t advance updated_at, so polling Users alone misses a significant class of “user activity” events. If you haven’t yet, skim the Polling Overview for the core polling pattern.

When to use this workflow

ScenarioThis workflow fits
Sync user records to an external CRM✓ Primary use case
Mirror VOMO users into a data warehouse
Trigger follow-up workflows on new user signup✓ With the new-user detection pattern
Detect users who recently updated their profile
Detect when a user participated in a shift✗ Doesn’t work via User polling — see the participation caveat
Detect when a user earned a certificate✗ Same caveat — track via separate polling or admin UI
One-time full user sync✗ Use the initial-sync pattern instead

The baseline pattern

The simplest user-change polling worker:
JavaScript
async function pollUserChanges(customerId) {
  const lastSync = await getCheckpoint(customerId, 'user_sync');

  const params = new URLSearchParams({
    updated_after: lastSync.toISOString(),
  });

  let url = `https://api.vomo.org/v1/users?${params}`;
  let latestSeen = lastSync;
  let processedCount = 0;

  while (url) {
    const response = await fetch(url, {
      headers: { Authorization: `Bearer ${process.env.VOMO_API_TOKEN}` },
    });
    if (!response.ok) throw new Error(`Poll failed: ${response.status}`);

    const page = await response.json();

    for (const user of page.data) {
      await processUserChange(customerId, user);

      const userUpdated = new Date(user.updated_at);
      if (userUpdated > latestSeen) latestSeen = userUpdated;
      processedCount++;
    }

    url = page.links.next;
  }

  await setCheckpoint(customerId, 'user_sync', latestSeen);

  return { processedCount, newCheckpoint: latestSeen };
}

// Schedule every 15 minutes
setInterval(() => pollUserChanges(customerId).catch(console.error), 15 * 60 * 1000);
This is the canonical shape. Most of this page adds layers on top.

Distinguishing new from modified users

updated_after returns both newly-created and modified users. Sometimes you want to treat them differently — fire a welcome email on creation, fire an update event on modification. The distinction: for newly-created users, created_at equals updated_at (or is very close). For updates, updated_at > created_at.
JavaScript
async function processUserChange(customerId, user) {
  const created = new Date(user.created_at);
  const updated = new Date(user.updated_at);
  const isNewlyCreated = updated.getTime() - created.getTime() < 1000; // <1 second apart

  if (isNewlyCreated) {
    await processNewUser(customerId, user);
  } else {
    await processUserUpdate(customerId, user);
  }
}

async function processNewUser(customerId, user) {
  // First-time-seeing-this-user logic
  await externalSystem.createUser(user);
  await sendWelcomeEmail(user);
}

async function processUserUpdate(customerId, user) {
  // Update-existing-user logic
  await externalSystem.updateUser(user);
}

Why this works (and why it’s approximate)

The heuristic assumes that creating a user produces created_at and updated_at simultaneously (with sub-second resolution). For most cases this is correct. Edge cases:
  • A user created and then immediately modified within the same poll window — both created_at and updated_at are recent, but they’re not equal. The heuristic might miss the “created” event and treat it as an update.
  • Bulk imports where many users are created in batch — they may all share the same created_at but have slightly different updated_at.
For more robust detection, track previously-seen User IDs:
JavaScript
async function processUserChange(customerId, user) {
  const previouslyKnown = await externalDb.userExists(customerId, user.id);

  if (!previouslyKnown) {
    await processNewUser(customerId, user);
    await externalDb.recordUserKnown(customerId, user.id);
  } else {
    await processUserUpdate(customerId, user);
  }
}
The cost: a per-user database lookup. The benefit: definitive new-vs-update detection. For most production integrations, the database approach is more reliable.

The participation caveat

The most important thing to understand about User polling: new participations do not advance the User’s updated_at.
ScenarioDoes User’s updated_at advance?
User updates their email, phone, address✓ Yes
User’s membership status changes✓ Yes (likely)
User signs up for a Project Date✗ No — the Participation is a separate record
User checks in to a shift✗ No
User’s hours are recorded✗ No
User is awarded a Certificate✗ Likely no
This is the central polling caveat. Polling /users?updated_after=X will not detect new volunteer activity.

Why this matters

A partner integration that promises “we’ll detect when your volunteers serve” cannot deliver on that promise via User polling alone. The integration architecture needs to account for this gap.

What to do instead

For detecting new participations, three options:

Option A: Poll GET /users/{id} for each user

After detecting a user change via the list endpoint, fetch detail to see their full participation list:
JavaScript
async function pollWithParticipations(customerId) {
  const changedUsers = await pollUserChanges(customerId);

  for (const userSummary of changedUsers) {
    const userDetail = await fetch(
      `https://api.vomo.org/v1/users/${userSummary.id}`,
      { headers: { Authorization: `Bearer ${token}` } }
    ).then((r) => r.json());

    const lastKnownParticipations = await externalDb.getParticipationIds(
      customerId,
      userSummary.id
    );
    const newParticipations = (userDetail.data.participations ?? []).filter(
      (p) => !lastKnownParticipations.has(p.project_date_id)
    );

    for (const participation of newParticipations) {
      await processNewParticipation(customerId, userDetail.data, participation);
      await externalDb.recordParticipation(customerId, userSummary.id, participation);
    }
  }
}
Cost: This only catches new participations for users whose User record was modified for some other reason — which is unlikely to coincide with new participations. So this option mostly doesn’t work for the stated purpose.

Option B: Periodic full participation scan

On a slower cadence (daily, weekly), iterate all users and check their participations:
JavaScript
async function dailyParticipationScan(customerId) {
  const allUsers = await listAllUsers();

  for (const userSummary of allUsers) {
    const userDetail = await getUserDetail(userSummary.id);
    const lastKnown = await externalDb.getParticipationIds(customerId, userSummary.id);

    const newParticipations = (userDetail.participations ?? []).filter(
      (p) => !lastKnown.has(p.project_date_id)
    );

    for (const p of newParticipations) {
      await processNewParticipation(customerId, userDetail, p);
      await externalDb.recordParticipation(customerId, userSummary.id, p);
    }
  }
}
Cost: N+1 — one User list + one User detail per User. For an account with 5,000 users, this is 5,001 requests every scan. Throttle aggressively and schedule for off-peak.

Option C: Poll Project Dates instead

If your integration mainly needs participation data for specific Projects, poll the Project Dates and pull participants from there:
JavaScript
async function pollProjectDateParticipations(customerId, projectId) {
  const project = await getProjectDetail(projectId);
  const recentDates = (project.all_dates ?? []).filter((d) => {
    const dEnd = new Date(d.ends_at);
    const lookback = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
    return dEnd > lookback;
  });

  for (const date of recentDates) {
    const detail = await getProjectDate(date.id);
    const participants = detail.data.participants ?? [];

    for (const p of participants) {
      const known = await externalDb.participationKnown(customerId, date.id, p.id);
      if (!known) {
        await processNewParticipation(customerId, p);
        await externalDb.recordParticipationKnown(customerId, date.id, p.id);
      }
    }
  }
}
Cost: Bounded by recent Project Dates rather than all users. For active customers with predictable Project schedules, this scales better than Option B. The right choice depends on your integration’s specific needs. See Reconciliation Patterns for the structural approach.

Combining updated_after with other filters

The polling pattern can be narrowed with additional filters when not all users need processing:

Poll only verified users

JavaScript
async function pollVerifiedUserChanges(customerId) {
  const lastSync = await getCheckpoint(customerId, 'verified_user_sync');

  const params = new URLSearchParams({
    updated_after: lastSync.toISOString(),
    // Note: the API doesn't expose user_status as a filter; we'd filter client-side
  });

  let url = `https://api.vomo.org/v1/users?${params}`;
  let latestSeen = lastSync;

  while (url) {
    const response = await fetch(url, {
      headers: { Authorization: `Bearer ${token}` },
    });
    const page = await response.json();

    const verified = page.data.filter((u) => u.user_status === 'VERIFIED');
    for (const user of verified) {
      await processUserChange(customerId, user);
      const userUpdated = new Date(user.updated_at);
      if (userUpdated > latestSeen) latestSeen = userUpdated;
    }

    url = page.links.next;
  }

  await setCheckpoint(customerId, 'verified_user_sync', latestSeen);
}
Note: the API doesn’t expose user_status as a server-side filter, so this is client-side filtering — the API returns all updated users, and your code filters in memory. The poll still consumes rate budget for all changed users, but downstream processing is narrowed.

Poll only users with email matches

JavaScript
async function pollUsersInDomain(customerId, domain) {
  const lastSync = await getCheckpoint(customerId, `user_sync_${domain}`);

  const params = new URLSearchParams({
    updated_after: lastSync.toISOString(),
    email_like: `@${domain}`,
  });

  // ... rest as before
}
This narrows server-side — useful for partner integrations scoped to a specific email domain (e.g., a corporate volunteer program’s employees).

Detecting deletions

Polling has a fundamental gap: deleted records don’t appear in queries. If a user is deleted in VOMO, polling /users?updated_after=X won’t show them — they’re just gone. For partner integrations that need to mirror deletions to external systems, the only path is reconciliation:
JavaScript
async function detectDeletedUsers(customerId) {
  // 1. Get all current VOMO users
  const currentUsers = await listAllUsers();
  const currentIds = new Set(currentUsers.map((u) => u.id));

  // 2. Compare to what your external system thinks exists
  const externalKnownIds = await externalDb.knownVomoUserIds(customerId);

  // 3. Find IDs in external but not in current
  const deletedIds = [...externalKnownIds].filter((id) => !currentIds.has(id));

  // 4. Mark deletions in external system
  for (const id of deletedIds) {
    await externalSystem.markUserDeleted(id);
    await externalDb.removeUserKnown(customerId, id);
  }

  return { deletedCount: deletedIds.length };
}
This is a slow reconciliation pattern — run daily or weekly because it’s a full-dataset operation. See Reconciliation Patterns.

A subtle gotcha: “deletion” vs. “not accessible”

A user may “disappear” from /users results for reasons other than deletion:
  • The user was moved to a different organization within the family
  • The token’s permissions changed
  • The user was banned or soft-deleted but still exists
Don’t assume “not in list” means “deleted.” Treat it as “not currently visible” and surface for review rather than immediately propagating as a deletion to the external system.

Reading user detail during the poll

The list endpoint returns abbreviated UserResource objects. If your integration needs the full UserDetailResource (with participations and profile_field_values), fetch detail per user:
JavaScript
async function pollUserChangesWithDetail(customerId) {
  const changedUsers = await pollUserChanges(customerId);

  for (const summary of changedUsers) {
    const detail = await getUserDetail(summary.id);
    await processUserFullDetail(customerId, detail);
  }
}
Cost: One additional request per changed user. For a poll cycle that detects 50 changes, that’s 51 requests instead of (roughly) 4 pages of 15.

When to fetch detail vs. when to skip

WorkflowFetch detail?
Sync basic profile to external CRMNo — list shape is sufficient
Mirror participation historyYes — detail’s participations array is the data
Display Form responsesYes — detail’s profile_field_values is the data
Just track “was modified” without specific fieldsNo
Update external system with new email/phoneNo — list shape has those
The list-shape vs. detail-shape decision matters for poll cost. Most integrations can use list shape for the change detection itself, fetching detail only for the subset of users where the additional fields matter.

Throttling and resource limits

Per-poll-cycle request cost on /users:
Pages required = ceil(changedUsers / 15)
A poll cycle that detects 150 changes pages 10 times. Multiplied across customers and resources, this is the bulk of the integration’s API traffic. A reference throttled polling worker:
JavaScript
const client = new ThrottledVomoClient({ token, requestsPerSecond: 3 });

async function pollUserChangesThrottled(customerId) {
  const lastSync = await getCheckpoint(customerId, 'user_sync');

  const params = new URLSearchParams({ updated_after: lastSync.toISOString() });
  let url = `https://api.vomo.org/v1/users?${params}`;
  let latestSeen = lastSync;

  while (url) {
    const response = await client.fetch(url);
    if (!response.ok) throw new Error(`Poll failed: ${response.status}`);

    const page = await response.json();
    for (const user of page.data) {
      await processUserChange(customerId, user);
      const u = new Date(user.updated_at);
      if (u > latestSeen) latestSeen = u;
    }

    url = page.links.next;
  }

  await setCheckpoint(customerId, 'user_sync', latestSeen);
}
A 3-req/sec rate is conservative; tune based on your overall integration’s budget across customers. See Rate Limits.

A reference user-change poller

A complete reference implementation incorporating the patterns above:
JavaScript
class UserChangePoller {
  constructor({ token, customerId, externalDb, checkpointStore }) {
    this.token = token;
    this.customerId = customerId;
    this.externalDb = externalDb;
    this.checkpoints = checkpointStore;
    this.client = new ThrottledVomoClient({ token, requestsPerSecond: 3 });
  }

  async poll() {
    const lastSync = await this.checkpoints.get(this.customerId, 'user_sync');

    const params = new URLSearchParams({
      updated_after: lastSync.toISOString(),
    });

    let url = `https://api.vomo.org/v1/users?${params}`;
    let latestSeen = lastSync;
    const failures = [];
    let processedCount = 0;

    while (url) {
      const response = await this.client.fetch(url);
      if (!response.ok) {
        throw new Error(`Poll failed: ${response.status}`);
      }

      const page = await response.json();

      for (const user of page.data) {
        try {
          await this._processUser(user);
          processedCount++;

          const u = new Date(user.updated_at);
          if (u > latestSeen) latestSeen = u;
        } catch (err) {
          failures.push({ user, error: err });
        }
      }

      url = page.links.next;
    }

    if (failures.length > 0) {
      await this._queueFailures(failures);
    }

    await this.checkpoints.set(this.customerId, 'user_sync', latestSeen);

    return {
      processedCount,
      failureCount: failures.length,
      newCheckpoint: latestSeen,
    };
  }

  async _processUser(user) {
    const previouslyKnown = await this.externalDb.userExists(this.customerId, user.id);

    if (!previouslyKnown) {
      await this._processNewUser(user);
      await this.externalDb.recordUserKnown(this.customerId, user.id);
    } else {
      await this._processUserUpdate(user);
    }
  }

  async _processNewUser(user) {
    // Customer-specific new-user logic
    await externalSystem.createUser({
      vomoId: user.id,
      firstName: user.first_name,
      lastName: user.last_name,
      email: user.email,
    });
  }

  async _processUserUpdate(user) {
    await externalSystem.updateUser({
      vomoId: user.id,
      firstName: user.first_name,
      lastName: user.last_name,
      email: user.email,
    });
  }

  async _queueFailures(failures) {
    for (const failure of failures) {
      await deadLetterQueue.publish({
        type: 'user_sync_failure',
        customerId: this.customerId,
        user: failure.user,
        error: failure.error.message,
      });
    }
  }
}

// Usage
const poller = new UserChangePoller({ token, customerId, externalDb, checkpointStore });
const result = await poller.poll();
console.log(`Processed ${result.processedCount} users (${result.failureCount} failed)`);
This handles the production-grade concerns: throttling, new-vs-update detection via persistent state, per-record failure isolation, dead-letter queueing, and correct checkpoint advancement.

Monitoring

Track these metrics per customer:
MetricWhat it tells you
Processed-users per poll cycleSync activity rate; sudden spike or drop is worth investigating
Latest checkpoint timestampConfirms polling is actually running
Lag time (now - latest_checkpoint)How fresh is the data? Sustained increase = polling issue
Dead-letter queue depthFailures accumulating — needs investigation
Poll cycle durationSustained increase may indicate large batches or rate-limit slowdown
4xx/5xx error ratesAuth issues, rate limits, server problems
A simple “is polling healthy?” alert checks that now - latest_checkpoint < 2 * poll_interval. Beyond that, no recent activity means polling has stalled.

Production checklist

For a User-change polling worker:
  • Checkpoint persisted per-customer in durable storage
  • Checkpoint advanced to the latest updated_at actually seen (not wall-clock time)
  • Per-user failures isolated; don’t fail whole batch on one bad record
  • Failed records go to a dead-letter queue
  • Rate-limit-aware throttling in place
  • Distinct paths for new-user vs. updated-user processing (where business logic differs)
  • Deletion detection runs as a separate (slower) reconciliation
  • Participation-related workflows use a different polling strategy (Project Dates, full scans, etc.)
  • Per-customer monitoring dashboards exist
  • Alerts on stalled checkpoints and growing DLQ

Where to go next

Detecting Project Changes

The Project-specific polling pattern with schedule-change considerations.

Reconciliation Patterns

The slow-scan patterns for deletion detection and gap recovery.

Change Detection Best Practices

The cross-cutting patterns — checkpointing, idempotency, drift.

Users

The reference page for User fields and the upsert behavior.
Last modified on May 22, 2026