Skip to main content
Polling catches most changes — but not all. Some events slip through the gaps:
  • Deletions don’t appear in updated_after queries (the record is just gone)
  • Silent processing failures advance the checkpoint despite the change not being applied
  • Field changes that don’t advance updated_at (rare but possible)
  • Worker crashes mid-batch that fail to track which records were processed
  • External-system writes that fail asynchronously — the integration thought it succeeded but didn’t
Reconciliation is the answer to all of these. It’s a separate, slower-cadence process that audits actual state against expected state and catches the gaps. Combined with polling, it produces production-grade reliability that polling alone cannot. This page covers the reconciliation patterns: daily and weekly cadences, deletion detection, three-way state checks, and the per-customer orchestration that makes reconciliation work at scale. If you haven’t yet, skim the Polling Overview for the polling foundation that reconciliation complements.

What reconciliation does

A reconciliation pass:
1

Reads the actual current state from VOMO

Typically a full or windowed list of a resource.
2

Reads the expected state from your integration's external store

What you believe VOMO contains based on prior sync activity.
3

Computes the diff

Records in VOMO but not in your store (missed creates / updates), records in your store but not in VOMO (missed deletions), records with mismatched fields (drift).
4

Applies corrective actions

Re-process missed records, mark deletions in downstream systems, alert on unexplained drift.
5

Records the reconciliation result

For monitoring, audit trail, and capacity planning.
The pattern is the same regardless of resource (Users, Projects, Groups, etc.). The specifics differ — what fields to compare, what corrective actions are appropriate — but the structure stays consistent.

The minimum reconciliation: daily catch-up

The simplest useful reconciliation: every night, re-read yesterday’s changes and verify they were processed.
JavaScript
async function dailyReconcileUsers(customerId) {
  const yesterday = new Date();
  yesterday.setDate(yesterday.getDate() - 1);
  yesterday.setHours(0, 0, 0, 0);

  const today = new Date(yesterday);
  today.setDate(today.getDate() + 1);

  // 1. Re-query for yesterday's changes
  const params = new URLSearchParams({
    updated_after: yesterday.toISOString(),
    updated_before: today.toISOString(),
  });

  let url = `https://api.vomo.org/v1/users?${params}`;
  const expectedUsers = [];

  while (url) {
    const response = await fetch(url, {
      headers: { Authorization: `Bearer ${process.env.VOMO_API_TOKEN}` },
    });
    const page = await response.json();
    expectedUsers.push(...page.data);
    url = page.links.next;
  }

  // 2. Check which were actually processed
  const gaps = [];
  for (const user of expectedUsers) {
    const processed = await externalDb.userWasProcessedAt(
      customerId,
      user.id,
      new Date(user.updated_at)
    );
    if (!processed) {
      gaps.push(user);
    }
  }

  // 3. Re-process the gaps
  for (const user of gaps) {
    try {
      await processUserChange(customerId, user);
      await externalDb.recordUserProcessed(customerId, user.id, new Date());
    } catch (err) {
      await deadLetterQueue.publish({
        type: 'reconciliation_failure',
        customerId,
        userId: user.id,
        error: err.message,
      });
    }
  }

  // 4. Record result
  await externalDb.recordReconciliation(customerId, {
    type: 'user_daily',
    date: yesterday,
    expectedCount: expectedUsers.length,
    gapCount: gaps.length,
  });

  return { expectedCount: expectedUsers.length, gapCount: gaps.length };
}

// Schedule for 2am customer-local-time each day

What this catches

IssueHow reconciliation catches it
Polling worker crashed mid-batchRecords modified during the crash weren’t in any successful poll; reconciliation re-discovers them
Per-record processing failure went to DLQ but was forgottenReconciliation finds the un-processed records and tries again
Polling worker silently advanced checkpoint past recordsReconciliation re-reads the time range, doesn’t trust the checkpoint
Network error during a poll’s paginationRecords on later pages were missed; reconciliation re-fetches them

What this doesn’t catch

