Skip to main content
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.

When to use this workflow

ScenarioThis workflow fits
Build a calendar view of a Project’s schedule✓ Use GET /projects/{id} for all_dates
Show “what’s happening today” across the org✓ Use GET /projects/today
Show participants for a specific shift✓ Use GET /projects/date/{id}
Generate an iCal feed of a Project’s schedule✓ Same as calendar view
Daily dashboard for check-in staff/projects/today then drill in per-Date
Find Projects with available slotsUse GET /projects with date filters; per-slot capacity may need detail fetches

The three endpoints

EndpointWhat it returnsUse case
GET /projects/{id}A single Project with all_dates[] and next_date embeddedFull schedule for one Project
GET /projects/todayAll Project Dates happening today (across all Projects)Daily operational dashboards
GET /projects/date/{id}A single Project Date with its participantsDetail view of one specific shift
The right choice depends on what you have and what you want. The decision tree:

Scenario 1: Get a Project’s full schedule

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.
cURL
curl https://api.vomo.org/v1/projects/789 \
  -H "Authorization: Bearer $VOMO_API_TOKEN"
A successful response (relevant fields):
{
  "data": {
    "id": 789,
    "name": "Saturday Food Bank Shift",
    "all_dates": [
      {
        "id": 1011,
        "starts_at": "2025-04-19T14:00:00Z",
        "ends_at": "2025-04-19T18:00:00Z",
        "participant_count": 15
      },
      {
        "id": 1012,
        "starts_at": "2025-04-26T14:00:00Z",
        "ends_at": "2025-04-26T18:00:00Z",
        "participant_count": 12
      }
    ],
    "next_date": {
      "id": 1011,
      "starts_at": "2025-04-19T14:00:00Z",
      "ends_at": "2025-04-19T18:00:00Z",
      "participant_count": 15
    }
  }
}

What you get without extra calls

all_dates[] includes for each Project Date:
FieldDescription
idThe Project Date’s stable ID
starts_atISO 8601 start time
ends_atISO 8601 end time
participant_countThe number of confirmed signups
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.

When you need more than all_dates provides

all_dates doesn’t include:
  • Per-Date participants (which volunteers signed up for which shift) — requires GET /projects/date/{id}
  • Per-Date custom data (if the customer added Project-Date-specific fields)
For workflows that need participants per Date, see Scenario 4: Get participants for a specific shift.

next_date for “next upcoming shift” displays

The next_date field is convenient for UIs showing “your next opportunity” — it’s the chronologically-nearest future Project Date, pre-computed:
JavaScript
async function getNextShiftForProject(projectId) {
  const project = await getProjectDetail(projectId);
  return project?.next_date ?? null;
}

// Usage
const next = await getNextShiftForProject(789);
if (next) {
  console.log(`Next shift: ${next.starts_at} (${next.participant_count} volunteers signed up)`);
}
For a “what’s coming up?” homepage card, this is the single right field.

Scenario 2: Daily operational dashboard

For check-in staff or operations dashboards showing “what’s happening today”:
cURL
curl https://api.vomo.org/v1/projects/today \
  -H "Authorization: Bearer $VOMO_API_TOKEN"
Returns a list of HappeningResource objects — all Project Dates scheduled for today, across all of the organization’s Projects:
{
  "data": [
    {
      "type": "happening",
      "id": 1011,
      "starts_at": "2025-04-19T14:00:00Z",
      "ends_at": "2025-04-19T18:00:00Z",
      "participant_count": 15
    },
    {
      "type": "happening",
      "id": 1015,
      "starts_at": "2025-04-19T09:00:00Z",
      "ends_at": "2025-04-19T12:00:00Z",
      "participant_count": 8
    }
  ]
}

What this is good for

WorkflowHow /projects/today fits
Daily summary email to admin teamPull at start of day; format the list
Check-in dashboardReal-time view of the day’s shifts
Operations status displayShow signup totals across all active shifts
Quick “find my shift” UIVolunteer selects from today’s options

Note: today’s Project Dates, not Projects

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 cost of “today” + drill-down

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:
JavaScript
class TodayCache {
  constructor({ ttlMs = 5 * 60 * 1000 }) {
    this.ttlMs = ttlMs;
    this.dateDetailCache = new Map(); // dateId → { detail, cachedAt }
  }

  async getDateDetail(dateId) {
    const cached = this.dateDetailCache.get(dateId);
    if (cached && Date.now() - cached.cachedAt < this.ttlMs) {
      return cached.detail;
    }

    const detail = await getProjectDate(dateId);
    this.dateDetailCache.set(dateId, { detail, cachedAt: Date.now() });
    return detail;
  }
}
5-minute cache TTL is reasonable for “today” workflows — participants change but not so fast that 5-minute lag matters for most dashboard use cases.

Scenario 3: Find Projects with available slots

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
}

Scenario 4: Get participants for a specific shift

When you need to know who’s signed up for a specific Project Date:
cURL
curl https://api.vomo.org/v1/projects/date/1011 \
  -H "Authorization: Bearer $VOMO_API_TOKEN"
Returns the Project Date with its participants embedded:
{
  "data": {
    "type": "project_date",
    "id": "5678",
    "project_name": "Saturday Food Bank Shift",
    "starts_at": "2025-04-19T14:00:00Z",
    "ends_at": "2025-04-19T18:00:00Z",
    "participant_count": 15,
    "participants": [
      {
        "type": "user",
        "id": 123456,
        "email": "bruce@wayne.example",
        "guests": 0,
        "checked_in_at": null,
        "checked_out_at": null,
        "signed_up_at": "2025-04-01T10:30:00Z",
        "hours": "0.00",
        "verified": 0,
        "role": "Volunteer",
        "project_id": 789,
        "project_date_id": 1011
      }
    ]
  }
}
⚠️ 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.

