Skip to main content
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 pagination envelope

Every list endpoint returns a JSON object with three top-level fields:
{
  "data": [
    /* array of resource objects */
  ],
  "links": {
    "first": "https://api.vomo.org/v1/users?page=1",
    "last":  "https://api.vomo.org/v1/users?page=8",
    "prev":  null,
    "next":  "https://api.vomo.org/v1/users?page=2"
  },
  "meta": {
    "current_page": 1,
    "from": 1,
    "to": 15,
    "path": "https://api.vomo.org/v1/users",
    "per_page": 15,
    "total": 120
  }
}
Three fields, each with a specific role:
FieldTypeRole
dataarrayThe records on this page. Each element is a resource (UserResource, ProjectResource, etc.).
linksobjectNavigation URLs to other pages (first, last, prev, next).
metaobjectNumeric metadata about the current pagination state.
This is the Laravel API resource pagination shape, common across Laravel-based APIs.
FieldTypeWhen null
links.firststringFirst page URL. Always populated.
links.laststringLast page URL. Always populated.
links.prevstring or nullPrevious page URL. null on the first page.
links.nextstring or nullNext page URL. null on the last page.
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 meta object

FieldTypeDescription
meta.current_pageintegerThe current page number (1-based)
meta.fromintegerThe 1-based index of the first record on this page
meta.tointegerThe 1-based index of the last record on this page
meta.pathstringThe base URL (without query parameters)
meta.per_pageintegerThe number of records per page (platform-controlled — see Page size below)
meta.totalintegerThe 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.

Page size

Volunteer’s pagination differs from CRM+ and Raise in an important way: the page size is not partner-controlled.
APIPage size control
CRM+Take query parameter, up to 100
Raisetake parameter, up to 1000
VolunteerNot 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.
The cleanest pagination pattern uses the API’s own links.next URL rather than constructing page URLs manually:
JavaScript
async function listAllUsers() {
  const allUsers = [];
  let url = 'https://api.vomo.org/v1/users';

  while (url) {
    const response = await fetch(url, {
      headers: {
        Authorization: `Bearer ${process.env.VOMO_API_TOKEN}`,
        Accept: 'application/json',
      },
    });

    if (!response.ok) {
      throw new Error(`Page fetch failed: ${response.status}`);
    }

    const page = await response.json();
    allUsers.push(...page.data);

    // Follow the next link, or stop if there isn't one
    url = page.links.next;
  }

  return allUsers;
}
Why this pattern is best:
ReasonWhy
Robust to pagination scheme changesIf the API changes how pages are addressed, the integration still works
No off-by-one bugsThe loop terminates exactly when links.next is null
Preserves query parametersThe next URL carries any filter parameters from the original request
Self-documentingThe flow is obvious — follow the next link until there isn’t one

Combining filters with pagination

When the original request has filter parameters (like name_like=wayne), the links.next URL preserves them:
JavaScript
async function listUsersMatchingName(searchTerm) {
  const allMatches = [];
  const params = new URLSearchParams({ name_like: searchTerm });
  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();
    allMatches.push(...page.data);
    url = page.links.next;
  }

  return allMatches;
}
The filter persists across pages because the API includes it in each links.next URL. The integration doesn’t need to re-attach it manually.

Stopping early

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.

Progress tracking for large reads

For long-running bulk reads, surface progress to the user or to logs:
JavaScript
async function backfillAllProjects(customerId, onProgress) {
  const allProjects = [];
  let url = 'https://api.vomo.org/v1/projects';
  let pagesRead = 0;
  let totalKnown = null;

  while (url) {
    const response = await fetch(url, {
      headers: { Authorization: `Bearer ${token}` },
    });
    const page = await response.json();

    allProjects.push(...page.data);
    pagesRead++;
    totalKnown ??= page.meta.total;

    if (onProgress) {
      onProgress({
        recordsLoaded: allProjects.length,
        totalRecords: totalKnown,
        currentPage: page.meta.current_page,
        totalPages: page.meta.last_page ?? Math.ceil(totalKnown / page.meta.per_page),
      });
    }

    url = page.links.next;
  }

  return allProjects;
}

// Usage
await backfillAllProjects(customerId, ({ recordsLoaded, totalRecords, currentPage }) => {
  console.log(`Page ${currentPage}: ${recordsLoaded}/${totalRecords} loaded`);
});
Useful both for visibility and for confidence — a 30-minute backfill that produces no output is hard to distinguish from a stalled one.

Edge cases

Empty result set

