Skip to content
Desktop Accounting API

Pagination

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.

Each list operation uses one of two modes, shown in the 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.
{
"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).

Terminal window
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..."

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:

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

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.

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);

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.

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

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

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.

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.

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.