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
| Scenario | This recipe fits |
|---|---|
| Annual volunteer hours summary report | ✓ |
| BI dashboard showing hours trends | ✓ |
| Top-volunteer recognition rankings | ✓ |
| Per-project performance metrics | ✓ |
| Compliance reporting (volunteer-hour grants, audited financials) | ✓ With reconciliation rigor |
| Real-time “hours so far today” display | ✗ Real-time is hard without webhooks; consider hourly minimum |
The data flow
Five components:| Component | Purpose |
|---|---|
| Participation collector | Pulls participation records from VOMO via the available endpoints |
| Raw participation store | Local database of every participation record, keyed by (project_date_id, user_id) |
| Hours aggregator | Computes summary statistics from the raw records |
| Aggregated reports | Pre-computed tables for fast querying (by user, project, period, etc.) |
| BI / dashboards | Customer-facing or partner-facing displays |
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
Strategy B: walk Users
For each User, fetch their detail (which embedsparticipations):
JavaScript
Which to use
| Strategy | Best for |
|---|---|
| A: Walk Project Dates | Reporting focused on specific Projects or time windows; most common case |
| B: Walk Users | Reporting focused on per-user hours summaries; when you need full user context (email, profile fields) |
| A then B | Reconciliation — use A primarily, B periodically to catch participations missed by Project filter |
Step 1: incremental collection
Re-scanning the entire dataset on every refresh is wasteful. Build incremental collection:JavaScript
| Pattern | Why |
|---|---|
Upsert by (project_date_id, user_id) | Participation records can be modified after creation (check-ins, hour adjustments). Upsert handles both new records and modifications. |
| Cache Project details within a single scan | Many Project Dates belong to the same Project. Caching prevents N redundant Project-detail fetches. |
Raw store schema
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
Step 3: pre-computed aggregations
For high-traffic dashboards, even fast SQL queries can be slow at scale. Materialize aggregations:JavaScript
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
Hours-by-organization (within the org family)
JavaScript
Step 5: handle unverified vs. verified hours
Participations have averified flag — true when the organizer confirmed attendance, false for self-reported or pending. Reports typically distinguish:
| Audience | What to count |
|---|---|
| Internal operations dashboards | All participations (verified + unverified) — gives complete picture |
| Customer-facing reports | Verified only — these are the “confirmed” hours |
| Compliance / grant reporting | Verified only, with explicit “as of X date” stamp |
| Trend analysis | Both, separately tracked |
Step 6: reconciliation and accuracy
Reports about hours need to be accurate — undercounting embarrasses the customer; overcounting embarrasses the integration.Daily verification scan
JavaScript
Verified vs. final-version drift
Sometimes a participation’shours 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
Performance at scale
A few patterns that help reports scale:Partition the raw store
For customers with millions of participations, partition by time:Tiered aggregation
For dashboards needing fast responses:| Tier | Storage | Refresh cadence |
|---|---|---|
| Raw participations | Local DB | Continuous (via collector) |
| Daily aggregates | Materialized view | Hourly |
| Weekly aggregates | Materialized view | Daily |
| Monthly summaries | Materialized view | Daily |
| Annual reports | Materialized view | Weekly |
Pre-computed user lookups
For per-user dashboards (e.g., “show me my volunteer history”), pre-compute per-user summaries:Things to watch for
Hours field is a string
Thehours field is returned as a string ("4.00") per audit #40. Always parse:
JavaScript
|| 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 avomo_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
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.