# Pagination
Source: https://www.desktopaccountingapi.com/docs/guides/pagination/

> Page through large QuickBooks lists with cursors, understand cursor expiry, use SDK auto-pagination, and recover from CURSOR_EXPIRED without losing records.

QuickBooks Desktop pages large queries with iterators that exist only inside one QuickBooks session. This API exposes them as cursors and is explicit about their limits: every page says when its cursor expires, a page you already fetched can be fetched again after a network error, and an expired cursor fails with a clear error instead of skipping records.

## Two kinds of lists

Each list operation uses one of two modes, shown in the [API reference](https://www.desktopaccountingapi.com/docs/api/reference/) and in the spec as `x-daapi-pagination`:

- **Cursor lists** (`cursor`): invoices, customers, bills, transactions and most other large lists. Use `limit` (1 to 150, default 150) and `cursor`.
- **Complete lists** (`none`): small lists QuickBooks returns in one piece, such as accounts, classes, terms, payment methods and payroll wage items. The response contains every matching record. Where QuickBooks supports it, `limit` caps the count.

## Cursor pages

```json
{
  "objectType": "list",
  "url": "/v1/quickbooks-desktop/customers",
  "data": [ { "id": "80000001-1730311000", "objectType": "qbd_customer", "name": "Acme Supply" } ],
  "nextCursor": "c1.MDFqOXg0bTZ2NGM4.2.Yk3kQ9",
  "hasMore": true,
  "remainingCount": 412,
  "cursorExpiresAt": "2026-10-05T16:04:11.120Z"
}
```

| Field | Meaning |
| --- | --- |
| `nextCursor` | Send it as `cursor` to get the next page. `null` on the last page. |
| `hasMore` | Whether more pages remain. |
| `remainingCount` | How many records QuickBooks has left after this page. `null` on the last page. |
| `cursorExpiresAt` | Our estimate of when `nextCursor` stops working if you do not use it. `null` on the last page. |

To continue, send `cursor`, and `limit` if you want a different page size. Filters were fixed by the first request and the cursor remembers them. You can leave them out, or resend exactly the same filters (Conductor's SDKs do); a changed, added or dropped filter returns `400 CURSOR_PARAMS_MISMATCH`, because it would describe a different list.

Each page returns a new `nextCursor`; always continue with the one from the latest page. Sending a cursor again repeats its page, so a retry after a network error never skips records. A finished page is repeated at most twice; after that the same cursor returns `400 CURSOR_INVALID`, so a loop that keeps sending one cursor stops with an error instead of reading the same page forever.

Looking records up by `ids`, `fullNames` or `refNumbers` returns every match in a single page, with `hasMore: false` and `nextCursor: null`. QuickBooks does not page these lookups, so `limit` next to them is ignored. Other filters cannot be combined with them (`400 INVALID_PARAMETER` naming both parameters).

```sh
curl "https://api.desktopaccountingapi.com/v1/quickbooks-desktop/customers?cursor=c1.MDFqOXg0bTZ2NGM4.2.Yk3kQ9" \
  -H "Authorization: Bearer $DAAPI_SECRET_KEY" \
  -H "Daapi-End-User-Id: eu_01j9..."
```

## Why cursors expire

A cursor points to an iterator inside a live QuickBooks session. The session stays open while you keep reading, but only for a short idle window: currently about 10 seconds after each page. While the cursor is open, QuickBooks treats the company file as in use by your app, so read the remaining pages promptly or stop reading. The iterator also ends when:

- the QuickBooks session closes (sessions are capped at 2 minutes of continuous work),
- QuickBooks restarts,
- you start a fourth paged query on the same connection while three are still open (the least recently used one is dropped).

Continuing an expired cursor returns `410 CURSOR_EXPIRED`:

```json
{
  "error": {
    "type": "INVALID_REQUEST_ERROR",
    "code": "CURSOR_EXPIRED",
    "httpStatusCode": 410,
    "retryable": false,
    "outcome": "not_applicable",
    "details": { "reason": "idle_timeout", "pagesServed": 3, "recordsServed": 450 }
  }
}
```

`details.reason` is `idle_timeout`, `session_ended`, `quickbooks_restarted` or `evicted`. We never restart the query for you, because records may have changed in between, and a silent restart can skip or repeat records.

## Safe page fetching

**Retrying the latest page is safe.** Each page returns a new cursor. If your network drops while fetching page N, send the same cursor again: you get page N again from a short-lived cache, and the iterator does not advance twice. An older cursor, a cursor from another end user, or a malformed one returns `400 CURSOR_INVALID`.

**Fetch pages back to back.** Process a page after you have requested the next one, or collect the records and process them afterwards. The SDKs request the next page only when your loop needs it, so a loop that stops early never runs an extra QuickBooks query; when your code holds a page for more than 2 seconds, they request the next one in the background to keep the cursor alive. `listAll()` requests each next page as soon as a page arrives.

## Auto-pagination in the SDKs

**TypeScript**

```ts
import { DesktopAccountingApi } from "@desktopaccountingapi/quickbooks-desktop";

const qb = new DesktopAccountingApi().forEndUser("eu_01j9...");

// Item by item, across pages; slow loops get the next page in the background
for await (const customer of qb.qbd.customers.list({ status: "all" })) {
  console.log(customer.id, customer.fullName);
}

// Page by page
for await (const page of qb.qbd.invoices.list({ limit: 100 }).pages()) {
  console.log(page.data.length, page.remainingCount);
}

// Everything into memory, as fast as possible
const all = await qb.qbd.vendors.list().listAll();
console.log(all.length);
```

**Python**

```python
from desktopaccountingapi import DesktopAccountingApi

qb = DesktopAccountingApi().for_end_user("eu_01j9...")

# Item by item, across pages; slow loops get the next page in the background
for customer in qb.qbd.customers.list(status="all"):
    print(customer.id, customer.full_name)

# Page by page
for page in qb.qbd.invoices.list(limit=100).iter_pages():
    print(len(page.data), page.remaining_count)

# Everything into memory, as fast as possible
vendors = qb.qbd.vendors.list().list_all()
print(len(vendors))
```

**C#**

```csharp
await foreach (var customer in qb.Qbd.Customers.ListAsync(new CustomerListParams { Status = ActiveStatus.All }))
{
    Console.WriteLine($"{customer.Id} {customer.FullName}");
}
```

**Java**

```java
for (Customer customer : qb.qbd().customers().list(new CustomerListParams().status(ActiveStatus.ALL))) {
    System.out.println(customer.id() + " " + customer.fullName());
}
```

If a cursor expires mid-iteration, the SDKs raise `CursorExpiredError` (`CursorExpiredException` in C# and Java). It carries the reason, how many pages were served and items yielded, and the `id` and `updatedAt` of the last record you received. The SDKs never restart silently.

## Recovering from CURSOR_EXPIRED

Restart from a watermark instead of from the beginning. Sort-independent watermarks work best with `updatedAfter`:

**TypeScript**

```ts
import { DesktopAccountingApi, CursorExpiredError } from "@desktopaccountingapi/quickbooks-desktop";

const qb = new DesktopAccountingApi().forEndUser("eu_01j9...");
const seen = new Set<string>();
let since = "2026-01-01";

for (let attempt = 0; attempt < 5; attempt++) {
  try {
    for await (const invoice of qb.qbd.invoices.list({ updatedAfter: since })) {
      if (seen.has(invoice.id)) continue; // records at the watermark can repeat
      seen.add(invoice.id);
      // ...store the invoice...
      if (invoice.updatedAt > since) since = invoice.updatedAt;
    }
    break; // finished
  } catch (err) {
    if (!(err instanceof CursorExpiredError)) throw err;
    // continue from the newest record already stored
  }
}
```

**Python**

```python
from datetime import datetime
from typing import Optional
from desktopaccountingapi import CursorExpiredError, DesktopAccountingApi

qb = DesktopAccountingApi().for_end_user("eu_01j9...")
seen: set[str] = set()
since = "2026-01-01"
newest: Optional[datetime] = None

for attempt in range(5):
    try:
        for invoice in qb.qbd.invoices.list(updated_after=since):
            if invoice.id in seen:
                continue  # records at the watermark can repeat
            seen.add(invoice.id)
            # ...store the invoice...
            if newest is None or invoice.updated_at > newest:
                newest = invoice.updated_at
        break  # finished
    except CursorExpiredError:
        if newest is not None:
            since = newest.isoformat()  # continue from the newest record already stored
```

QuickBooks iterators return records in their own order, not by `updatedAt`, so this pattern re-reads some records after a restart. Deduplicate on `id`, as above, or make your writes idempotent by `id`. For full syncs of very large files, split the work into `updatedAfter` / `updatedBefore` windows (for example one month at a time) so each paged query finishes well inside one session. See [Incremental sync](https://www.desktopaccountingapi.com/docs/guides/incremental-sync/).

## Large responses

Complete lists and reports return everything at once. If QuickBooks' answer is too large to process, the API returns `422 QBD_RESPONSE_TOO_LARGE`. Narrow the filters, such as a date range or `status`, or use the cursor version of the list when one exists.

## Platform lists

`GET /v1/end-users` (and the other platform lists) use the same `cursor`, `limit`, `nextCursor` and `hasMore` names, with `limit` from 1 to 100 (default 50). These cursors come from our database and do not expire; `cursorExpiresAt` is always `null`.
