> ## 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 Project Changes

> The Project-specific polling pattern — using updated_after for metadata changes, the schedule-change considerations, distinguishing Project changes from Project Date changes, and the participant detection patterns.

Projects change less frequently than Users but matter more per-change. A renamed Project, a modified schedule, an added Project Date — these often have downstream implications for partner integrations: updated external calendar feeds, refreshed reporting dashboards, notifications to interested volunteers.

This page covers Project-specific polling: how `updated_after` works for Projects, what changes are (and aren't) captured, how to detect schedule changes, and how participant detection sits adjacent to (but distinct from) Project polling.

If you haven't yet, skim the [Polling Overview](/volunteer/polling-and-sync/overview) for the foundational pattern.

## When to use this workflow

| Scenario                                                | This workflow fits                      |
| ------------------------------------------------------- | --------------------------------------- |
| Mirror Project metadata to an external system           | ✓ Primary use case                      |
| Generate iCal feeds that stay current                   | ✓ Detect schedule changes               |
| Detect new Project Dates added to a Project             | ✓ With the schedule-change pattern      |
| Notify volunteers when a Project's policy changes       | ✓                                       |
| Sync the org's full Project catalog to a data warehouse | ✓                                       |
| Detect new participants for a specific Project Date     | ✗ Use Project Date polling instead      |
| Detect when a Project Date's participants change        | ✗ Same                                  |
| Find Projects with available capacity                   | ✗ This is a query, not change detection |

***

## The baseline pattern

```javascript JavaScript theme={null}
async function pollProjectChanges(customerId) {
  const lastSync = await getCheckpoint(customerId, 'project_sync');

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

  let url = `https://api.vomo.org/v1/projects?${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 project of page.data) {
      await processProjectChange(customerId, project);

      const projectUpdated = new Date(project.updated_at);
      if (projectUpdated > latestSeen) latestSeen = projectUpdated;
      processedCount++;
    }

    url = page.links.next;
  }

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

  return { processedCount, newCheckpoint: latestSeen };
}

// Schedule less frequently than Users — Projects change less often
setInterval(() => pollProjectChanges(customerId).catch(console.error), 60 * 60 * 1000);
```

Hourly is a reasonable starting cadence for Projects. Most Projects change a few times during their setup phase and then stay stable; constant polling produces minimal change but consumes rate budget.

***

## Useful filters for Project polling

Projects support more filter parameters than Users — useful for narrowing the polling scope:

| Parameter       | What it filters by                                    |
| --------------- | ----------------------------------------------------- |
| `updated_after` | Projects modified since X                             |
| `active`        | `true`/`false` — only active Projects                 |
| `published`     | `true`/`false` — only published Projects              |
| `anytime`       | Include "anytime" Projects (no specific dates)        |
| `org_slug`      | Filter to one or more organizations (comma-separated) |
| `name_like`     | Substring match on name                               |
| `dates_before`  | Projects with dates before X                          |
| `dates_after`   | Projects with dates after X                           |

A common production pattern: **poll only active, published, recent Projects:**

```javascript JavaScript theme={null}
async function pollActiveProjectChanges(customerId) {
  const lastSync = await getCheckpoint(customerId, 'active_project_sync');

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

  let url = `https://api.vomo.org/v1/projects?${params}`;
  // ... (rest as before)
}
```

This narrows polling to Projects that actually matter for downstream workflows. Drafts and archived Projects are skipped, reducing both API request volume and downstream noise.

### Per-organization polling

For multi-org customers, polling can be split by child organization:

```javascript JavaScript theme={null}
async function pollProjectChangesForChildOrgs(customerId) {
  const childOrgSlugs = await getActiveChildOrgs(customerId);

  for (const slug of childOrgSlugs) {
    await pollProjectsForOrg(customerId, slug);
  }
}