The participants embedded here

Each participant entry has:
FieldDescription
idThe participating User’s ID
emailThe User’s email
guestsNumber of guests they brought
checked_in_atWhen they arrived (null if not checked in)
checked_out_atWhen they left (null if not checked out)
signed_up_atWhen they registered
hoursTotal hours volunteered (fractional, returned as string per audit #40)
verifiedWhether the organizer confirmed attendance (0 or 1)
roleTheir role (e.g., “Volunteer”, “Team Lead”)
project_idBack-reference to the Project
project_date_idBack-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?”

Parsing the participants defensively

JavaScript
function parseProjectDateParticipants(raw) {
  return (raw.participants ?? []).map((p) => ({
    userId: p.id,
    email: p.email,
    guests: p.guests ?? 0,
    checkedInAt: p.checked_in_at ? new Date(p.checked_in_at) : null,
    checkedOutAt: p.checked_out_at ? new Date(p.checked_out_at) : null,
    signedUpAt: p.signed_up_at ? new Date(p.signed_up_at) : null,
    // hours: spec says integer but live returns string fractional value
    hours: typeof p.hours === 'string' ? parseFloat(p.hours) : (p.hours ?? 0),
    verified: p.verified === 1 || p.verified === true,
    role: p.role ?? 'Volunteer',
    projectId: p.project_id,
    projectDateId: p.project_date_id,
  }));
}
The defensive parsing handles:
  • hours returning as a string ("4.00") instead of integer per audit #40
  • verified returning as 0 or 1 integer (treat as boolean)
  • Missing optional fields

Scenario 5: Build an iCal feed for a Project

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.

Scenario 6: Multi-Project schedule view

For “all upcoming shifts” displays spanning multiple Projects:
JavaScript
async function getAllUpcomingShifts(maxDaysOut = 14) {
  const now = new Date();
  const cutoff = new Date(now.getTime() + maxDaysOut * 24 * 60 * 60 * 1000);

  // 1. Get Projects with dates in the window
  const params = new URLSearchParams({
    active: 'true',
    published: 'true',
    dates_after: now.toISOString(),
    dates_before: cutoff.toISOString(),
  });

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

  // 2. For each Project, fetch detail to get all_dates (parallelizable)
  const allShifts = [];
  for (const projectSummary of projects) {
    const project = await getProjectDetail(projectSummary.id);
    const futureShifts = (project?.all_dates ?? []).filter((d) => {
      const dStart = new Date(d.starts_at);
      return dStart >= now && dStart <= cutoff;
    });

    for (const shift of futureShifts) {
      allShifts.push({
        projectId: project.id,
        projectName: project.name,
        shiftId: shift.id,
        startsAt: new Date(shift.starts_at),
        endsAt: new Date(shift.ends_at),
        participantCount: shift.participant_count,
      });
    }
  }

  // 3. Sort chronologically
  return allShifts.sort((a, b) => a.startsAt - b.startsAt);
}
This is N+1 (one Project list + one detail per Project). For accounts with many Projects, throttle and cache:
JavaScript
async function getAllUpcomingShiftsCached(customerId, maxDaysOut = 14) {
  const cacheKey = `upcoming_shifts_${customerId}_${maxDaysOut}`;
  const cached = await cache.get(cacheKey);
  if (cached && Date.now() - cached.fetchedAt < 5 * 60 * 1000) {
    return cached.shifts;
  }

  const shifts = await getAllUpcomingShifts(maxDaysOut);
  await cache.set(cacheKey, { shifts, fetchedAt: Date.now() });
  return shifts;
}
A 5-minute cache balances freshness with API cost — schedules don’t change so quickly that 5-minute lag matters for most “what’s coming up?” displays.

Choosing the right endpoint

A summary table for quick decisions:
You want…Use…Why
A Project’s full scheduleGET /projects/{id}all_dates is embedded — no extra calls needed
The next upcoming shift for a ProjectGET /projects/{id} then next_dateSame
Today’s active shifts across the orgGET /projects/todaySingle call, ready-to-display
Today’s shifts grouped by ProjectGET /projects/today then GET /projects/date/{id} per HappeningGet project_name from the Date detail
Participants signed up for one shiftGET /projects/date/{id}Participants embedded
Find Projects with available capacityGET /projects (filtered) + next_dateUse the embedded next_date.participant_count
All upcoming shifts across all ProjectsGET /projects (filtered) + GET /projects/{id} perN+1 — cache aggressively
Volunteer hours summary for a UserGET /users/{id}participations is embeddedNo 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.

What can’t be done via the API

CapabilityStatus
Create a Project Date independently of its ProjectNot exposed — schedule is set via the Project’s dates array on POST/PUT
Modify a Project Date’s time without updating the ProjectNot exposed — use full PUT on /projects/{id}
Cancel an individual Project DateNot exposed — must remove from the Project’s dates array
Sign someone up for a Project DateNot exposed — admin UI only
Mark a participant as checked inNot exposed — admin UI only
Update participant hours after the factNot exposed
List all Project Dates without going through a ProjectNot exposed — no top-level /project-dates endpoint
See Understand Write Limitations.

Where to go next

Create or Update a Project

The companion write workflow — modifying the schedule is done by updating the Project.

Inspect Form Completions

The next supplementary workflow.

Projects and Project Dates

The reference page for the field shapes used here.

API Performance Tips

The caching patterns that make multi-Project schedule reads scale.
Last modified on May 22, 2026