Skip to main content
When volunteers fill out a Form in VOMO — a waiver, a signup question, a post-event survey — the result is a Form Completion record. Form Completions are read-only via the API (volunteers submit them through VOMO; partner integrations consume them), but they’re often the richest source of structured data about volunteers’ preferences, eligibility, and feedback. This workflow page walks through reading Form Completions: how to find them, how to display them with their field context, how to filter by user or time range, and how to sync them to external systems for reporting or follow-up. If you haven’t yet, skim the Forms and Form Completions concept page for the five-resource family structure and field shapes.

When to use this workflow

ScenarioThis workflow fits
Display a user’s submitted forms in a partner UI
Sync waiver acceptances to an external compliance system
Sync survey responses to a BI/analytics platform
Build a report aggregating responses across a Form
Trigger follow-up workflows when a Form is submitted (e.g., new waiver detected)✓ Use incremental polling
Submit a Form Completion programmatically✗ Not exposed — admin UI / VOMO UI only
Modify a submitted Completion’s responses✗ Not exposed
Delete a Completion✗ Not exposed

The three endpoints involved

EndpointWhat it returns
GET /formsList of Forms (to find the Form ID you care about)
GET /forms/{id}/completionsList of Completions for that Form (filterable)
GET /forms/{id}/completions/{completion}A single Completion with full field responses
⚠️ Spec gap (audit #19, #20): The path parameter for the single-completion endpoint is {completion} (not the more conventional {completionId}), and the parameter descriptions in the spec contain typos (“VOMO From ID” instead of “VOMO Form ID”). The endpoints work as documented; the naming will be cleaned up in a future spec revision.

Step 1: Find the Form

If you have the Form ID already, skip to Step 2. Otherwise, find it from the Forms list:
JavaScript
async function findFormByName(formName) {
  const params = new URLSearchParams({ name_like: formName });
  const response = await fetch(
    `https://api.vomo.org/v1/forms?${params}`,
    { headers: { Authorization: `Bearer ${process.env.VOMO_API_TOKEN}` } }
  );
  if (!response.ok) throw new Error(`Failed: ${response.status}`);

  const page = await response.json();
  return page.data.find(
    (f) => f.name.toLowerCase() === formName.toLowerCase()
  ) ?? null;
}

// Usage
const waiver = await findFormByName('General Volunteer Waiver');
if (!waiver) throw new Error('Waiver form not configured in VOMO');
For partner integrations, the typical pattern is to configure the Form ID once during onboarding rather than looking it up by name every time:
JavaScript
// In customer settings
const customerConfig = {
  vomoForms: {
    waiver: { id: 42, name: 'General Volunteer Waiver' },
    survey: { id: 43, name: 'Post-Event Survey' },
  },
};
The configured ID is more robust than name-based lookup (Form renames don’t break the integration) and avoids the per-call lookup cost.

Step 2: List completions

GET /forms/{id}/completions returns the Completions for that Form, paginated with the standard data/links/meta envelope:
cURL
curl https://api.vomo.org/v1/forms/42/completions \
  -H "Authorization: Bearer $VOMO_API_TOKEN"

Available filters

ParameterWhat it filters by
user_idOnly completions by a specific User
created_beforeCompletions submitted on or before a date
created_afterCompletions submitted on or after a date
updated_beforeCompletions modified on or before a date
updated_afterCompletions modified on or after a date
pagePagination
A reference paginate function:
JavaScript
async function listCompletions(formId, filters = {}) {
  const params = new URLSearchParams(filters);
  const all = [];
  let url = `https://api.vomo.org/v1/forms/${formId}/completions?${params}`;

  while (url) {
    const response = await fetch(url, {
      headers: { Authorization: `Bearer ${token}` },
    });
    if (!response.ok) throw new Error(`Failed: ${response.status}`);

    const page = await response.json();
    all.push(...page.data);
    url = page.links.next;
  }

  return all;
}

Common list patterns

All completions of a Form (e.g., everyone who’s signed the waiver):
JavaScript
const allWaivers = await listCompletions(waiverFormId);
console.log(`${allWaivers.length} users have signed the waiver`);
One user’s completions of a Form:
JavaScript
const usersWaivers = await listCompletions(waiverFormId, {
  user_id: '12345',
});
const hasSignedWaiver = usersWaivers.length > 0;
Completions since the last sync:
JavaScript
async function newCompletionsSince(formId, sinceDate) {
  return listCompletions(formId, {
    created_after: sinceDate.toISOString(),
  });
}
The created_after filter is the primary incremental sync mechanism — feed it the timestamp of the last completion you processed.

Step 3: Read responses with context

The Form Completion’s field_responses array contains the volunteer’s answers, but each response is just { field_id, value } — without the Form’s field definitions, you don’t know what question was asked or what type of input the value represents. The pattern: fetch the Form once, then join responses with field definitions when displaying:
JavaScript
async function readCompletionWithContext(formId, completionId) {
  // 1. Fetch the Form (gives us field definitions)
  const formResponse = await fetch(
    `https://api.vomo.org/v1/forms?name_like=`, // workaround — see notes
    { headers: { Authorization: `Bearer ${token}` } }
  );
  // ... locate the specific form
  const form = (await formResponse.json()).data.find((f) => f.id === formId);
  if (!form) throw new Error(`Form ${formId} not found`);

  // 2. Fetch the Completion
  const completionResponse = await fetch(
    `https://api.vomo.org/v1/forms/${formId}/completions/${completionId}`,
    { headers: { Authorization: `Bearer ${token}` } }
  );
  if (!completionResponse.ok) {
    throw new Error(`Failed: ${completionResponse.status}`);
  }
  const completion = (await completionResponse.json()).data;

  // 3. Build a field lookup
  const fieldsById = new Map(form.fields.map((f) => [f.id, f]));

  // 4. Join responses with field definitions
  return {
    completionId: completion.id,
    userId: completion.user_id,
    projectId: completion.project_id,
    submittedAt: new Date(completion.created_at),
    responses: completion.field_responses.map((response) => {
      const field = fieldsById.get(response.field_id);
      return {
        fieldId: response.field_id,
        question: field?.name ?? `Field ${response.field_id}`,
        fieldType: field?.field_type ?? 'UNKNOWN',
        rawValue: response.value,
        displayValue: parseFieldValue(field, response),
      };
    }),
  };
}
The output is a displayable Completion — question text, field type, and parsed value per response — suitable for showing in a UI: “Bruce Wayne’s General Volunteer Waiver submitted 2025-04-19.”

Parsing values by field type

Different field types need different parsing:
JavaScript
function parseFieldValue(field, response) {
  if (!field) return response.value;

  switch (field.field_type) {
    case 'SHORTTEXT':
    case 'LONGTEXT':
    case 'TEXTSINGLE':
      return response.value;

    case 'WAIVER':
      // Truthy if "true" / "1" — treat as accepted
      return response.value === 'true' || response.value === '1';

    case 'DROPDOWN':
      // Look up the option by value to get display label
      const option = field.options?.find((o) => o.value === response.value);
      return option?.name ?? response.value;

    case 'MULTIPLESELECT':
      // Comma-separated values; look up each
      const values = response.value.split(',').map((v) => v.trim()).filter(Boolean);
      return values.map((v) => {
        const opt = field.options?.find((o) => o.value === v);
        return opt?.name ?? v;
      });

    default:
      return response.value;
  }
}
The exact storage format for MULTIPLESELECT (comma-separated vs. JSON array) isn’t documented in the spec — the pattern above handles the most likely format defensively.

Caching the Form definitions

For workflows that process many completions of the same Form, cache the Form (the field definitions) so you don’t re-fetch on every completion:
JavaScript
class FormCompletionReader {
  constructor({ token }) {
    this.token = token;
    this.formCache = new Map(); // formId → Form definition
  }

  async getForm(formId) {
    if (this.formCache.has(formId)) {
      return this.formCache.get(formId);
    }
    // (fetch the form — implementation depends on form ID vs lookup needs)
    const form = await this._fetchFormById(formId);
    this.formCache.set(formId, form);
    return form;
  }

  async readCompletion(formId, completionId) {
    const form = await this.getForm(formId);
    const completion = await this._fetchCompletion(formId, completionId);
    return this._joinResponsesWithFields(form, completion);
  }

  // ...
}
Forms change infrequently — caching for the duration of a sync run (or longer) is safe.

Scenario 1: Display a user’s form history

For a partner UI showing “all forms this volunteer has submitted”:
JavaScript
async function getUserFormHistory(userId) {
  // 1. Get all active Forms (so we know what to check)
  const formsResponse = await fetch(
    'https://api.vomo.org/v1/forms?include_archived=false',
    { headers: { Authorization: `Bearer ${token}` } }
  );
  const forms = (await formsResponse.json()).data;

  // 2. For each Form, find this user's completions
  const allHistory = [];
  for (const form of forms) {
    const userCompletions = await listCompletions(form.id, {
      user_id: userId.toString(),
    });

    for (const completion of userCompletions) {
      allHistory.push({
        formId: form.id,
        formName: form.name,
        completionId: completion.id,
        submittedAt: new Date(completion.created_at),
        projectId: completion.project_id,
      });
    }
  }

  // 3. Sort newest first
  return allHistory.sort((a, b) => b.submittedAt - a.submittedAt);
}
This is N+1 (one Forms 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:
  • Limit to specific forms the user is likely to have completed (waivers, role-specific forms)
  • Cache the result per user (Form history changes only when new completions arrive)
  • Run nightly and cache for the next day’s display

Scenario 2: Detect waiver expiration

For organizations requiring annual waiver renewal:
JavaScript
async function findUsersNeedingWaiverRenewal(waiverFormId) {
  const oneYearAgo = new Date();
  oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1);

  // 1. Get all waiver completions
  const allCompletions = await listCompletions(waiverFormId);

  // 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 (or who haven't signed)
  const needsRenewal = [];
  for (const [userId, completion] of latestByUser.entries()) {
    if (new Date(completion.created_at) < oneYearAgo) {
      needsRenewal.push({
        userId,
        lastSignedAt: new Date(completion.created_at),
        daysSinceSign: Math.floor(
          (Date.now() - new Date(completion.created_at)) / (1000 * 60 * 60 * 24)
        ),
      });
    }
  }

  return needsRenewal;
}
Feed the result into the customer’s renewal outreach pipeline. Run on a schedule (weekly or monthly) and email/notify users approaching their renewal deadline.

Comparing against active volunteers

A more nuanced version: only consider users who are currently active (have recent participations):
JavaScript
async function findActiveUsersNeedingWaiver(waiverFormId, activeSinceDate) {
  // 1. Get recently-active users
  const recentlyActive = await listUsers({
    updated_after: activeSinceDate.toISOString(),
  });
  const activeUserIds = new Set(recentlyActive.map((u) => u.id));

  // 2. Find waiver renewal candidates
  const candidates = await findUsersNeedingWaiverRenewal(waiverFormId);

  // 3. Filter to only the active ones
  return candidates.filter((c) => activeUserIds.has(c.userId));
}
This narrows the renewal list to volunteers who are actually engaged — avoiding outreach to dormant users.

Scenario 3: Sync completions to an external system

For partner integrations mirroring Form data into an external CRM or analytics platform:
JavaScript
async function syncFormCompletionsToExternal(customerId, formId) {
  const lastSync = await getCheckpoint(customerId, `form_${formId}`);
  const reader = new FormCompletionReader({ token });

  const newCompletions = await listCompletions(formId, {
    created_after: lastSync.toISOString(),
  });

  let highWaterMark = lastSync;

  for (const completion of newCompletions) {
    try {
      // Read with field context for richer external data
      const enriched = await reader.readCompletion(formId, completion.id);

      await externalSystem.recordCompletion({
        externalId: `vomo-completion-${completion.id}`,
        formId,
        userId: completion.user_id,
        projectId: completion.project_id,
        submittedAt: new Date(completion.created_at),
        responses: enriched.responses,
      });

      const createdAt = new Date(completion.created_at);
      if (createdAt > highWaterMark) highWaterMark = createdAt;
    } catch (err) {
      console.error(`Failed to sync completion ${completion.id}:`, err);
      // Continue to next — partial failures shouldn't block the whole sync
    }
  }

  await advanceCheckpoint(customerId, `form_${formId}`, highWaterMark);
}
Run on a schedule per Form the customer wants synced. The high-water-mark pattern (advancing to the latest created_at actually seen) ensures interrupted syncs resume correctly.

Triggering follow-up workflows

For workflows that need to react to new completions (e.g., send a welcome email when a new waiver is signed):
JavaScript
async function processNewCompletions(customerId, formId, onNewCompletion) {
  const lastSync = await getCheckpoint(customerId, `form_${formId}_trigger`);

  const newCompletions = await listCompletions(formId, {
    created_after: lastSync.toISOString(),
  });

  for (const completion of newCompletions) {
    try {
      await onNewCompletion(completion);
    } catch (err) {
      console.error(`Handler failed for completion ${completion.id}:`, err);
      // For triggers, you may want to fail-loud rather than continue —
      // depending on your business logic
    }
  }

  await advanceCheckpoint(customerId, `form_${formId}_trigger`, new Date());
}

// Usage
await processNewCompletions(customerId, waiverFormId, async (completion) => {
  await emailService.sendWelcome(completion.user_id);
});
Run on a polling interval (every 15 minutes for “near-real-time,” hourly for less urgent workflows). See Polling and Sync for the broader pattern.

Scenario 4: Aggregate responses for reporting

For survey or feedback forms where you want aggregate statistics:
JavaScript
async function aggregateResponsesForForm(formId) {
  const form = await getForm(formId);
  const completions = await listCompletions(formId);

  // For each field, tally the responses
  const tallyByField = new Map();

  for (const completion of completions) {
    for (const response of completion.field_responses) {
      if (!tallyByField.has(response.field_id)) {
        tallyByField.set(response.field_id, {
          field: form.fields.find((f) => f.id === response.field_id),
          values: [],
        });
      }
      tallyByField.get(response.field_id).values.push(response.value);
    }
  }

  // Format the report
  return Array.from(tallyByField.entries()).map(([fieldId, data]) => ({
    fieldId,
    question: data.field?.name ?? `Field ${fieldId}`,
    fieldType: data.field?.field_type ?? 'UNKNOWN',
    totalResponses: data.values.length,
    distribution: tallyByType(data.field, data.values),
  }));
}

function tallyByType(field, values) {
  if (!field) return { unknown: values.length };

  switch (field.field_type) {
    case 'DROPDOWN':
      // Count occurrences of each value
      return values.reduce((acc, v) => {
        acc[v] = (acc[v] ?? 0) + 1;
        return acc;
      }, {});

    case 'MULTIPLESELECT':
      // Each response may contain multiple comma-separated values
      const counts = {};
      values.forEach((response) => {
        response.split(',').map((v) => v.trim()).forEach((v) => {
          counts[v] = (counts[v] ?? 0) + 1;
        });
      });
      return counts;

    case 'WAIVER':
      const yes = values.filter((v) => v === 'true' || v === '1').length;
      return { yes, no: values.length - yes };

    default:
      // Text fields — return a sample
      return { sample: values.slice(0, 10), total: values.length };
  }
}
The output is a per-field distribution suitable for charts, reports, or BI exports.

Performance considerations

A few practical considerations for production-scale workloads:
ConsiderationPractice
Form-with-fields cacheCache for the duration of a sync run; refresh if Form’s updated_at advances
Pagination costVolunteer’s page size is 15 — Forms with many completions take many requests
ThrottlingUse a ThrottledClient (see Rate Limits) — 3-5 req/sec is conservative
N+1 patternsFor per-user form history, decide between N+1 (clean) and full-tracking (efficient)
Cold-start lookupsPre-cache the Form IDs your customer cares about; don’t look up by name on every call
Large field_responses arraysForms with many fields produce large completion objects — process per-response, not all at once for big batches
See API Performance Tips for the broader patterns.

What can’t be done via the API

CapabilityStatus
Submit a Form Completion on behalf of a userNot exposed — admin UI / VOMO UI only
Update an existing Completion’s responsesNot exposed
Delete a CompletionNot exposed
Modify Form Fields or Field OptionsNot exposed
Create a new FormNot exposed
Archive or delete a FormNot exposed
If a partner integration needs to push externally-collected Form data into VOMO (e.g., waivers signed through a third-party legal service), the typical path is:
  1. Capture the completion data externally
  2. Store a reference linking the external completion to the VOMO User
  3. Coordinate with the customer’s admin team for periodic CSV import into VOMO — or skip the VOMO sync entirely if the external system is authoritative
See Understand Write Limitations.

Where to go next

Understand Write Limitations

The reference for what can and can’t be done through the API.

Forms and Form Completions

The reference page for Form resource fields and the field-type values.

Polling and Sync

The broader change-detection pattern for incremental sync workflows.

Users

Form Completions link back to Users via user_id.
Last modified on May 22, 2026