Walk through POST /users — the upsert behavior, the 200/201 detection pattern, syncing from external systems, bulk imports, and handling the email-as-primary-key reality.
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.
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 schedule
✓ dates 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.
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:
Look up whether the external Project has a known VOMO Project ID (from a mapping table)
If yes → PUT /projects/{vomoId}
If no → POST /projects and record the resulting VOMO ID
POST /projects and PUT /projects/{id} share the same body shape. Required fields:
Field
Type
Notes
name
string
3-255 characters
description
string
3-2000 characters
allow_guests
boolean
Whether volunteers can bring guests
age_limit
integer
0-99 (minimum volunteer age)
volunteer_question
string
Up to 500 characters; the optional signup question
dates
array
At least one Project Date is required
Optional fields:
Field
Type
Notes
details
string
3-2000 characters (operational notes — what to wear, what to bring)
participant_approval_required
boolean
Organizer must approve signups
background_check_required
boolean
Background check required to volunteer
show_volunteer_counter
boolean
Display signup count publicly
volunteer_counter_threshold
integer
Minimum count before showing the counter
draft
boolean
Whether the Project is unpublished
privacy
string
"PUBLIC" or "PRIVATE"
address
object
Project location
point_person
object
Day-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 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:
Implication
What it means
Projects and schedules are created together
You can’t “create the Project now, schedule it later” via API
The dates array contains Project Date objects
Each with starts_at, ends_at, and other per-occurrence data
Updates submit the full dates array
Adding a new Project Date means submitting the existing dates plus the new one
Removing a Project Date means omitting it from the PUT body
A 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.
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, };}
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
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.
Throttle aggressively for bulk creates — each request includes a full Project with potentially many Project Dates, making the work-per-request substantial.
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.
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."] }}