A query that matches no records returns an empty data array with links.next === null:
{
  "data": [],
  "links": {
    "first": "https://api.vomo.org/v1/users?page=1",
    "last": "https://api.vomo.org/v1/users?page=1",
    "prev": null,
    "next": null
  },
  "meta": {
    "current_page": 1,
    "from": null,
    "to": null,
    "path": "https://api.vomo.org/v1/users",
    "per_page": 15,
    "total": 0
  }
}
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}`;
}

Single page of results

When all records fit on one page, links.prev and links.next are both null:
{
  "data": [/* records */],
  "links": {
    "first": "https://api.vomo.org/v1/users?page=1",
    "last": "https://api.vomo.org/v1/users?page=1",
    "prev": null,
    "next": null
  },
  "meta": { "current_page": 1, "total": 10, "last_page": 1, /* ... */ }
}
The loop body runs once and terminates. No special-case code needed.

Modification during iteration

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:
OptionDescription
Read with a fixed time filter?created_before=<read-start-time> ensures new records added during iteration don’t appear
De-duplicate by ID downstreamTrack IDs as you process them; skip duplicates
The created_before filter is the cleaner pattern for read-once snapshots. If your code (incorrectly) requests ?page=999 against an endpoint with only 8 pages, the response is typically still a valid envelope:
{
  "data": [],
  "links": { "first": "...", "last": "...", "prev": "...", "next": null },
  "meta": { "current_page": 999, "total": 120, "last_page": 8, /* ... */ }
}
The data array is empty, next is null. The integration’s loop terminates correctly even if a page number is somehow out of range.

When to manually construct page URLs

For most workflows, follow links.next. But two specific cases call for manual page URL construction:

Random-access page jumps

For UI workflows like “go to page 5” or “show the last page”:
JavaScript
async function getPage(resource, pageNumber, filters = {}) {
  const params = new URLSearchParams({ ...filters, page: pageNumber.toString() });
  const url = `https://api.vomo.org/v1/${resource}?${params}`;

  const response = await fetch(url, {
    headers: { Authorization: `Bearer ${token}` },
  });

  return response.json();
}

// Get page 5 of users matching "wayne"
const page5 = await getPage('users', 5, { name_like: 'wayne' });

Resumable iterators

For workflows that may be interrupted (long backfills, crashable workers):
JavaScript
async function backfillResumable(customerId) {
  let currentPage = await getCheckpoint(customerId) ?? 1;

  while (true) {
    const params = new URLSearchParams({ page: currentPage.toString() });
    const response = await fetch(
      `https://api.vomo.org/v1/projects?${params}`,
      { headers: { Authorization: `Bearer ${token}` } }
    );
    const page = await response.json();

    if (page.data.length === 0) break;

    await processProjects(page.data);

    currentPage++;
    await setCheckpoint(customerId, currentPage);
  }
}
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.

Performance considerations

A few patterns that keep paginated reads efficient:

Don’t re-fetch pages you’ve already processed

JavaScript
// ❌ Anti-pattern — fetches every page twice
const totalPages = await getTotalPages(); // 1 request
for (let i = 1; i <= totalPages; i++) {
  const page = await getPage(i); // N more requests
  await process(page);
}

// ✅ Use the cursor pattern — one pass through pages
let url = '/v1/users';
while (url) {
  const page = await fetch(url).then(r => r.json());
  await process(page);
  url = page.links.next;
}
The first pattern wastes a request determining the count before doing the work. The second pattern is single-pass.

Filter aggressively

JavaScript
// ❌ Anti-pattern — reads 1000 pages, filters to recent ones in code
const all = await listAllUsers();
const recent = all.filter((u) => new Date(u.created_at) > startOfYear);

// ✅ Push the filter into the request
const recentParams = new URLSearchParams({ created_after: startOfYear.toISOString() });
const recent = await listAllUsers(`?${recentParams}`);
The second pattern reads only the records that match, often a fraction of the total.

Process pages as they arrive

For very large reads, don’t accumulate everything in memory:
JavaScript
// ❌ Memory-heavy — accumulates everything before processing
const all = await listAllUsers();
for (const user of all) {
  await processUser(user);
}

// ✅ Streaming — process each page as it arrives
async 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.

Pagination across the three Virtuous APIs

Quick reference for partners building against multiple APIs:
AspectCRM+RaiseVolunteer
Envelope{ list, total }{ items, total }{ data, links, meta }
ModelSkip / Take offsetsskip / take offsetspage numbers
Page size controlTake up to 100take up to 1000Not controllable
NavigationConstruct URLsConstruct URLsFollow links.next
Terminationskip >= totalitems.length < takelinks.next === null
CasingPascalCasemixedsnake_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.

A pagination checklist

When implementing pagination against the Volunteer API:
  • Use links.next to navigate, not manual page number construction (where possible)
  • Check links.next with simple null check, not string comparison
  • Handle the empty-result-set case (data: [], all meta.from/meta.to null)
  • Push filters into the request, not client-side after the fact
  • Process pages as they arrive for large reads (don’t accumulate everything)
  • For interrupted bulk reads, save page-number checkpoints
  • For UI workflows needing random-access pages, construct URLs manually
  • Be aware page size isn’t partner-controllable
  • Pair pagination with rate-limit-aware throttling (see Rate Limits)

Where to go next

Rate Limits

The throttling patterns that pair with paginated reads.

The Volunteer Data Model

What resources are available to paginate through.

Error Handling

Error handling specific to multi-page reads.

API Performance Tips

The broader performance patterns including pagination-aware caching.
Last modified on May 22, 2026