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

# Detecting User Changes

> The User-specific polling pattern — using updated_after for incremental sync, handling the participation-not-captured caveat, distinguishing new from modified users, and detecting deletions without a webhook surface.

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](/volunteer/polling-and-sync/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](/volunteer/polling-and-sync/overview) for the core polling pattern.

## When to use this workflow

| Scenario                                        | This 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](#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 JavaScript theme={null}
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 JavaScript theme={null}
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 JavaScript theme={null}
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`.

| Scenario                                 | Does 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 JavaScript theme={null}
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 JavaScript theme={null}
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 JavaScript theme={null}
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](/volunteer/polling-and-sync/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 JavaScript theme={null}
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 JavaScript theme={null}
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 JavaScript theme={null}
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](/volunteer/polling-and-sync/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 JavaScript theme={null}
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

| Workflow                                          | Fetch detail?                                     |
| ------------------------------------------------- | ------------------------------------------------- |
| Sync basic profile to external CRM                | No — list shape is sufficient                     |
| Mirror participation history                      | Yes — detail's `participations` array is the data |
| Display Form responses                            | Yes — detail's `profile_field_values` is the data |
| Just track "was modified" without specific fields | No                                                |
| Update external system with new email/phone       | No — 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`:

```text theme={null}
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 JavaScript theme={null}
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](/volunteer/rate-limits).

***

## A reference user-change poller

A complete reference implementation incorporating the patterns above:

```javascript JavaScript theme={null}
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:

| Metric                               | What it tells you                                                    |
| ------------------------------------ | -------------------------------------------------------------------- |
| Processed-users per poll cycle       | Sync activity rate; sudden spike or drop is worth investigating      |
| Latest checkpoint timestamp          | Confirms polling is actually running                                 |
| Lag time (`now - latest_checkpoint`) | How fresh is the data? Sustained increase = polling issue            |
| Dead-letter queue depth              | Failures accumulating — needs investigation                          |
| Poll cycle duration                  | Sustained increase may indicate large batches or rate-limit slowdown |
| `4xx`/`5xx` error rates              | Auth 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

<CardGroup cols={2}>
  <Card title="Detecting Project Changes" icon="briefcase-clock" href="/volunteer/polling-and-sync/detecting-project-changes">
    The Project-specific polling pattern with schedule-change considerations.
  </Card>

  <Card title="Reconciliation Patterns" icon="rotate" href="/volunteer/polling-and-sync/reconciliation-patterns">
    The slow-scan patterns for deletion detection and gap recovery.
  </Card>

  <Card title="Change Detection Best Practices" icon="list-check" href="/volunteer/polling-and-sync/change-detection-best-practices">
    The cross-cutting patterns — checkpointing, idempotency, drift.
  </Card>

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