Skip to main content
POST /projects and PUT /projects/{id} are the two write endpoints for Projects in the Volunteer API. Unlike Users (which have a single upsert endpoint), Projects have explicit create and update endpoints — but both share the same required field set, and updates are full-record replacement rather than partial PATCH. This workflow page covers the practical mechanics: the required field set (including the often-overlooked dates requirement), the GET-then-PUT pattern for safe partial updates, syncing Projects from external systems, and the audit-flagged quirks partner integrations should know about. If you haven’t yet, skim the Projects and Project Dates concept page for the field reference and the Project vs. Project Date distinction.

When to use this workflow

ScenarioThis workflow fits
Mirror Projects from an external system into VOMO✓ Upsert each external Project
Customer wants partner-controlled Project creation (e.g., from a job-scheduling system)
Update Project metadata (description, age limit, etc.)✓ Use the GET-then-PUT pattern
Create a Project with its full scheduledates is required at create time
Adjust a Project’s schedule (add/remove Project Dates)✓ But see the schedule warning below
Delete a Project✗ Not exposed — admin UI only
Modify a single Project Date’s time without touching the Project✗ Not exposed
Important constraint: Both POST /projects and PUT /projects/{id} require a dates array as part of the body. The schedule isn’t a separate concern from the Project — submitting a Project without dates fails validation. See The dates requirement below.

The two endpoints

EndpointMethodWhat it does
/projectsPOSTCreate a new Project (with schedule)
/projects/{id}PUTReplace an existing Project (with full data)
Unlike Users, there’s no auto-upsert behavior — you must know whether you’re creating or updating and call the right endpoint. For partner integrations syncing Projects from external systems, the pattern is typically:
  1. Look up whether the external Project has a known VOMO Project ID (from a mapping table)
  2. If yes → PUT /projects/{vomoId}
  3. If no → POST /projects and record the resulting VOMO ID
See Scenario 1: Sync Projects from an external system.

The request body

POST /projects and PUT /projects/{id} share the same body shape. Required fields:
FieldTypeNotes
namestring3-255 characters
descriptionstring3-2000 characters
allow_guestsbooleanWhether volunteers can bring guests
age_limitinteger0-99 (minimum volunteer age)
volunteer_questionstringUp to 500 characters; the optional signup question
datesarrayAt least one Project Date is required
Optional fields:
FieldTypeNotes
detailsstring3-2000 characters (operational notes — what to wear, what to bring)
participant_approval_requiredbooleanOrganizer must approve signups
background_check_requiredbooleanBackground check required to volunteer
show_volunteer_counterbooleanDisplay signup count publicly
volunteer_counter_thresholdintegerMinimum count before showing the counter
draftbooleanWhether the Project is unpublished
privacystring"PUBLIC" or "PRIVATE"
addressobjectProject location
point_personobjectDay-of contact
⚠️ Spec gap (audit #33): Some field descriptions in the spec for PUT /projects/{id} contain placeholder text ("Update Project" rather than meaningful descriptions). The field shapes themselves match POST /projects, but the descriptive content is incomplete. The field set on this page comes from the POST endpoint (where descriptions are correct) and is the working contract for both.
⚠️ Spec gap (audit #43): address is typed as array in ProjectResource (the read shape), though logically and likely in practice it’s an object. For write bodies, send it as an object matching the read shape you observe from the live API.

The dates requirement

The most distinctive part of the Project body: dates is a required field. You can’t create a Project without at least one scheduled Project Date. This shapes the integration flow significantly:
ImplicationWhat it means
Projects and schedules are created togetherYou can’t “create the Project now, schedule it later” via API
The dates array contains Project Date objectsEach with starts_at, ends_at, and other per-occurrence data
Updates submit the full dates arrayAdding a new Project Date means submitting the existing dates plus the new one
Removing a Project Date means omitting it from the PUT bodyA PUT with fewer dates removes the missing ones
The exact field shape inside the dates array isn’t fully specified by the spec. The fields typically expected per Project Date: starts_at (ISO 8601 datetime), ends_at (ISO 8601 datetime), and possibly fields like address if the date overrides the Project’s. Confirm against live API behavior or coordinate with VOMO support for the canonical shape.

A minimal create

cURL
curl -X POST https://api.vomo.org/v1/projects \
  -H "Authorization: Bearer $VOMO_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Saturday Food Bank Volunteer Shift",
    "description": "Sort donated food and pack boxes for delivery.",
    "allow_guests": true,
    "age_limit": 16,
    "volunteer_question": "Do you have any dietary restrictions or food allergies?",
    "dates": [
      {
        "starts_at": "2025-04-19T14:00:00Z",
        "ends_at": "2025-04-19T18:00:00Z"
      }
    ]
  }'
The response returns the created Project with its assigned id — capture this for subsequent updates.

A create with recurring dates

For a Project running on a recurring schedule (e.g., every Saturday for a month):
JavaScript
function generateSaturdayDates(startDate, weekCount, startTime, endTime) {
  const dates = [];
  for (let week = 0; week < weekCount; week++) {
    const saturdayStart = new Date(startDate);
    saturdayStart.setDate(startDate.getDate() + week * 7);

    const [startHour, startMinute] = startTime.split(':').map(Number);
    saturdayStart.setUTCHours(startHour, startMinute, 0, 0);

    const saturdayEnd = new Date(saturdayStart);
    const [endHour, endMinute] = endTime.split(':').map(Number);
    saturdayEnd.setUTCHours(endHour, endMinute, 0, 0);

    dates.push({
      starts_at: saturdayStart.toISOString(),
      ends_at: saturdayEnd.toISOString(),
    });
  }
  return dates;
}

const createBody = {
  name: 'Saturday Food Bank Volunteer Shift',
  description: 'Sort donated food and pack boxes for delivery.',
  allow_guests: true,
  age_limit: 16,
  volunteer_question: 'Do you have any dietary restrictions?',
  dates: generateSaturdayDates(new Date('2025-04-19'), 8, '14:00', '18:00'),
};

const response = await fetch('https://api.vomo.org/v1/projects', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${token}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify(createBody),
});

