> ## Documentation Index
> Fetch the complete documentation index at: https://docs.virtuous.org/llms.txt
> Use this file to discover all available pages before exploring further.

# Pagination & Conventions

> 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 pagination envelope

Every list endpoint returns a JSON object with three top-level fields:

```json theme={null}
{
  "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:

| Field   | Type   | Role                                                                                            |
| ------- | ------ | ----------------------------------------------------------------------------------------------- |
| `data`  | array  | The records on this page. Each element is a resource (`UserResource`, `ProjectResource`, etc.). |
| `links` | object | Navigation URLs to other pages (`first`, `last`, `prev`, `next`).                               |
| `meta`  | object | Numeric metadata about the current pagination state.                                            |

This is the **Laravel API resource** pagination shape, common across Laravel-based APIs.

### The `links` object

| Field         | Type           | When `null`                                  |
| ------------- | -------------- | -------------------------------------------- |
| `links.first` | string         | First page URL. Always populated.            |
| `links.last`  | string         | Last page URL. Always populated.             |
| `links.prev`  | string or null | Previous page URL. `null` on the first page. |
| `links.next`  | string or null | Next 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.

<Warning>
  **⚠️ 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.
</Warning>

### The `meta` object

| Field               | Type    | Description                                                                              |
| ------------------- | ------- | ---------------------------------------------------------------------------------------- |
| `meta.current_page` | integer | The current page number (1-based)                                                        |
| `meta.from`         | integer | 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](#page-size) below) |
| `meta.total`        | integer | The total number of records across all pages                                             |

<Warning>
  **⚠️ 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.
</Warning>

***

## Page size

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](/volunteer/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 recommended pattern: follow `links.next`

The cleanest pagination pattern uses the API's own `links.next` URL rather than constructing page URLs manually:

```javascript JavaScript theme={null}
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:

| Reason                                  | Why                                                                     |
| --------------------------------------- | ----------------------------------------------------------------------- |
| **Robust to pagination scheme changes** | If the API changes how pages are addressed, the integration still works |
| **No off-by-one bugs**                  | The loop terminates exactly when `links.next` is `null`                 |
| **Preserves query parameters**          | The next URL carries any filter parameters from the original request    |
| **Self-documenting**                    | The 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 JavaScript theme={null}
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 JavaScript theme={null}
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 JavaScript theme={null}
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`:

```json theme={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 JavaScript theme={null}
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`:

```json theme={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:

| 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.

### Page navigation beyond `last`

If your code (incorrectly) requests `?page=999` against an endpoint with only 8 pages, the response is typically still a valid envelope:

```json theme={null}
{
  "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 JavaScript theme={null}
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 JavaScript theme={null}
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 JavaScript theme={null}
// ❌ 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 JavaScript theme={null}
// ❌ 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 JavaScript theme={null}
// ❌ 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:

| 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.

***

## 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](/volunteer/rate-limits))

***

## Where to go next

<CardGroup cols={2}>
  <Card title="Rate Limits" icon="gauge" href="/volunteer/rate-limits">
    The throttling patterns that pair with paginated reads.
  </Card>

  <Card title="The Volunteer Data Model" icon="diagram-project" href="/volunteer/concepts/data-model">
    What resources are available to paginate through.
  </Card>

  <Card title="Error Handling" icon="triangle-exclamation" href="/volunteer/error-handling">
    Error handling specific to multi-page reads.
  </Card>

  <Card title="API Performance Tips" icon="gauge" href="/volunteer/best-practices/api-performance-tips">
    The broader performance patterns including pagination-aware caching.
  </Card>
</CardGroup>
