The Form resource family — Forms, Form Fields, Form Completions, and Field Responses. The chain that captures structured data from volunteers, with the spec-flagged quirks partner integrations need to know.
Forms in the Volunteer API are data-collection templates — waivers, volunteer applications, signup questions, post-event surveys. When a volunteer fills out a Form, the result is a Form Completion containing the volunteer’s responses to each field. The chain is:
Form (template) ├── Form Fields (questions) │ └── Form Field Options (dropdown values) └── Form Completion (volunteer's submission) └── Form Field Responses (per-field answers)
This page covers the five-resource Form family, the three read endpoints, and the practical patterns for working with form data — including the audit-flagged quirks in the spec.
All Form endpoints are read-only. Forms are created and managed in the VOMO admin UI; volunteers submit Form Completions through the VOMO UI; partner integrations consume the data.
⚠️ Spec gap (audit #20): The path parameter for the third endpoint is {completion} rather than the more conventional {completionId}. The parameter descriptions in the spec also contain typos (“VOMO From ID” instead of “VOMO Form ID” — audit #19). The path works as documented; the names will be cleaned up in a future spec revision.
⚠️ Spec gap (audit #17): The FormResource schema is incorrectly typed as array in the spec. The actual resource is a single object — the field set above is on the items inside that erroneous array. Code generated from the spec may need manual adjustment.
⚠️ Spec gap (audit #15): The slug field is documented with two contradictory pieces of information:
An enum: ['SHORTTEXT', 'LONGTEXT', 'DROPDOWN', 'MULTIPLESELECT', 'WAIVER', 'TEXTSINGLE'] (these look like form-field types)
An example: "2d8614a5-31ed-4810-8da2-448f59463e43" (a UUID)
The enum and example can’t both be right. The example suggests the slug is a UUID identifier for the Form; the enum looks like it was copy-pasted from a Form Field’s field_type. Treat the slug as a UUID string per the example. The enum will be removed in a future spec revision.
⚠️ Spec gap (audit #16): The id field has minimum: 3, maximum: 45 constraints — these are string-length-style constraints applied to an integer ID. The actual ID range almost certainly isn’t 3 to 45; treat IDs as unconstrained integers.
The form_type enum determines what context the Form is used in:
Value
Meaning
PROJECT
A Form attached to a Project (waivers, project-specific signups, post-event surveys)
⚠️ Spec gap (audit #54): The spec documents only PROJECT as a valid form_type value. Other types likely exist in production (organization-wide signups, user-profile forms, etc.) but aren’t documented. Treat unknown values gracefully — log them but don’t crash.
The field_type on a Form Field determines what input the volunteer provides. Likely values (from the Form slug enum, which appears to have been miscategorized — see audit #15):
field_type value
What it represents
SHORTTEXT
A single-line text input
LONGTEXT
A multi-line text input
DROPDOWN
A dropdown menu — values come from options[]
MULTIPLESELECT
A multi-select — values come from options[]
WAIVER
A waiver acceptance (typically a checkbox + legal text)
TEXTSINGLE
(Likely equivalent to SHORTTEXT)
For partner integrations parsing Form completions:
JavaScript
function parseFieldValue(field, response) { switch (field.field_type) { case 'SHORTTEXT': case 'LONGTEXT': case 'TEXTSINGLE': return response.value; // String as-is case 'WAIVER': return response.value === 'true' || response.value === '1'; case 'DROPDOWN': // Look up the option by value return field.options?.find((o) => o.value === response.value)?.name ?? response.value; case 'MULTIPLESELECT': // Comma-separated values; look up each const values = response.value.split(',').map((v) => v.trim()); return values.map((v) => field.options?.find((o) => o.value === v)?.name ?? v ); default: return response.value; // Unknown type — return raw string }}
The exact storage format for MULTIPLESELECT (comma-separated vs. JSON array vs. something else) isn’t documented in the spec; the pattern above handles the most likely formats defensively.
⚠️ Spec gap (audit #18): The include_archived parameter is typed string in the spec but is functionally a boolean — accepts "true" or "false". Code should send the boolean as a string ("true" / "false").
async function userCompletionsForForm(formId, userId) { const params = new URLSearchParams({ user_id: userId.toString() }); return paginate(`https://api.vomo.org/v1/forms/${formId}/completions?${params}`);}
All completions since the last sync:
JavaScript
async function newCompletionsForForm(formId, since) { const params = new URLSearchParams({ created_after: since.toISOString(), }); return paginate(`https://api.vomo.org/v1/forms/${formId}/completions?${params}`);}
A polling integration pulling form completions on a schedule would iterate over active Forms, then for each Form pull new completions since the checkpoint.
Returns a single FormCompletionResource with full field_responses[]:
JavaScript
async function getFormCompletion(formId, completionId) { const response = await fetch( `https://api.vomo.org/v1/forms/${formId}/completions/${completionId}`, { headers: { Authorization: `Bearer ${token}` } } ); if (response.status === 404) return null; if (!response.ok) throw new Error(`Failed: ${response.status}`); const result = await response.json(); return result.data;}
The list endpoint already returns completions with their field responses, so the single-completion endpoint is most useful for re-fetching a specific completion when you have the ID but not the cached data.
Reading field responses with the Field definitions
A common pattern: display a Form completion alongside the Field definitions so the responses make sense in context.
JavaScript
async function getCompletionWithFieldContext(formId, completionId) { // 1. Fetch the Form (to get Field definitions) const forms = await fetch(`https://api.vomo.org/v1/forms?name_like=`, { headers: { Authorization: `Bearer ${token}` }, }); // ... find the specific form const form = (await forms.json()).data.find((f) => f.id === formId); // 2. Fetch the Completion const completion = await getFormCompletion(formId, completionId); if (!completion) return null; // 3. Join responses with field definitions const fieldsById = new Map(form.fields.map((f) => [f.id, f])); return { completionId: completion.id, user_id: completion.user_id, project_id: completion.project_id, submittedAt: new Date(completion.created_at), fields: completion.field_responses.map((r) => { const field = fieldsById.get(r.field_id); return { question: field?.name ?? `Field ${r.field_id}`, type: field?.field_type ?? 'UNKNOWN', value: parseFieldValue(field, r), }; }), };}
The result is a displayable Form Completion with question text, field type, and parsed value for each response — what you’d show in a UI showing “Bruce Wayne’s volunteer application.”For partner integrations doing this frequently, cache the Form definitions — they change rarely and the lookup happens often.
For showing a single user’s full form submission history:
JavaScript
async function userFormHistory(userId) { // 1. Get all Forms (to know what's available) const forms = await activeForms(); // 2. For each Form, get this user's completions const allCompletions = []; for (const form of forms) { const params = new URLSearchParams({ user_id: userId.toString() }); const url = `https://api.vomo.org/v1/forms/${form.id}/completions?${params}`; const response = await fetch(url, { headers: { Authorization: `Bearer ${token}` } }); const result = await response.json(); for (const completion of result.data) { allCompletions.push({ formName: form.name, formId: form.id, completion, }); } } return allCompletions.sort((a, b) => new Date(b.completion.created_at) - new Date(a.completion.created_at) );}
This is N+1 (one Form list + one Completion list per Form). For accounts with many Forms but where most users have few completions, this is acceptable; for high-volume accounts, consider caching.
For organizations requiring annual waiver renewal:
JavaScript
async function findUsersNeedingWaiver(waiverFormId) { const oneYearAgo = new Date(); oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1); // 1. Get all completions of the waiver const allCompletions = await paginate( `https://api.vomo.org/v1/forms/${waiverFormId}/completions` ); // 2. Find the most recent completion per user const latestByUser = new Map(); for (const c of allCompletions) { const existing = latestByUser.get(c.user_id); if (!existing || new Date(c.created_at) > new Date(existing.created_at)) { latestByUser.set(c.user_id, c); } } // 3. Filter to users whose latest is older than a year return Array.from(latestByUser.entries()) .filter(([_, c]) => new Date(c.created_at) < oneYearAgo) .map(([userId, c]) => ({ userId, lastCompletedAt: c.created_at }));}
The result is a list of users whose waiver completion has aged out — candidates for renewal outreach.
If a partner integration needs to push Form data into VOMO (e.g., importing waivers signed externally), coordinate with VOMO’s admin team for an alternative path — typically a CSV import.See Understand Write Limitations.
The parsers handle the defensive cases — missing optional fields, the array-vs-object schema confusion (audit #17 affects the top-level FormResource and FormFieldResource but doesn’t reach parser logic if you treat the response as the array-of-one-record it sometimes appears as).