IssueWhy
DeletionsDeleted records aren’t in updated_after results — need a separate scan
Drift between polling and external stateNeed a three-way reconciliation — see below
Changes that don’t advance updated_atThese are invisible to time-based reconciliation; need full-state scan
For most production needs, daily catch-up reconciliation closes 90%+ of the gaps that polling misses. The remaining gaps require more comprehensive patterns.

Deletion detection

Deletions are the hardest thing to detect in a polling architecture. The pattern: periodically read the full current state, compare to what your external store thinks exists, and treat the difference as deletions.

The full-state scan

JavaScript
async function detectDeletedUsers(customerId) {
  // 1. Read all current Users from VOMO
  const currentUsers = await listAllUsers();
  const currentIds = new Set(currentUsers.map((u) => u.id));

  // 2. Read what your external store knows
  const knownIds = await externalDb.getAllKnownVomoUserIds(customerId);

  // 3. Find IDs known to external but not in VOMO
  const possiblyDeletedIds = [...knownIds].filter((id) => !currentIds.has(id));

  // 4. Confirm each as actually deleted (vs. inaccessible)
  const confirmedDeletions = [];
  for (const id of possiblyDeletedIds) {
    const detail = await getUserDetail(id);
    if (!detail) {
      // 404 — confirmed deleted
      confirmedDeletions.push(id);
    } else {
      // Still exists but didn't appear in list — log for investigation
      await alertOps({
        severity: 'low',
        type: 'user_missing_from_list',
        customerId,
        userId: id,
      });
    }
  }

  // 5. Propagate deletions
  for (const id of confirmedDeletions) {
    await externalSystem.markUserDeleted(id);
    await externalDb.removeUserKnown(customerId, id);
  }

  return { possiblyDeletedCount: possiblyDeletedIds.length, confirmedDeletions };
}

Run cadence

ResourceSuggested deletion-scan cadence
UsersWeekly (deletions are rare; freshness less critical than other changes)
ProjectsWeekly
GroupsDaily (Groups can be intentionally deleted as part of admin cleanup)
CampaignsWeekly
FormsMonthly
CertificatesMonthly
Deletion scans are full-dataset operations — expensive in API requests. Pace accordingly:
A scan for 10,000 users at 15-per-page = 667 requests
At 3 req/sec, that's ~3.7 minutes of solid polling
Across 10 customers, ~37 minutes/day on deletion scans alone
For larger customers, consider weekly cadences and off-peak scheduling.

Distinguishing deletion from inaccessibility

A user “missing” from the list query could mean:
CauseAction
User was deleted in VOMOPropagate deletion to external
User moved to a different organizationDon’t propagate — handle as scope change
Token’s permissions narrowedDon’t propagate — handle as scope change
User was banned/soft-deleted but record still existsDon’t propagate as full deletion
The detail fetch (GET /users/{id}) resolves the ambiguity:
  • 200 response → user exists but didn’t match list query (investigate filters)
  • 404 response → user is truly gone
Without the detail-fetch step, you’d propagate “deletions” for users who actually just changed scope — confusing the external system.

Three-way state reconciliation

The most rigorous pattern: compare three sources of truth. Each pair has a possible disagreement:
ComparisonDetects
VOMO ↔ Partner statePolling gaps (Partner missed a VOMO change)
Partner state ↔ ExternalPush failures (Partner thought it pushed but External didn’t receive)
VOMO ↔ External (transitive)End-to-end correctness — what the customer ultimately experiences

The end-to-end audit