const created = await response.json();
console.log(`Created Project ${created.data.id} with ${createBody.dates.length} scheduled dates`);
The Project is created with its full eight-week schedule in one request — no separate Project Date creation needed.

The GET-then-PUT pattern for updates

PUT /projects/{id} is full-record replacement — submit the entire Project’s data, not just the fields you want to change. Fields omitted from the request are subject to being set to defaults or null. For partial updates, fetch the current record first, merge your changes, then PUT the full record back:
JavaScript
async function updateProjectField(projectId, field, value) {
  // 1. Fetch current state
  const current = await getProjectDetail(projectId);
  if (!current) throw new Error(`Project ${projectId} not found`);

  // 2. Build the full PUT body with the change applied
  const updateBody = {
    name: current.name,
    description: current.description,
    allow_guests: current.allow_guests,
    age_limit: current.age_limit,
    volunteer_question: current.volunteer_question,
    details: current.details,
    participant_approval_required: current.participant_approval_required,
    background_check_required: current.background_check_required,
    show_volunteer_counter: current.show_volunteer_counter,
    volunteer_counter_threshold: current.volunteer_counter_threshold,
    draft: current.draft,
    privacy: current.privacy,
    address: current.address,
    dates: (current.all_dates ?? []).map(rebuildDateForPut),
    [field]: value, // The field being changed
  };

  // 3. PUT the full record
  const response = await fetch(
    `https://api.vomo.org/v1/projects/${projectId}`,
    {
      method: 'PUT',
      headers: {
        Authorization: `Bearer ${token}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(updateBody),
    }
  );

  if (!response.ok) throw new Error(`Update failed: ${response.status}`);
  return response.json();
}

function rebuildDateForPut(existingDate) {
  // Strip read-only fields, keep the schedule data the PUT expects
  return {
    starts_at: existingDate.starts_at,
    ends_at: existingDate.ends_at,
  };
}

What rebuildDateForPut is doing

The all_dates field on ProjectDetailResource (the GET response) includes per-Date metadata that may not belong in the PUT body — things like participant_count, id, etc. The PUT expects a clean array of date objects; the helper strips read-only fields to produce that. Without this transformation, your PUT might either:
  • Include unexpected fields the API rejects
  • Modify Project Date IDs in ways that confuse downstream consumers
  • Persist read-only fields back, causing silent data drift

When GET-then-PUT is necessary

Always. Without it, omitting fields effectively clears them. A “quick update of the age limit” without the GET-then-PUT pattern would wipe the description, settings, and schedule. The cost: one extra read per update. For partner integrations with high update frequency, cache the GET result:
JavaScript
class ProjectUpdater {
  constructor({ token }) {
    this.token = token;
    this.cache = new Map(); // projectId → cached detail
  }

  async update(projectId, changes) {
    let current = this.cache.get(projectId);

    if (!current) {
      current = await getProjectDetail(projectId);
      this.cache.set(projectId, current);
    }

    const updateBody = this._buildPutBody(current, changes);
    const response = await fetch(
      `https://api.vomo.org/v1/projects/${projectId}`,
      {
        method: 'PUT',
        headers: {
          Authorization: `Bearer ${this.token}`,
          'Content-Type': 'application/json',
        },
        body: JSON.stringify(updateBody),
      }
    );

    if (!response.ok) throw new Error(`Update failed: ${response.status}`);

    // Invalidate the cache — local snapshot is now stale
    this.cache.delete(projectId);

    return response.json();
  }

  _buildPutBody(current, changes) {
    return {
      name: current.name,
      description: current.description,
      allow_guests: current.allow_guests,
      age_limit: current.age_limit,
      volunteer_question: current.volunteer_question,
      details: current.details,
      participant_approval_required: current.participant_approval_required,
      background_check_required: current.background_check_required,
      show_volunteer_counter: current.show_volunteer_counter,
      volunteer_counter_threshold: current.volunteer_counter_threshold,
      draft: current.draft,
      privacy: current.privacy,
      address: current.address,
      dates: (current.all_dates ?? []).map(rebuildDateForPut),
      ...changes, // Apply the changes
    };
  }
}
The cache is invalidated after each update because local state is stale post-PUT.

Scenario 1: Sync Projects from an external system

For partner integrations whose customer manages Projects in an external scheduling system:
JavaScript
async function syncProjectFromExternal(externalProject) {
  // 1. Look up the existing mapping (if any)
  const mapping = await externalDb.findProjectMapping(externalProject.id);

  // 2. Build the PUT/POST body
  const body = {
    name: externalProject.name,
    description: externalProject.description,
    allow_guests: externalProject.allowGuests ?? true,
    age_limit: externalProject.ageLimit ?? 0,
    volunteer_question: externalProject.signupQuestion ?? '',
    details: externalProject.details,
    draft: !externalProject.isPublished,
    privacy: externalProject.isPrivate ? 'PRIVATE' : 'PUBLIC',
    dates: externalProject.scheduledDates.map((d) => ({
      starts_at: d.startTime,
      ends_at: d.endTime,
    })),
  };

  let response;
  let created = false;

  if (mapping) {
    // Update existing
    response = await fetch(
      `https://api.vomo.org/v1/projects/${mapping.vomoProjectId}`,
      {
        method: 'PUT',
        headers: {
          Authorization: `Bearer ${token}`,
          'Content-Type': 'application/json',
        },
        body: JSON.stringify(body),
      }
    );
  } else {
    // Create new
    response = await fetch('https://api.vomo.org/v1/projects', {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${token}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(body),
    });
    created = true;
  }

  if (!response.ok) {
    throw new Error(`Sync failed: ${response.status}`);
  }

  const result = await response.json();

  // Update the mapping if newly created
  if (created) {
    await externalDb.recordProjectMapping({
      externalId: externalProject.id,
      vomoProjectId: result.data.id,
      syncedAt: new Date(),
    });
  }

  return { project: result.data, created };
}

The external-ID-to-VOMO-ID mapping

Unlike Users (where email is the natural matching key), Projects have no equivalent matching key in VOMO. The mapping table is essential:
CREATE TABLE project_mappings (
  external_id    VARCHAR PRIMARY KEY,
  vomo_project_id INTEGER NOT NULL UNIQUE,
  synced_at      TIMESTAMP,
  created_at     TIMESTAMP DEFAULT NOW()
);
Without this table, you can’t reliably know “is this external Project already in VOMO?” — leading to duplicate creates and orphaned VOMO records.

Scenario 2: Bulk Project creation

For one-time setup imports (a new customer onboarding with many existing Projects):
JavaScript
async function bulkCreateProjects(externalProjects) {
  const client = new ThrottledVomoClient({ token, requestsPerSecond: 2 });
  const results = {
    created: [],
    failed: [],
  };

  for (let i = 0; i < externalProjects.length; i++) {
    const externalProject = externalProjects[i];

    try {
      const { project, created } = await syncProjectFromExternalVia(client, externalProject);
      if (created) {
        results.created.push({
          externalId: externalProject.id,
          vomoProjectId: project.id,
        });
      }
    } catch (err) {
      results.failed.push({
        externalId: externalProject.id,
        error: err.message,
      });
    }

    if ((i + 1) % 10 === 0) {
      console.log(`Created ${i + 1}/${externalProjects.length}: ` +
                  `${results.created.length} ok, ${results.failed.length} failed`);
    }
  }

  return results;
}
Throttle aggressively for bulk creates — each request includes a full Project with potentially many Project Dates, making the work-per-request substantial.

Scenario 3: Adjust a Project’s schedule

Modifying the schedule is the same as any other update — full PUT with the modified dates array:
JavaScript
async function addProjectDate(projectId, newStartsAt, newEndsAt) {
  const current = await getProjectDetail(projectId);

  // Build the new dates array (existing + new)
  const existingDates = (current.all_dates ?? []).map(rebuildDateForPut);
  const newDates = [
    ...existingDates,
    { starts_at: newStartsAt, ends_at: newEndsAt },
  ];

  return updateProjectField(projectId, 'dates', newDates);
}

async function removeProjectDate(projectId, dateIdToRemove) {
  const current = await getProjectDetail(projectId);

  // Build the new dates array (existing minus the removed one)
  const newDates = (current.all_dates ?? [])
    .filter((d) => d.id !== dateIdToRemove)
    .map(rebuildDateForPut);

  if (newDates.length === 0) {
    throw new Error('Cannot remove the last Project Date — dates is required');
  }

  return updateProjectField(projectId, 'dates', newDates);
}

The “can’t have zero dates” constraint

Because dates is required (and the array must be non-empty), you can’t reduce a Project’s schedule to nothing via the API. Practical implications:
  • Removing all Project Dates effectively requires admin-UI involvement (or deleting the Project entirely, which also requires admin UI).
  • Schedule cleanup workflows should keep at least one placeholder Date until the Project is intentionally ended.

Preserving Project Date IDs

Existing Project Dates have stable IDs that are referenced from Participation records. When updating the dates array:
  • Existing Dates kept in the PUT preserve their IDs and participations.
  • New Dates added to the PUT get newly-assigned IDs.
  • Existing Dates omitted from the PUT are removed; their participations may be affected.
The exact behavior on omission (whether participations are also deleted, or whether the Project Date is soft-deleted) isn’t documented in the spec. Coordinate with VOMO support before relying on this for production workflows that touch Dates with existing participations.

Handling validation errors

A 422 Unprocessable Entity response means the request body failed validation:
{
  "message": "The given data was invalid.",
  "errors": {
    "name": ["The name field is required."],
    "dates": ["The dates field must contain at least one item."],
    "age_limit": ["The age limit may not be greater than 99."]
  }
}
Surface the field-level errors:
JavaScript
try {
  await syncProjectFromExternal(externalProject);
} catch (err) {
  if (err.status === 422) {
    const fields = err.body?.errors ?? {};
    Object.entries(fields).forEach(([field, messages]) => {
      console.error(`${field}: ${messages.join(', ')}`);
    });
    return;
  }
  throw err;
}
Common validation issues:
IssueCause
name: requiredMissing or empty name
description: minimum 3 charactersToo-short description
dates: required / must have at least 1 itemMissing dates array, or empty array
age_limit: must be between 0 and 99Out-of-range value
volunteer_question: requiredMissing field even when no question is intended (submit empty string?)
starts_at / ends_at inside dates: invalid formatNon-ISO-8601 timestamps
See Error Handling: 422 Validation Error.

A reference Project upsert client

JavaScript
class VomoProjectUpsert {
  constructor({ token }) {
    this.token = token;
    this.baseUrl = 'https://api.vomo.org/v1';
  }

  async create(projectData) {
    const response = await this._fetch(`${this.baseUrl}/projects`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(this._buildBody(projectData)),
    });

    if (response.status === 422) {
      const problem = await response.json();
      throw new ValidationError(problem.message, problem.errors ?? {});
    }

    if (!response.ok) throw new Error(`Create failed: ${response.status}`);
    return response.json();
  }

  async update(projectId, projectData) {
    const response = await this._fetch(`${this.baseUrl}/projects/${projectId}`, {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(this._buildBody(projectData)),
    });

    if (response.status === 404) {
      throw new Error(`Project ${projectId} not found`);
    }

    if (response.status === 422) {
      const problem = await response.json();
      throw new ValidationError(problem.message, problem.errors ?? {});
    }

    if (!response.ok) throw new Error(`Update failed: ${response.status}`);
    return response.json();
  }

  async updateField(projectId, field, value) {
    const currentResponse = await this._fetch(`${this.baseUrl}/projects/${projectId}`);
    if (!currentResponse.ok) {
      throw new Error(`Could not fetch current project: ${currentResponse.status}`);
    }
    const { data: current } = await currentResponse.json();

    return this.update(projectId, { ...this._normalizeForUpdate(current), [field]: value });
  }

  _buildBody(data) {
    if (!data.dates || data.dates.length === 0) {
      throw new Error('Projects must have at least one date');
    }

    const body = {
      name: data.name,
      description: data.description,
      allow_guests: data.allow_guests ?? false,
      age_limit: data.age_limit ?? 0,
      volunteer_question: data.volunteer_question ?? '',
      dates: data.dates.map((d) => ({
        starts_at: d.starts_at,
        ends_at: d.ends_at,
      })),
    };

    // Optional fields
    if (data.details !== undefined) body.details = data.details;
    if (data.participant_approval_required !== undefined) {
      body.participant_approval_required = data.participant_approval_required;
    }
    if (data.background_check_required !== undefined) {
      body.background_check_required = data.background_check_required;
    }
    if (data.show_volunteer_counter !== undefined) {
      body.show_volunteer_counter = data.show_volunteer_counter;
    }
    if (data.volunteer_counter_threshold !== undefined) {
      body.volunteer_counter_threshold = data.volunteer_counter_threshold;
    }
    if (data.draft !== undefined) body.draft = data.draft;
    if (data.privacy) body.privacy = data.privacy;
    if (data.address) body.address = data.address;
    if (data.point_person) body.point_person = data.point_person;

    return body;
  }

  _normalizeForUpdate(current) {
    return {
      name: current.name,
      description: current.description,
      allow_guests: current.allow_guests,
      age_limit: current.age_limit,
      volunteer_question: current.volunteer_question,
      details: current.details,
      participant_approval_required: current.participant_approval_required,
      background_check_required: current.background_check_required,
      show_volunteer_counter: current.show_volunteer_counter,
      volunteer_counter_threshold: current.volunteer_counter_threshold,
      draft: current.draft,
      privacy: current.privacy,
      address: current.address,
      dates: (current.all_dates ?? []).map((d) => ({
        starts_at: d.starts_at,
        ends_at: d.ends_at,
      })),
    };
  }

  _fetch(url, options = {}) {
    return fetch(url, {
      ...options,
      headers: {
        Authorization: `Bearer ${this.token}`,
        Accept: 'application/json',
        ...options.headers,
      },
    });
  }
}
The updateField helper does the GET-then-PUT in one call — convenient for single-field updates without the boilerplate.

What can’t be done via the API

CapabilityStatus
Delete a ProjectNot exposed — admin UI only
Soft-delete / archive a ProjectUse draft: true to unpublish; full deletion requires admin UI
Update only a Project Date’s time without touching the ProjectNot exposed; modify via the full PUT
Cancel a single Project DateSame — modify via full PUT
Add participants programmaticallyNot exposed
Modify a Project’s organization assignmentNot exposed
Modify a Project’s Campaign attachmentsNot exposed — admin UI only
See Understand Write Limitations for the full picture.

Where to go next

Read a Project's Schedule

The companion read workflow for the Project Dates you’ve scheduled.

Manage Groups and Members

The other write-heavy workflow in the Volunteer API.

Projects and Project Dates

The reference page for Project resource fields and relationships.

Understand Write Limitations

The explicit list of what the API can and can’t do for Projects.
Last modified on May 22, 2026