Skip to main content
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 for the foundational pattern.

When to use this workflow

ScenarioThis 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
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:
ParameterWhat it filters by
updated_afterProjects modified since X
activetrue/false — only active Projects
publishedtrue/false — only published Projects
anytimeInclude “anytime” Projects (no specific dates)
org_slugFilter to one or more organizations (comma-separated)
name_likeSubstring match on name
dates_beforeProjects with dates before X
dates_afterProjects with dates after X
A common production pattern: poll only active, published, recent Projects:
JavaScript
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
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:
ChangeAdvances 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
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
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
// 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
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
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.

Cadence considerations

Projects change less frequently than Users, so polling can be less aggressive:
WorkloadSuggested cadence
Project metadata syncHourly
Schedule mirroring (iCal, calendar feeds)Every 1-2 hours
Per-org Project catalogue refreshEvery 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
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
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.

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
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:
MetricWhat it tells you
Projects changed per poll cycleActivity level; spike may indicate bulk admin changes
Schedule diffs per cycle (added/removed/modified)What kind of changes are happening
Time since last checkpoint advancePolling worker health
Failed Project processing ratePer-Project errors needing investigation
Detail-fetch latencyAPI 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

Reconciliation Patterns

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

Change Detection Best Practices

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

Detecting User Changes

The User-specific polling pattern.

Projects and Project Dates

The reference for Project resource fields and the all_dates / next_date pattern.
Last modified on May 22, 2026