async function pollProjectsForOrg(customerId, orgSlug) {
  const lastSync = await getCheckpoint(customerId, `project_sync_${orgSlug}`);

  const params = new URLSearchParams({
    updated_after: lastSync.toISOString(),
    org_slug: orgSlug,
    active: 'true',
    published: 'true',
  });

  // ...
}
```

This allows different child orgs to have independent polling cadences, checkpoints, and processing logic — useful when partner integrations route data to per-org destinations.

***

## What `updated_at` captures for Projects

The Project's `updated_at` advances when:

| Change                                                            | Advances `updated_at`?                                                     |
| ----------------------------------------------------------------- | -------------------------------------------------------------------------- |
| Project's name or description changes                             | ✓ Yes                                                                      |
| `age_limit`, `allow_guests`, or policy changes                    | ✓ Yes                                                                      |
| `draft` status flips (publish or unpublish)                       | ✓ Yes                                                                      |
| `privacy` setting changes                                         | ✓ Yes                                                                      |
| Address change                                                    | ✓ Yes                                                                      |
| Schedule modification (Project Dates added, removed, or modified) | ✓ Likely (confirm with live behavior)                                      |
| Project's `campaigns` attachment changes                          | ✓ Likely                                                                   |
| A new participant signs up for a Project Date                     | ✗ No — see [Detecting participant changes](#detecting-participant-changes) |
| A participant checks in/out                                       | ✗ No                                                                       |

The schedule-change behavior is the most important one to verify in your environment. Most integrations treat "Project's `updated_at` advanced" as a signal to re-read the full Project (including `all_dates`) to detect what changed.

***

## Detecting schedule changes

When a Project's `updated_at` advances, the change might be:

* Metadata (name, description, policy)
* Schedule (Project Dates added, removed, or modified)
* Both

To detect schedule changes specifically, **compare the current `all_dates` to a previous snapshot**:

```javascript JavaScript theme={null}
async function processProjectChange(customerId, projectSummary) {
  // 1. Fetch the full Project detail (with all_dates)
  const project = await getProjectDetail(projectSummary.id);

  // 2. Get the last-known schedule from our external store
  const lastKnown = await externalDb.getProjectSchedule(customerId, project.id);

  // 3. Diff the schedules
  const currentDates = (project.all_dates ?? []).map((d) => ({
    id: d.id,
    starts_at: d.starts_at,
    ends_at: d.ends_at,
  }));
  const lastKnownDates = lastKnown?.dates ?? [];

  const scheduleDiff = computeScheduleDiff(lastKnownDates, currentDates);

  // 4. Process the diff
  for (const added of scheduleDiff.added) {
    await processNewProjectDate(customerId, project, added);
  }
  for (const removed of scheduleDiff.removed) {
    await processRemovedProjectDate(customerId, project, removed);
  }
  for (const modified of scheduleDiff.modified) {
    await processModifiedProjectDate(customerId, project, modified);
  }

  // 5. Process metadata changes
  await processProjectMetadata(customerId, project);

  // 6. Update the stored schedule snapshot
  await externalDb.setProjectSchedule(customerId, project.id, {
    dates: currentDates,
    snapshotAt: new Date(),
  });
}

