How pagination works in the Volunteer API — the data/links/meta envelope, page-based navigation, the follow-the-link pattern, and the practical patterns for reading multi-page result sets.
The Volunteer API uses a distinctive pagination pattern that differs from CRM+ and Raise — a three-part envelope with data, links, and meta fields, page-based navigation via ?page=N, and HATEOAS-style links the API returns for navigating between pages. This page covers the envelope, the navigation pattern, the edge cases, and the practical patterns for reading paginated data.
The URLs in links are complete — they include the full host, version segment, path, and query parameters. Use them as-is rather than constructing URLs by hand.
⚠️ Spec gap (audit #6): The OpenAPI spec’s example for links.prev and links.next shows the value as the literal string "null" (with quotes) rather than JSON null. The live API returns actual JSON null. Code that checks for the string "null" will treat all pages as having both previous and next pages.Use a simple null check (if (page.links.next) or if (page.links.next !== null)), not a string comparison.
The 1-based index of the first record on this page
meta.to
integer
The 1-based index of the last record on this page
meta.path
string
The base URL (without query parameters)
meta.per_page
integer
The number of records per page (platform-controlled — see Page size below)
meta.total
integer
The total number of records across all pages
⚠️ Spec gap (audit #5): The OpenAPI spec types meta.current_page, meta.from, meta.to, and meta.per_page as integer but provides examples as strings (e.g., "1", "15"). The live API likely returns actual integer values. Code that parses these fields should expect integers, not strings.
Volunteer’s pagination differs from CRM+ and Raise in an important way: the page size is not partner-controlled.
API
Page size control
CRM+
Take query parameter, up to 100
Raise
take parameter, up to 1000
Volunteer
Not controllable — platform-set default (typically 15)
The Volunteer spec documents page as a query parameter on list endpoints but does not document per_page as a query parameter. Partner integrations cannot request larger or smaller pages.This has practical implications:
A customer with many records produces many pages. 120 users with per_page: 15 = 8 pages. 12,000 users = 800 pages.
Bulk reads make many requests. Compared to a 1000-per-page API, bulk reads against Volunteer make ~67× more requests for the same data.
Rate limits matter more. With many requests required, rate-limit awareness becomes critical earlier. See Rate Limits.
If you find your integration needing larger pages, coordinate with the customer’s VOMO concierge — they may be able to enable a larger default for the customer’s account, though this isn’t documented in the spec.
For workflows that don’t need every record — finding a specific user, taking the first N matches, etc. — break out of the loop when you have what you need:
JavaScript
async function findUserByEmail(email) { const params = new URLSearchParams({ email_like: email }); let url = `https://api.vomo.org/v1/users?${params}`; while (url) { const response = await fetch(url, { headers: { Authorization: `Bearer ${token}` }, }); const page = await response.json(); const match = page.data.find((u) => u.email?.toLowerCase() === email.toLowerCase()); if (match) return match; // Found — stop reading url = page.links.next; } return null; // No match across all pages}
This pattern avoids paging through everything when an early termination is acceptable.
The loop terminates immediately because links.next is null. The empty data array is correctly handled.Note that meta.from and meta.to are null when there are no records on the page — handle this if you display the range to users:
JavaScript
function formatPageRange(meta) { if (meta.total === 0) return 'No results'; return `Showing ${meta.from}–${meta.to} of ${meta.total}`;}
If records are added or modified while you’re paging through results, the page boundaries may shift. A record that was on page 3 when you started might move to page 4 by the time you read it; a new record added at the start might cause your next page to repeat records you’ve already seen.For most analytics workloads, this is acceptable — the snapshot is “as of approximately when the read started.” For workloads that need strict consistency, two options:
Option
Description
Read with a fixed time filter
?created_before=<read-start-time> ensures new records added during iteration don’t appear
De-duplicate by ID downstream
Track IDs as you process them; skip duplicates
The created_before filter is the cleaner pattern for read-once snapshots.
Saving the page number allows resuming from approximately where the previous run left off. The “approximately” matters — records added between runs may cause some overlap or gap, but the worker recovers.
For very large reads, don’t accumulate everything in memory:
JavaScript
// ❌ Memory-heavy — accumulates everything before processingconst all = await listAllUsers();for (const user of all) { await processUser(user);}// ✅ Streaming — process each page as it arrivesasync function streamAllUsers(processFn) { let url = 'https://api.vomo.org/v1/users'; while (url) { const page = await fetch(url).then(r => r.json()); for (const user of page.data) { await processFn(user); } url = page.links.next; }}
For backfills of 100,000+ records, streaming uses bounded memory regardless of dataset size.
Quick reference for partners building against multiple APIs:
Aspect
CRM+
Raise
Volunteer
Envelope
{ list, total }
{ items, total }
{ data, links, meta }
Model
Skip / Take offsets
skip / take offsets
page numbers
Page size control
Take up to 100
take up to 1000
Not controllable
Navigation
Construct URLs
Construct URLs
Follow links.next
Termination
skip >= total
items.length < take
links.next === null
Casing
PascalCase
mixed
snake_case
Three different pagination styles across three APIs. The mental model for Volunteer is the most distinct — follow the API’s own navigation links rather than calculating page offsets.