JavaScript
async function endToEndAudit(customerId, sampleSize = 100) {
  // 1. Sample a few users from VOMO
  const allUsers = await listAllUsers();
  const sample = randomSample(allUsers, sampleSize);

  const issues = [];

  for (const vomoUser of sample) {
    // 2. Verify partner state
    const partnerKnown = await externalDb.getUserState(customerId, vomoUser.id);
    if (!partnerKnown) {
      issues.push({
        userId: vomoUser.id,
        issue: 'unknown_to_partner',
        vomoLastUpdated: vomoUser.updated_at,
      });
      continue;
    }

    // 3. Verify external state
    const externalKnown = await externalSystem.getUser(vomoUser.id);
    if (!externalKnown) {
      issues.push({
        userId: vomoUser.id,
        issue: 'missing_from_external',
        vomoLastUpdated: vomoUser.updated_at,
        partnerLastSynced: partnerKnown.lastSyncedAt,
      });
      continue;
    }

    // 4. Verify field-level consistency
    if (vomoUser.email !== externalKnown.email) {
      issues.push({
        userId: vomoUser.id,
        issue: 'email_mismatch',
        vomoValue: vomoUser.email,
        externalValue: externalKnown.email,
      });
    }

    // ... other field checks ...
  }

  await externalDb.recordAuditResult(customerId, {
    timestamp: new Date(),
    sampleSize,
    issueCount: issues.length,
    issues,
  });

  if (issues.length > sampleSize * 0.05) {
    // >5% issue rate — significant problem
    await alertOps({
      severity: 'high',
      message: `Integration audit found ${issues.length}/${sampleSize} issues`,
      customerId,
    });
  }

  return { sampleSize, issueCount: issues.length, issues };
}

When to run end-to-end audits

CadencePurpose
Weekly random sampleDetect drift early
After any significant code changeVerify the change doesn’t regress sync correctness
When a customer reports an issueConfirm whether issue is broader than the single reported case
Before quarterly reporting cutoffsConfirm data integrity for downstream reports
For partner integrations serving customers with strict data correctness needs (compliance reporting, audited financials), random sampling at 1% weekly catches most drift before it becomes a customer-facing issue.

Incremental vs. full reconciliation

Two distinct cadences for different purposes:

Incremental (daily) reconciliation

Re-reads a recent time window. Lower cost, catches recent gaps.
JavaScript
async function dailyIncrementalReconcile(customerId, resourceType) {
  const yesterday = startOfDayUTC(daysAgo(1));
  const today = startOfDayUTC(daysAgo(0));

  const params = new URLSearchParams({
    updated_after: yesterday.toISOString(),
    updated_before: today.toISOString(),
  });

  const recentRecords = await paginate(
    `https://api.vomo.org/v1/${resourceType}?${params}`
  );

  // Check each was processed
  const gaps = [];
  for (const record of recentRecords) {
    const wasProcessed = await externalDb.wasRecordProcessed(
      customerId,
      resourceType,
      record.id,
      new Date(record.updated_at)
    );
    if (!wasProcessed) gaps.push(record);
  }

  // Re-process gaps
  await reprocessGaps(customerId, resourceType, gaps);

  return { recentRecords: recentRecords.length, gaps: gaps.length };
}
Cost: Bounded by the daily change volume. Typical: a few hundred records per day per customer. Manageable.

Full (weekly) reconciliation

Re-reads everything. Higher cost, catches everything including deletions and drift.
JavaScript
async function weeklyFullReconcile(customerId, resourceType) {
  // 1. Read all current records
  const current = await paginate(`https://api.vomo.org/v1/${resourceType}`);
  const currentIds = new Set(current.map((r) => r.id));

  // 2. Read what external store knows
  const known = await externalDb.getAllKnownRecordIds(customerId, resourceType);

  // 3. Detect categories of issue
  const missingFromPartner = current.filter((r) => !known.has(r.id));
  const possiblyDeleted = [...known].filter((id) => !currentIds.has(id));

  // 4. For each missing-from-partner, process
  for (const record of missingFromPartner) {
    await processChange(customerId, resourceType, record);
  }

  // 5. For each possibly-deleted, confirm
  for (const id of possiblyDeleted) {
    const detail = await getRecordDetail(resourceType, id);
    if (!detail) {
      await externalSystem.markDeleted(resourceType, id);
      await externalDb.removeKnown(customerId, resourceType, id);
    }
  }

  return {
    currentCount: current.length,
    addedCount: missingFromPartner.length,
    deletedCount: possiblyDeleted.length,
  };
}
Cost: Full dataset read each run. Expensive — schedule during off-peak hours.