function computeScheduleDiff(previous, current) {
  const prevByDateId = new Map(previous.map((d) => [d.id, d]));
  const currByDateId = new Map(current.map((d) => [d.id, d]));

  const added = current.filter((d) => !prevByDateId.has(d.id));
  const removed = previous.filter((d) => !currByDateId.has(d.id));
  const modified = current.filter((d) => {
    const prev = prevByDateId.get(d.id);
    return prev && (prev.starts_at !== d.starts_at || prev.ends_at !== d.ends_at);
  });

  return { added, removed, modified };
}
```

The pattern: **detect that the Project changed (via polling), fetch the full detail (with embedded `all_dates`), diff against the last-known snapshot, and emit per-change events**.

This adds cost (one detail fetch per changed Project), but it's how you turn a "Project changed somehow" signal into "this specific Project Date was added at this time."

### Storing the schedule snapshot

The snapshot is per-Project, per-customer. A simple structure:

```javascript JavaScript theme={null}
// Pseudo-schema for the snapshot store
{
  customerId: 'cust-001',
  vomoProjectId: 789,
  dates: [
    { id: 1011, starts_at: '2025-04-19T14:00:00Z', ends_at: '2025-04-19T18:00:00Z' },
    { id: 1012, starts_at: '2025-04-26T14:00:00Z', ends_at: '2025-04-26T18:00:00Z' },
  ],
  snapshotAt: '2025-04-19T15:30:00Z',
}
```

Update the snapshot after each successful processing. The next polling cycle uses it as the comparison base.

***

## Detecting participant changes

**Participants on a Project Date are not detected by polling Projects.** The Project's `updated_at` doesn't advance when a participant signs up or checks in.

For partner integrations that need participant-change detection:

### Option A: Poll Project Dates separately

For each active Project, poll its Project Dates' participants on a schedule:

```javascript JavaScript theme={null}
async function pollParticipants(customerId, projectId) {
  // 1. Get the Project's current schedule
  const project = await getProjectDetail(projectId);
  const recentDates = (project.all_dates ?? []).filter((d) => {
    const dStart = new Date(d.starts_at);
    const now = new Date();
    return dStart < new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); // within next week
  });

  // 2. For each upcoming/recent Date, fetch detail (with participants)
  for (const date of recentDates) {
    const dateDetail = await getProjectDate(date.id);
    const participants = dateDetail.data.participants ?? [];

    // 3. Get the last-known participant set
    const lastKnown = await externalDb.getDateParticipants(customerId, date.id);
    const currentIds = new Set(participants.map((p) => p.id));
    const lastKnownIds = new Set(lastKnown ?? []);

    // 4. Diff
    const newParticipants = participants.filter((p) => !lastKnownIds.has(p.id));
    const removedParticipantIds = [...lastKnownIds].filter((id) => !currentIds.has(id));

    for (const p of newParticipants) {
      await processNewParticipant(customerId, project, date, p);
    }
    for (const removedId of removedParticipantIds) {
      await processRemovedParticipant(customerId, project, date, removedId);
    }

    // 5. Update snapshot
    await externalDb.setDateParticipants(customerId, date.id, [...currentIds]);
  }
}
```

**Cost:** One detail fetch per Project Date per poll cycle. For a customer with 10 active Projects, each with 3 upcoming Dates, that's 30 requests per cycle. Throttle accordingly.

### Option B: Bound the polling scope

Don't poll all participants for all Project Dates — limit to:

* Projects you specifically care about (configured by customer)
* Project Dates in a recent time window (past week + next week)
* Project Dates that have changed `participant_count` (compare against snapshot)

```javascript JavaScript theme={null}
async function pollChangedParticipantCounts(customerId, projectId) {
  const project = await getProjectDetail(projectId);
  const allDates = project.all_dates ?? [];

  for (const date of allDates) {
    const lastKnownCount = await externalDb.getDateParticipantCount(customerId, date.id);

    if (date.participant_count !== lastKnownCount) {
      // Count changed — fetch participants to diff
      const dateDetail = await getProjectDate(date.id);
      // ... process as before
      await externalDb.setDateParticipantCount(customerId, date.id, date.participant_count);
    }
    // Otherwise skip — count hasn't changed, no participant changes
  }
}
```

The `participant_count` field on `all_dates` is updated by VOMO when participants are added or removed. Using it as a tripwire avoids fetching detail for Project Dates that haven't changed.

### Option C: Periodic full participant scan

For integrations where participant detection isn't real-time, scan periodically (daily or weekly) and reconcile against the last snapshot. See [Reconciliation Patterns](/volunteer/polling-and-sync/reconciliation-patterns).

***

## Cadence considerations

Projects change less frequently than Users, so polling can be less aggressive:

| Workload                                    | Suggested cadence                    |
| ------------------------------------------- | ------------------------------------ |
| Project metadata sync                       | Hourly                               |
| Schedule mirroring (iCal, calendar feeds)   | Every 1-2 hours                      |
| Per-org Project catalogue refresh           | Every 4-6 hours                      |
| Participant detection (Option A — per Date) | Every 15-30 minutes for active dates |
| Full participant scan (Option C)            | Daily                                |

A common pattern: **Projects polled hourly; participants polled more frequently but only for "active" Project Dates** (those within a recent or upcoming window).

***

## Combining metadata and schedule polling

For most integrations, one polling worker handles both metadata changes and schedule changes — they happen at the same cadence because they share the same `updated_at`:

```javascript JavaScript theme={null}
async function pollProjectFullChanges(customerId) {
  const lastSync = await getCheckpoint(customerId, 'project_sync');
  const params = new URLSearchParams({
    updated_after: lastSync.toISOString(),
    active: 'true',
    published: 'true',
  });

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

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

    for (const projectSummary of page.data) {
      try {
        // Fetch full detail (one extra request per changed Project)
        const project = await getProjectDetail(projectSummary.id);

        // Process metadata
        await processProjectMetadata(customerId, project);

        // Process schedule diff
        const lastSnapshot = await externalDb.getProjectSchedule(customerId, project.id);
        const diff = computeScheduleDiff(
          lastSnapshot?.dates ?? [],
          (project.all_dates ?? []).map(toScheduleEntry)
        );
        await processScheduleDiff(customerId, project, diff);

        // Update snapshot
        await externalDb.setProjectSchedule(customerId, project.id, {
          dates: (project.all_dates ?? []).map(toScheduleEntry),
          snapshotAt: new Date(),
        });

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

    url = page.links.next;
  }

  if (failures.length > 0) {
    await deadLetterQueue.publish(failures);
  }
  await setCheckpoint(customerId, 'project_sync', latestSeen);
}

function toScheduleEntry(d) {
  return { id: d.id, starts_at: d.starts_at, ends_at: d.ends_at };
}
```

The cost: list query + one detail fetch per changed Project. For an account with stable Projects (most days no changes), this is cheap. For periods of active scheduling changes, it scales linearly.

***

## Handling deletions

Like Users, Project deletions aren't detected through `updated_after` polling. The same reconciliation pattern applies:

```javascript JavaScript theme={null}
async function detectDeletedProjects(customerId) {
  // 1. Get all current Projects
  const currentParams = new URLSearchParams({ active: 'true', published: 'true' });
  const currentProjects = await paginate(`https://api.vomo.org/v1/projects?${currentParams}`);
  const currentIds = new Set(currentProjects.map((p) => p.id));

  // 2. Compare to external store
  const externalKnownIds = await externalDb.knownVomoProjectIds(customerId);
  const missingIds = [...externalKnownIds].filter((id) => !currentIds.has(id));

  // 3. For each missing ID, fetch detail to determine if it's deleted vs unpublished
  for (const id of missingIds) {
    const project = await getProjectDetail(id);
    if (!project) {
      // 404 — truly deleted
      await externalSystem.markProjectDeleted(id);
      await externalDb.removeProjectKnown(customerId, id);
    } else {
      // Still exists but didn't match filters (e.g., draft or archived)
      await externalSystem.markProjectInactive(id);
    }
  }
}
```

Run daily or weekly. See [Reconciliation Patterns](/volunteer/polling-and-sync/reconciliation-patterns).

### Why the per-ID detail fetch

A Project missing from the filtered list could be:

* Genuinely deleted (returns `404` on detail fetch)
* Set to `draft: true` (no longer in `published: true` list)
* Set to `active: false` (no longer in `active: true` list)
* Outside the date filter window (if you used one)

The detail fetch distinguishes these cases. Without it, you might propagate "deleted" to external systems for Projects that were merely unpublished.

***

## A reference project-change poller

```javascript JavaScript theme={null}
class ProjectChangePoller {
  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, 'project_sync');

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

    let url = `https://api.vomo.org/v1/projects?${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 projectSummary of page.data) {
        try {
          await this._processChangedProject(projectSummary);
          processedCount++;

          const u = new Date(projectSummary.updated_at);
          if (u > latestSeen) latestSeen = u;
        } catch (err) {
          failures.push({ projectId: projectSummary.id, error: err });
        }
      }
      url = page.links.next;
    }

    if (failures.length > 0) {
      await this._queueFailures(failures);
    }
    await this.checkpoints.set(this.customerId, 'project_sync', latestSeen);

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

  async _processChangedProject(projectSummary) {
    // Fetch detail (with all_dates)
    const detailResponse = await this.client.fetch(
      `https://api.vomo.org/v1/projects/${projectSummary.id}`
    );
    if (!detailResponse.ok) {
      throw new Error(`Detail fetch failed: ${detailResponse.status}`);
    }
    const project = (await detailResponse.json()).data;

    // Process metadata
    await this._processMetadata(project);

    // Process schedule diff
    const lastSnapshot = await this.externalDb.getProjectSchedule(this.customerId, project.id);
    const currentDates = (project.all_dates ?? []).map(this._toScheduleEntry);
    const diff = this._computeScheduleDiff(lastSnapshot?.dates ?? [], currentDates);

    await this._processScheduleDiff(project, diff);

    // Update snapshot
    await this.externalDb.setProjectSchedule(this.customerId, project.id, {
      dates: currentDates,
      snapshotAt: new Date(),
    });
  }

  async _processMetadata(project) {
    await externalSystem.updateProject({
      vomoId: project.id,
      name: project.name,
      description: project.description,
      // ... other fields ...
    });
  }

  async _processScheduleDiff(project, diff) {
    for (const added of diff.added) {
      await externalSystem.recordProjectDate(project.id, added);
    }
    for (const removed of diff.removed) {
      await externalSystem.removeProjectDate(project.id, removed.id);
    }
    for (const modified of diff.modified) {
      await externalSystem.updateProjectDate(project.id, modified);
    }
  }

  _computeScheduleDiff(previous, current) {
    const prevByDateId = new Map(previous.map((d) => [d.id, d]));
    const currByDateId = new Map(current.map((d) => [d.id, d]));

    return {
      added: current.filter((d) => !prevByDateId.has(d.id)),
      removed: previous.filter((d) => !currByDateId.has(d.id)),
      modified: current.filter((d) => {
        const p = prevByDateId.get(d.id);
        return p && (p.starts_at !== d.starts_at || p.ends_at !== d.ends_at);
      }),
    };
  }

  _toScheduleEntry(d) {
    return { id: d.id, starts_at: d.starts_at, ends_at: d.ends_at };
  }

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

