# Pagination examples

Use this guide when you are writing a sync job rather than testing a single
request in the API reference.

## Cursor helper

Cursor values are opaque. Store or replay them exactly as returned, and do not
decode or construct them.

```js
const baseUrl = process.env.HOLLYHR_API_BASE_URL;
const token = process.env.HOLLYHR_API_TOKEN;

async function holly(path) {
  const response = await fetch(`${baseUrl}${path}`, {
    headers: {
      Authorization: `Bearer ${token}`,
      Accept: "application/json",
    },
  });

  if (response.status === 429 || response.status >= 500) {
    throw new Error(`Retry the same page later: ${response.status}`);
  }

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

  return response.json();
}

async function* pages(path) {
  let cursor;

  do {
    const separator = path.includes("?") ? "&" : "?";
    const page = await holly(
      `${path}${cursor ? `${separator}cursor=${encodeURIComponent(cursor)}` : ""}`,
    );

    yield page;
    cursor = page.pagination?.next_cursor;
  } while (cursor);
}
```

Retry the current page on `429` or `5xx`. For incremental sync, advance your
watermark only after every page in the run has completed.

## People sync

Use `updated_since` for polling jobs. Persist the maximum `updated_at` seen
only after the full paginated run completes.

```js
let maxUpdatedAt = "2026-06-16T09:00:00.000Z";

for await (const page of pages(
  `/people?limit=100&updated_since=${encodeURIComponent(maxUpdatedAt)}`,
)) {
  for (const person of page.data) {
    await upsertPerson(person);
    if (person.updated_at > maxUpdatedAt) {
      maxUpdatedAt = person.updated_at;
    }
  }
}

await savePeopleWatermark(maxUpdatedAt);
```

## Time-off windows

Time-off list reads should be bounded by a date window. Combine the window with
status or person filters when you can.

```js
for await (const page of pages(
  "/time-off?limit=100&from=2026-07-01&to=2026-07-31&status=approved",
)) {
  for (const request of page.data) {
    await upsertTimeOff(request);
  }
}
```

Use `updated_since` for reconciliation jobs that need changes since the last
run. Keep the date window if the downstream system only needs a bounded
calendar range.

## Document metadata

Document endpoints return metadata only. They do not return document bytes or
download URLs.

```js
for await (const page of pages("/documents?limit=50&updated_since=2026-06-16T09%3A00%3A00.000Z")) {
  for (const document of page.data) {
    await upsertDocumentMetadata(document);
  }
}
```

## Provider mappings

Provider mappings are useful when reconciling remote identifiers. They require
`provider_mappings:read` plus the relevant resource read scope.

```js
for await (const page of pages("/provider-mappings?limit=100&provider_name=merge")) {
  for (const mapping of page.data) {
    await linkRemoteObject(mapping);
  }
}
```

## Webhook delivery logs

Delivery logs are paginated independently per webhook endpoint. Use filters to
review failures or retry queues.

```js
for await (const page of pages(
  "/webhooks/{webhookId}/deliveries?limit=50&status=failed&event_type=person.updated&created_since=2026-06-21T00%3A00%3A00.000Z",
)) {
  for (const delivery of page.data) {
    await inspectDelivery(delivery);
  }
}
```

Use the redelivery action only after you have fixed the receiver problem that
caused the delivery to fail.

## Non-paginated collections

Some endpoints return small full collections under `data` and do not include a
`pagination` object. Examples include `/organisation/locations`, `/org-units`,
`/working-patterns`, `/custom-fields`, `/public-holidays`, and
`/reference/*` dictionaries. Treat those responses as a single page.

Single-resource endpoints, such as `GET /people/{personId}` or
`GET /webhooks/{webhookId}/deliveries/{deliveryId}`, also do not paginate.