Combining the two

For most production integrations, both cadences run: Different cadences catch different issue classes; the layered approach catches more than any single pass.

Per-resource reconciliation patterns

Different resources have different reconciliation needs:

Users

ConcernPattern
Polling caught most changesDaily incremental — re-read yesterday
Detect deletionsWeekly full scan
Detect drift in email/name/phoneMonthly random-sample audit
Detect missed participationsDaily Project Date scan (see below)

Projects

ConcernPattern
Metadata change gapsDaily incremental
Schedule drift (Project Date count differs from snapshot)Weekly full scan with all_dates comparison
Project deletion (rare but happens)Weekly full scan
Project Date deletionsRe-fetched as part of schedule diff

Groups

ConcernPattern
Membership driftDaily — re-read members for active Groups
Group deletion (more common — admin cleanup)Daily full scan
Parent-child relationship changesDaily — re-check parent_id on changed Groups

Forms / Form Completions

ConcernPattern
Form structure changesWeekly
Missed Form CompletionsDaily incremental per active Form
Completion modificationsDetected by updated_after

Participation reconciliation (the hardest case)

Because participations aren’t directly polled, reconciliation is the only path. Run daily:
JavaScript
async function reconcileRecentParticipations(customerId) {
  // 1. Get Projects with active recent Dates
  const recent = await getProjectsWithRecentDates(customerId, 7); // last 7 days

  for (const project of recent) {
    for (const date of project.all_dates) {
      const isRecent =
        new Date(date.starts_at) > new Date(Date.now() - 14 * 24 * 60 * 60 * 1000);
      if (!isRecent) continue;

      const dateDetail = await getProjectDate(date.id);
      const currentParticipants = dateDetail.data.participants ?? [];
      const knownParticipantIds = new Set(
        await externalDb.getKnownParticipantIds(customerId, date.id)
      );

      // New participants
      for (const p of currentParticipants) {
        if (!knownParticipantIds.has(p.id)) {
          await processNewParticipation(customerId, project, date, p);
          await externalDb.recordParticipant(customerId, date.id, p.id);
        }
      }

      // Removed participants (cancellations)
      const currentIds = new Set(currentParticipants.map((p) => p.id));
      for (const knownId of knownParticipantIds) {
        if (!currentIds.has(knownId)) {
          await processRemovedParticipation(customerId, date.id, knownId);
          await externalDb.removeParticipant(customerId, date.id, knownId);
        }
      }
    }
  }
}
This runs on a tighter cadence than other reconciliation (every few hours for active customers) because participation freshness matters more for downstream workflows.

Per-customer orchestration

For partner integrations serving many customers, reconciliation across them needs orchestration:
JavaScript
class ReconciliationOrchestrator {
  constructor({ customers, throttle }) {
    this.customers = customers;
    this.throttle = throttle;
  }

  async runDaily() {
    for (const customerId of this.customers) {
      try {
        await this._reconcileCustomer(customerId);
      } catch (err) {
        await alertOps({
          severity: 'medium',
          message: `Reconciliation failed for ${customerId}`,
          error: err.message,
        });
        // Continue to next customer
      }
    }
  }

  async _reconcileCustomer(customerId) {
    const tasks = [
      { name: 'users_incremental', fn: () => dailyReconcileUsers(customerId) },
      { name: 'projects_incremental', fn: () => dailyReconcileProjects(customerId) },
      { name: 'participations', fn: () => reconcileRecentParticipations(customerId) },
    ];

    for (const task of tasks) {
      const startTime = Date.now();
      const result = await task.fn();
      const durationMs = Date.now() - startTime;

      await externalDb.recordReconciliationRun({
        customerId,
        task: task.name,
        startedAt: new Date(startTime),
        durationMs,
        result,
      });
    }
  }
}

Staggering

