How to build Volunteer integrations that survive API evolution — defensive parsing, the eventual v2 overhaul, deprecation handling, feature flags, and the practices that keep integrations running through change.
The Volunteer API is at v1 — URL-versioned via the /v1/ segment in the base URL (https://api.vomo.org/v1). A v2 overhaul will eventually arrive, addressing many of the audit-flagged quirks (type inconsistencies, naming inconsistencies, anonymous schemas) accumulated in v1. When that happens, integrations that handled v1’s quirks defensively will migrate gracefully; integrations that depended on v1’s exact behavior will break.This page covers the patterns that prepare integrations for API evolution: defensive parsing, version-aware clients, feature flags for behavior changes, deprecation handling, and the partner-side schema evolution practices that keep integrations running through inevitable change.
The current v1 API has documented quirks that v2 will likely address:
v1 quirk
Likely v2 behavior
hours returned as string (“4.00”) per audit #40
Will be a proper number
verified returned as 0/1 integer per audit spec
Will be a proper boolean
project_id/project_date_id typed string but should be integer per audit #41
Will be a proper integer
address typed as array on ProjectResource per audit #43
Will be a proper object
Empty schema: {} on Organization endpoints per audit #25/#26/#27
Will have proper schema with OrganizationResource
Path parameter {completion} instead of {completionId} per audit #20
Will be renamed for consistency
POST /groups returns 200 instead of 201 per audit #21
Will return proper 201 Created
Anonymous inline body schemas on POST/PUT /projects per audit #31/#33
Will be named, fully-documented component schemas
operationId: createUser for upsert endpoint per audit #47
Will be renamed to upsertUser or split into separate endpoints
name_like / email_like substring matching
May add name / email exact-match alternatives
These are documented in the audit findings throughout the docs (see Concepts and individual workflow pages). Many integrations have built workarounds for them; v2 will allow those workarounds to be removed.But between now and v2, your integration runs against v1. And during the transition, your integration may need to support both. This page is about how.
The single most valuable practice for surviving API evolution: never trust the API’s response shape. Parse defensively at the boundary; produce a clean internal representation; quarantine the API’s quirks in one place.
// Boundary parser — knows about the API's quirksclass VomoUserParser { parseFromApi(raw) { return { id: this._parseInt(raw.id), firstName: raw.first_name ?? null, lastName: raw.last_name ?? null, fullName: raw.full_name ?? null, email: this._parseEmail(raw.email), phone: raw.phone ?? null, birthday: this._parseDate(raw.birthday), gender: raw.gender ?? null, userStatus: raw.user_status ?? null, membershipRole: raw.membership_role ?? null, createdAt: this._parseDateTime(raw.created_at), updatedAt: this._parseDateTime(raw.updated_at), }; } _parseInt(value) { if (value === null || value === undefined) return null; if (typeof value === 'number') return value; return parseInt(value, 10) || null; // Audit #41: handle string ints } _parseDateTime(value) { if (!value) return null; const parsed = new Date(value); if (isNaN(parsed.getTime())) return null; return parsed; } _parseDate(value) { if (!value) return null; // Audit #44: format is "YYYY-MM-DD" const parsed = new Date(value + 'T00:00:00Z'); if (isNaN(parsed.getTime())) return null; return parsed; } _parseEmail(value) { return value?.toLowerCase().trim() ?? null; }}
The parser is the only place that knows about v1’s quirks. Application code receives clean, typed, normalized data — and doesn’t care whether the API returns hours as a string or number.
class VomoUserParserV2 extends VomoUserParser { // v2 returns proper types; the base parser's defensive coercion still works // but we can override for clarity if desired _parseInt(value) { return value; // v2 returns proper integers; pass through }}
The migration is localized — only the parser changes. Application code continues working without touching.
For staged migration, version selection becomes per-customer:
JavaScript
async function getClientForCustomer(customerId) { const config = await db.getCustomerConfig(customerId); const version = config.vomoApiVersion ?? 'v1'; const token = await credentialStore.getToken(customerId, 'api_call'); return new VomoClient({ token, version });}
Some customers can be on v2 while others stay on v1, allowing gradual migration. The application code stays oblivious — the client and parser handle the version differences.
When the API returns fields you don’t recognize, ignore them. Don’t fail; don’t log them as errors; just skip:
JavaScript
class TolerantParser { parse(raw) { const known = { id: raw.id, first_name: raw.first_name, // ... fields you explicitly know about ... }; // Unknown fields are silently ignored return known; }}
If the API adds a new field (e.g., v2 adds last_login_at), your integration continues working — it just doesn’t surface the new field until you decide to. This is the forward-compatible reading principle: be conservative in what you send, liberal in what you accept.
This breaks every time the API adds a new field. Customers see the integration fail; the partner has to ship a release just to acknowledge the new field exists.
Principle 4: feature flags for API behavior changes
When migrating from v1 to v2 (or any time you change how the integration handles API responses), wrap the change in a feature flag:
JavaScript
async function fetchUserDetail(customerId, userId) { const useV2 = await featureFlags.isEnabled('use_vomo_v2', { customerId }); const client = useV2 ? new VomoClient({ version: 'v2', token: await getToken(customerId) }) : new VomoClient({ version: 'v1', token: await getToken(customerId) }); const response = await client.get(`/users/${userId}`); const raw = await response.json(); return useV2 ? new VomoUserParserV2().parseFromApi(raw.data) : new VomoUserParser().parseFromApi(raw.data);}
Flagged rollout:
Phase
Customers on v2
Goal
1
Internal test
Verify against test data
2
1-2 friendly customers
Real-world signals; partners notified
3
10% of customers
Statistical sample
4
50% of customers
Confidence build
5
All customers
Full migration; v1 client code marked for removal
At each phase, monitor: error rates, processed-record counts, reconciliation gap rates. If anything degrades, roll back the flag (no code deploy needed).
Your own data model evolves too — fields get added, types change, columns are deprecated. Plan for the partner-side schema to evolve in lockstep with VOMO’s changes:
-- ✅ Safe to deploy: existing rows have NULL for the new fieldALTER TABLE persons ADD COLUMN ethnicity VARCHAR DEFAULT NULL;-- ❌ Risky: NOT NULL constraint breaks if any row already existsALTER TABLE persons ADD COLUMN ethnicity VARCHAR NOT NULL;
Backfill data before adding NOT NULL. Or accept nullable forever — sometimes the right answer.
When VOMO eventually marks a field or endpoint as deprecated (typical pattern: Deprecation header or Sunset header per RFC 8594), your integration should:
Detect the deprecation in API responses
Log it for awareness
Schedule migration before the sunset date
Verify the migration before the sunset takes effect
For partner integrations, run regular tests against VOMO that verify the integration’s assumptions about response shapes:
JavaScript
describe('VOMO API contract — Users', () => { it('returns expected fields on list', async () => { const response = await fetch( 'https://api.vomo.org/v1/users?page=1', { headers: { Authorization: `Bearer ${testToken}` } } ); const page = await response.json(); expect(page).toHaveProperty('data'); expect(page).toHaveProperty('links.next'); expect(page).toHaveProperty('meta.total'); if (page.data.length > 0) { const sampleUser = page.data[0]; expect(sampleUser).toHaveProperty('id'); expect(sampleUser).toHaveProperty('email'); expect(sampleUser).toHaveProperty('updated_at'); } }); it('updated_at is parseable as ISO 8601 datetime', async () => { const response = await fetch(/* ... */); const page = await response.json(); if (page.data.length > 0) { const parsed = new Date(page.data[0].updated_at); expect(parsed.toString()).not.toBe('Invalid Date'); } }); it('hours field on participations is parseable as float', async () => { // The known v1 quirk per audit #40 const userDetail = await fetchUserDetail(/* ... */); if (userDetail.participations?.length > 0) { const hours = userDetail.participations[0].hours; expect(typeof hours === 'string' || typeof hours === 'number').toBe(true); expect(parseFloat(hours)).toBeGreaterThanOrEqual(0); } });});
Run these tests:
Nightly against the production API (with a test customer’s token)
As part of CI for any release that touches the integration
When VOMO ships v2, these tests will surface incompatibilities — expect(typeof hours === 'number') would now pass without the string fallback. You’d see the change immediately and could plan the migration.
Use a dedicated test customer / test organization for contract tests. Don’t run them against a real customer’s data — both for privacy and for stability (test runs could affect real data if the API behaves unexpectedly).
For records that can’t be sanely processed (missing required fields, malformed data), choose between rejection (DLQ) and degradation (process with defaults):
JavaScript
function processWithFallback(vomoUser) { // Required: email and ID if (!vomoUser.email || !vomoUser.id) { return { skipped: true, reason: 'missing_required_fields' }; } // Everything else has defaults return externalSystem.upsertUser({ external_id: `vomo-${vomoUser.id}`, email: vomoUser.email.toLowerCase(), first_name: vomoUser.first_name?.trim() || 'Unknown', last_name: vomoUser.last_name?.trim() || '', // ... });}
Decide per-field whether absence is fatal or tolerable. Document the decisions.
Every assumption your integration makes about the API should be documented. When the API changes, the documentation tells you what to re-verify.A simple convention: a per-integration API_ASSUMPTIONS.md file:
# Integration Assumptions about VOMO API v1## Identifiers- `user.id` is integer- `project.id` is integer- Participation primary key: (project_date_id, user_id)## Field types- `hours` returned as string (audit #40); we parse as float- `verified` returned as 0/1 integer; we treat as boolean- `birthday` returned as "YYYY-MM-DD" string## Endpoints we depend on- GET /users (list with updated_after filter)- GET /users/{id} (detail with embedded participations)- POST /users (upsert by email)- GET /projects (list with active/published/dates filters)- GET /projects/{id} (detail with embedded all_dates)- GET /projects/date/{id} (detail with embedded participants)## Pagination- Page size: 15 (not configurable)- Page parameter: `page=N`- Next link in response.links.next; null when no next page## Rate limits- We throttle to 3 req/sec per customer- We respect Retry-After on 429
When a v2 migration is on the horizon, this doc is the migration checklist — every assumption that v2 changes needs corresponding integration code update.
Principle 10: graceful failure modes for API changes
When the API does something unexpected, what does your integration do?
Unexpected response
Best response
Field is missing (was present before)
Treat as null; log debug
Field’s type changed
Defensive parser handles it; log warning
New required field on writes
Hopefully introduced with deprecation notice; otherwise fail with clear error
Endpoint returns 404 (was 200 before)
Treat as resource-not-found; reconciliation will surface if needed
Endpoint moved (new URL)
Hopefully deprecated first; otherwise discovered via failed contract test
Response shape changed
Parser fails; record is sent to DLQ for review
The general principle: fail loudly enough to be detected, but quietly enough to not crash the whole integration. Per-record failures go to DLQ; systemic patterns trigger alerts; the integration continues serving the customers it can.
Not every potential API change deserves defensive code. The pragmatic decision matrix:
Change
Defend?
Type change on a field you parse
✓ Always — parser handles both
New field added
✓ Ignore unknown fields by default
Existing field removed
✗ Hard to defend against without complicating code; usually announced via deprecation
Endpoint path renamed
✗ Usually deprecated with warning; migration is straightforward
Pagination semantics change
✓ Defensive — handle both old and new link/meta shapes
Authentication scheme change
✗ Major version change; full migration
Rate limit semantics change
✓ Defensive — handle both Retry-After and other backoff signals
The general rule: defend against subtle changes that could silently produce wrong output; accept the cost of explicit migration for major changes that are obvious when they happen.
An integration with these practices doesn’t dread API changes — it absorbs them. An integration without them treats every change as a crisis. The difference is months of cumulative engineering work, paid out one improvement at a time.