The three-endpoint pattern for reading Project scheduling data — embedded all_dates on the Project detail, the daily /projects/today feed, and the per-Date /projects/date/ detail. How to combine them efficiently.
The Volunteer API exposes Project scheduling data through three different endpoints, each suited to a different purpose. Choosing the right one for a workflow can be the difference between an integration that scales gracefully and one that drowns in unnecessary requests.This workflow page walks through the three endpoints, when to use each, how to combine them, and the practical patterns for common scheduling integrations — calendar views, daily dashboards, participant rosters, and external-calendar sync.If you haven’t yet, skim the Projects and Project Dates concept page for the Project vs. Project Date distinction and the field shapes.
For a known Project, the most efficient way to get its full schedule is GET /projects/{id} — the response includes all_dates and next_date embedded, so you don’t need separate Project Date fetches.
This is enough for calendar views, scheduling displays, and most “show the schedule” workflows. You don’t need to call GET /projects/date/{id} unless you also need the per-Date participants.
The response is Project Dates (Happenings), not Projects. A single Project running a morning and afternoon shift today appears as two entries. To roll up to Projects:
JavaScript
async function getTodayGroupedByProject() { const response = await fetch( 'https://api.vomo.org/v1/projects/today', { headers: { Authorization: `Bearer ${token}` } } ); const today = await response.json(); // Each happening doesn't include the Project name directly — to display nicely, // call GET /projects/date/{id} per Happening to get the project_name const enriched = await Promise.all( today.data.map(async (happening) => { const date = await getProjectDate(happening.id); return { ...happening, projectName: date?.data?.project_name, projectId: date?.data?.project_id, }; }) ); // Group by Project const byProject = new Map(); for (const item of enriched) { if (!byProject.has(item.projectId)) { byProject.set(item.projectId, { projectName: item.projectName, projectId: item.projectId, shifts: [], }); } byProject.get(item.projectId).shifts.push({ id: item.id, starts_at: item.starts_at, ends_at: item.ends_at, participant_count: item.participant_count, }); } return Array.from(byProject.values());}
This produces a Project-keyed view: “Saturday Food Bank Shift has two shifts today (9-12 and 2-6).”
The pattern above is N+1 — one /projects/today call plus one /projects/date/{id} per Happening. For organizations with many simultaneous shifts (10-20+ on a given day), this can be expensive. Caching the per-Date detail helps:
For partner-built signup interfaces, find Projects with upcoming Dates that have capacity:
JavaScript
async function findProjectsWithCapacity(maxParticipants = 50) { // 1. Get upcoming, active Projects const now = new Date().toISOString(); const futureCutoff = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(); const params = new URLSearchParams({ active: 'true', published: 'true', dates_after: now, dates_before: futureCutoff, }); const upcomingProjects = await paginate( `https://api.vomo.org/v1/projects?${params}` ); // 2. For each Project, check the next Date's capacity const available = []; for (const projectSummary of upcomingProjects) { const project = await getProjectDetail(projectSummary.id); const next = project?.next_date; if (next && next.participant_count < maxParticipants) { available.push({ project, nextShift: next, availableSpots: maxParticipants - next.participant_count, }); } } return available;}
The pattern uses next_date to avoid fetching all dates for each Project — efficient for the common “find a shift to volunteer for” use case.For more sophisticated filtering (e.g., “find shifts with at least 5 open spots”), iterate all_dates:
JavaScript
async function findShiftsWithCapacity(projectId, openSlotsNeeded) { const project = await getProjectDetail(projectId); const maxCapacity = 50; // From elsewhere — maybe configured per Project return (project?.all_dates ?? []) .filter((d) => maxCapacity - d.participant_count >= openSlotsNeeded) .filter((d) => new Date(d.starts_at) > new Date()); // Future only}
⚠️ Spec gap (audit #4): This endpoint uses empty schema: {} in the spec — the response shape is documented only through the inline example. Build parsers from observed live responses.
⚠️ Spec gap (audit #34): The endpoint’s path /projects/date/{id} is unconventional (verb-like singular noun). A future revision may rename it to /projects/{projectId}/dates/{dateId}. Until then, the current path is what works.
Total hours volunteered (fractional, returned as string per audit #40)
verified
Whether the organizer confirmed attendance (0 or 1)
role
Their role (e.g., “Volunteer”, “Team Lead”)
project_id
Back-reference to the Project
project_date_id
Back-reference to this Project Date
This shape includes the Participation data inline (rather than requiring a separate fetch through the User detail). It’s the cleanest path to “who’s signed up for this shift?”
A common partner workflow: generate an iCalendar (.ics) feed of a Project’s schedule for the customer’s external calendar systems:
JavaScript
async function generateProjectIcal(projectId) { const project = await getProjectDetail(projectId); if (!project) throw new Error(`Project ${projectId} not found`); const events = (project.all_dates ?? []).map((date) => ({ uid: `vomo-project-date-${date.id}@vomo.org`, summary: project.name, description: project.description, location: project.address?.formatted_address ?? '', start: new Date(date.starts_at), end: new Date(date.ends_at), url: project.url, })); return buildIcalString(events);}function buildIcalString(events) { const lines = [ 'BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//Volunteer Integration//EN', ]; for (const event of events) { lines.push('BEGIN:VEVENT'); lines.push(`UID:${event.uid}`); lines.push(`SUMMARY:${escapeIcal(event.summary)}`); lines.push(`DESCRIPTION:${escapeIcal(event.description)}`); lines.push(`LOCATION:${escapeIcal(event.location)}`); lines.push(`DTSTART:${formatIcalDate(event.start)}`); lines.push(`DTEND:${formatIcalDate(event.end)}`); if (event.url) lines.push(`URL:${event.url}`); lines.push('END:VEVENT'); } lines.push('END:VCALENDAR'); return lines.join('\r\n');}function formatIcalDate(date) { return date.toISOString().replace(/[-:.]/g, '').replace('000Z', 'Z');}function escapeIcal(s) { return (s ?? '').replace(/[\\,;]/g, '\\$&').replace(/\n/g, '\\n');}
The one GET /projects/{id} call gives you the full schedule needed for the feed — no per-Date fetches required.For workflows generating iCal feeds for many Projects simultaneously, cache aggressively — Project schedules change infrequently.
GET /projects/today then GET /projects/date/{id} per Happening
Get project_name from the Date detail
Participants signed up for one shift
GET /projects/date/{id}
Participants embedded
Find Projects with available capacity
GET /projects (filtered) + next_date
Use the embedded next_date.participant_count
All upcoming shifts across all Projects
GET /projects (filtered) + GET /projects/{id} per
N+1 — cache aggressively
Volunteer hours summary for a User
GET /users/{id} — participations is embedded
No Project Date fetches needed
The pattern is roughly: use the most-embedded endpoint that has what you need, then drill down only when necessary. The Project detail endpoint is the workhorse for most workflows.