Avoid running reconciliation for all customers at the same minute — spike load and rate-limit hits will follow. Stagger:
JavaScript
async function stagggeredDailyReconciliation(customers) {
  for (let i = 0; i < customers.length; i++) {
    const delay = i * 60 * 1000; // 1 minute between customer starts
    setTimeout(() => reconcileCustomer(customers[i]), delay);
  }
}
For 60 customers, this spreads reconciliation across an hour. The total elapsed time is the same, but no minute has all 60 customers’ reconciliation running simultaneously.

Per-customer cadence

Not every customer needs the same cadence. Small customers (a few hundred users) might be fine with weekly full reconciliation; large customers (50,000+ users) need daily.
JavaScript
async function chooseReconciliationCadence(customerId) {
  const stats = await getCustomerStats(customerId);

  if (stats.userCount > 10000 || stats.changesPerDay > 500) {
    return 'daily_full';
  }
  if (stats.userCount > 1000) {
    return 'daily_incremental_weekly_full';
  }
  return 'weekly_full';
}
The cadence becomes per-customer configuration, tuned to actual activity.

Reconciliation as documentation

A useful side-effect of reconciliation: it produces an audit trail. Each reconciliation run records:
  • When it ran
  • What it checked
  • What gaps it found
  • What corrective actions it took
  • Whether the integration is operating correctly
For customer-facing transparency, this audit trail is valuable:
JavaScript
async function getCustomerSyncHealth(customerId) {
  const recent = await externalDb.getRecentReconciliations(customerId, 30);

  const dailyResults = recent.filter((r) => r.task === 'users_incremental');
  const gapsPerDay = dailyResults.map((r) => ({
    date: r.startedAt,
    expectedCount: r.result.expectedCount,
    gapCount: r.result.gapCount,
    rate: r.result.gapCount / r.result.expectedCount,
  }));

  const avgGapRate = average(gapsPerDay.map((d) => d.rate));

  return {
    averageGapRate: avgGapRate,
    healthy: avgGapRate < 0.01, // <1% gaps is healthy
    days: gapsPerDay,
  };
}
Customers asking “is our sync working?” get a definitive answer backed by audit data.

When reconciliation finds too much

A successful reconciliation finds zero gaps. A few gaps per day is normal. A sudden spike in gaps means something is wrong with polling:
PatternLikely cause
Daily incremental finds 50%+ of records as gapsPolling is broken — checkpoint not advancing, workers stalled, or polling cadence is too long
Weekly full finds 100s of “deleted” recordsEither a real mass-deletion event, or polling missed creates (records exist in VOMO and externally but not in your partner state)
Sudden field-level drift on a specific fieldMapping logic is broken — recent code change may have regressed
One customer has high gap rate; others normalCustomer-specific issue — token, rate-limiting, or org-specific data problem
Treat reconciliation results as a signal for investigation, not just automatic correction. If gaps grow, pause auto-correction until the root cause is understood.

Production checklist

For reconciliation:
  • Daily incremental reconciliation per resource per customer
  • Weekly full reconciliation including deletion detection
  • Three-way audits (sample-based) at least weekly
  • Reconciliation is throttled — doesn’t compete with regular polling
  • Per-customer cadence is configurable, not hardcoded
  • Staggered scheduling avoids same-minute spikes
  • Audit trail of every reconciliation run is preserved
  • Per-customer “sync health” view exposes the data
  • Alerts fire when gap rate exceeds threshold (e.g., >1% daily)
  • When reconciliation finds too much, pause auto-correction and surface for review
These practices catch the residual failures that polling misses — turning a “mostly works” integration into one that’s auditable, debuggable, and trustworthy.

Where to go next

Change Detection Best Practices

The cross-cutting reliability patterns: checkpointing, idempotency, drift detection.

Sync Architecture Patterns

The broader architectural picture for sync designs.

Detecting User Changes

The polling pattern that reconciliation complements.

Detecting Project Changes

Same for Projects, including the schedule-diff pattern.
Last modified on May 22, 2026