// Usage
const poller = new ProjectChangePoller({ token, customerId, externalDb, checkpointStore });
const result = await poller.poll();
console.log(`Processed ${result.processedCount} projects (${result.failureCount} failed)`);
```

***

## Monitoring

Per-customer metrics worth tracking:

| Metric                                            | What it tells you                                     |
| ------------------------------------------------- | ----------------------------------------------------- |
| Projects changed per poll cycle                   | Activity level; spike may indicate bulk admin changes |
| Schedule diffs per cycle (added/removed/modified) | What kind of changes are happening                    |
| Time since last checkpoint advance                | Polling worker health                                 |
| Failed Project processing rate                    | Per-Project errors needing investigation              |
| Detail-fetch latency                              | API health proxy                                      |

A polled-Project-without-schedule-diff is mostly a metadata change. A polled-Project-with-many-schedule-diffs may indicate a customer doing bulk scheduling — interesting for capacity planning.

***

## Production checklist

For a Project-change polling worker:

* Polling filters to `active: true, published: true` (or whatever scope the integration cares about)
* Checkpoint persisted per-customer
* Checkpoint advanced to latest `updated_at` actually seen
* Detail fetch per changed Project for schedule diff
* Schedule snapshot stored externally per-Project
* Per-Project processing failures isolated; logged and queued
* Schedule diff computes added/removed/modified per Date
* Rate-limit-aware throttling
* Deletion detection runs as a separate reconciliation
* Participant detection (if needed) runs as a separate poller
* Per-customer monitoring dashboards

***

## Where to go next

<CardGroup cols={2}>
  <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="Detecting User Changes" icon="user-clock" href="/volunteer/polling-and-sync/detecting-user-changes">
    The User-specific polling pattern.
  </Card>

  <Card title="Projects and Project Dates" icon="briefcase" href="/volunteer/concepts/projects-and-project-dates">
    The reference for Project resource fields and the all\_dates / next\_date pattern.
  </Card>
</CardGroup>
