Skip to main content
A reporting integration’s central question: how many hours did our volunteers serve, and where? The data exists in VOMO — every Participation captures hours, signed_up_at, checked_in_at, checked_out_at, and the linkage to a Project Date. But there’s no GET /participations endpoint to pull it directly. Hours reporting requires walking either users (via /users/{id} → embedded participations) or Project Dates (via /projects/date/{id} → embedded participants). This recipe walks through both approaches: when each is right, how to aggregate efficiently, how to build incremental reporting that doesn’t re-scan everything every time, and the performance patterns for large datasets.

What you’ll build

A reporting pipeline that:
  • Collects all volunteer participation data despite no direct participations endpoint
  • Aggregates hours by user, project, organization, time period, and other dimensions
  • Builds incremental reports that update rather than fully recompute
  • Stores aggregated results for fast querying by downstream BI tools or dashboards
  • Surfaces hours-related insights (trends, top volunteers, project performance)

When this recipe fits


The data flow

Five components: The raw store is the integration’s canonical record of participations — once collected, aggregation is cheap and re-aggregation for different reports is straightforward.

Two collection strategies

The fundamental question: how do you collect participation records when there’s no direct participations endpoint?

Strategy A: walk Project Dates

For each upcoming or recent Project Date, fetch its participants:
JavaScript
Cost: One Projects-list query + one Project-detail-fetch per Project + one Project-Date-detail-fetch per Date. For an account with 50 active Projects each with 4 weekly Dates over a month, that’s roughly 1 + 50 + 200 = 251 requests per scan.

Strategy B: walk Users

For each User, fetch their detail (which embeds participations):
JavaScript
Cost: One Users-list query + one User-detail-fetch per User. For an account with 5,000 users, that’s 5,001 requests per scan — significantly more than Strategy A.

Which to use

For most production reporting, Strategy A is the default. It’s cheaper, the data captured is richer (includes Project name), and Project-Date-based collection aligns naturally with reporting dimensions.

Step 1: incremental collection

Re-scanning the entire dataset on every refresh is wasteful. Build incremental collection:
JavaScript
Two key patterns:

Raw store schema

The composite primary key (project_date_id, user_id) is the natural unique identifier for a participation. Indexes by user, by project, and by time support the common aggregation queries.

Step 2: aggregate hours

Once raw participations are in the local store, aggregation is fast:

Hours per user

Hours per project

Hours per time period

Each query maps to a specific reporting question. With proper indexes, these queries respond in milliseconds even for large datasets.

Step 3: pre-computed aggregations

For high-traffic dashboards, even fast SQL queries can be slow at scale. Materialize aggregations:
Refresh on a schedule that matches reporting needs (hourly for dashboards, daily for reports):
JavaScript
The materialized views become the fast path for dashboards. The raw participation table is still queryable for ad-hoc reports.

Step 4: report-specific patterns

Different reports need different shapes. A few common ones:

Top volunteers ranking

JavaScript

Volunteer engagement trend

JavaScript

Retention cohort analysis

JavaScript
Retention cohorts answer “of users who first volunteered in May, how many came back in June, July, etc.” — useful for understanding volunteer engagement quality.

Hours-by-organization (within the org family)

JavaScript
Useful for multi-org customers — “the Gotham Outreach branch contributed X hours; the Wayne Manor branch contributed Y.”

Step 5: handle unverified vs. verified hours

Participations have a verified flag — true when the organizer confirmed attendance, false for self-reported or pending. Reports typically distinguish: For most reports, verified-only is the canonical metric. Make the distinction explicit in dashboard labels: “Total verified hours: 2,450” rather than just “Total hours.”

Step 6: reconciliation and accuracy

Reports about hours need to be accurate — undercounting embarrasses the customer; overcounting embarrasses the integration.

Daily verification scan

JavaScript
Daily reconciliation against yesterday’s data catches most gaps within a 24-hour window.

Verified vs. final-version drift

Sometimes a participation’s hours field changes after initial capture — organizers may adjust hours post-event. The incremental upsert handles this automatically (new value overwrites old), but you may want to track when changes happen:
JavaScript
For audited reports, the change log is essential — it provides explainability when totals differ between report runs.

Performance at scale

A few patterns that help reports scale:

Partition the raw store

For customers with millions of participations, partition by time:
Queries with time filters scan only relevant partitions.

Tiered aggregation

For dashboards needing fast responses: The dashboard hits the materialized view at the appropriate granularity; the raw data remains for ad-hoc queries.

Pre-computed user lookups

For per-user dashboards (e.g., “show me my volunteer history”), pre-compute per-user summaries:
A “show me my history” query becomes a single-row lookup instead of an aggregation.

Things to watch for

Hours field is a string

The hours field is returned as a string ("4.00") per audit #40. Always parse:
JavaScript
The || 0 defends against null or invalid values.

Participation modifications happen post-event

Organizers commonly adjust hours, verify participations, and add notes after a shift ends. The incremental collector picks these up because Project Dates’ participants can change. But don’t assume yesterday’s data is final — the collector should keep re-scanning recent Project Dates (e.g., last 14 days) for changes.

Unverified participations are noisy

verified: false participations are often self-reported or pending — including them in reports can produce inflated numbers. Default to verified: true only for customer-facing reports.

Project deletion can orphan participations

If a Project is deleted in VOMO (admin action), its Project Dates and embedded participations disappear. Your local store still has them — they’ll appear in historical reports but not in current VOMO state. Decide whether to keep them (historical accuracy) or remove them (current accuracy). Typically: keep them with a vomo_deleted_at timestamp, so historical reports remain accurate but current state shows “this Project no longer exists in VOMO.”

Multiple participations per user per Date are unusual but possible

The primary key (project_date_id, user_id) enforces one row per user per Date. If VOMO ever returns multiple participation entries for the same (user, date) tuple, the upsert will collapse them into one. This is almost certainly the right behavior, but be aware of it for reports that involve participation counts.

What you’ve built

After this recipe:
  • ✅ A collector that pulls participation records from VOMO via Project Dates
  • ✅ A local raw store keyed by (project_date_id, user_id)
  • ✅ Incremental collection that doesn’t re-scan everything every run
  • ✅ Aggregated views for the common reporting questions
  • ✅ Pre-computed materialized views for fast dashboards
  • ✅ Daily reconciliation catching gaps
  • ✅ Change tracking for hours modifications
This is a reporting integration’s foundation. Specific dashboards and reports build on top of these materialized aggregates.

Where to go next

Combine Volunteer Data with CRM+ Data

The cross-API recipe joining Volunteer hours with CRM+ donor data.

Build a Volunteer Self-Service Portal

The end-user-facing recipe using participation data.

The Volunteer Data Model

The data model context for participations.

Reconciliation Patterns

The reconciliation patterns this recipe uses.
Last modified on May 22, 2026