Pagination
Firmly uses two pagination styles, depending on the endpoint:
- Discovery search uses cursor-based pagination — you walk the results by following the cursor returned with each page, not by computing offsets.
- Catalog listing (Get All Products) uses page-number pagination — you request a specific
pageandsize, and the response tells you the total number of pages.
This page is the canonical reference for both patterns.
Cursor-based pagination (discovery search)
Each cursor-paginated response includes a cursor that, when passed to the next request, returns the next page of results.
{"products": [{ "...": "product 1" },{ "...": "product 2" }],"next_page": "https://api.firmly.work/api/v1/discovery/search?cursor=eyJ...&page_size=20"}
To get the next page, use the cursor value:
const results = await searchProducts('leather sofa', { page_size: 20 });if (results.next_page) {const cursor = new URL(results.next_page).searchParams.get('cursor');const moreResults = await searchProducts('leather sofa', { cursor, page_size: 20 });}
When there are no more results, next_page is absent (or null). Treat its absence as “end of results.”
Why cursor-based, not offset-based
Cursor-based pagination is the modern standard because it handles concurrent insertions gracefully. With offset-based pagination, a product added or removed between requests can cause items to appear twice or get skipped. Cursors are stateful markers into the result set rather than numeric offsets.
This is the same pattern most well-respected commerce APIs use.
Page size (discovery search)
The page_size parameter controls how many results come back in one discovery response. Reasonable values:
| Use case | Suggested page_size |
|---|---|
| Conversational AI surfacing a few options | 3–5 |
| List view in a UI | 10–20 |
| Bulk catalog walk for indexing | 50–100 |
Discovery search clamps page_size to the range 1–100; a value outside that range is adjusted to the nearest bound rather than rejected.
Page-number pagination (catalog listing)
Get All Products returns the full catalog one page at a time. Instead of a cursor, you pass a page number and a size. The paging fields come back nested under a metadata object — products is a sibling of metadata, not nested inside it:
{"metadata": {"page": 1,"size": 2000,"totalRecords": 4200,"domain": "staging.luma.gift","countryCode": "us","totalPages": 3,"generatedAt": "2026-03-17T10:30:00.000Z","startIndex": 0,"endIndex": 1999},"products": [{ "...": "product 1" },{ "...": "product 2" }]}
size accepts one of 100, 500, 1000, or 2000 (default 2000). Walk the catalog by requesting page 1, 2, 3 … up to metadata.totalPages. There is no cursor and no next_page field on this endpoint.
Walking the whole result set
For a complete catalog walk, iterate until next_page is absent:
async function fetchAllProducts(query, pageSize = 50) {const all = [];let cursor = undefined;while (true) {const page = await searchProducts(query, { cursor, page_size: pageSize });all.push(...page.products);if (!page.next_page) break;cursor = new URL(page.next_page).searchParams.get('cursor');}return all;}
For very large result sets, this can be slow and consume rate budget. Consider:
- Streaming results into your processing pipeline instead of accumulating in memory
- Walking incrementally based on a need (top N matches) rather than the full set
- Caching the discovery results briefly if you re-query the same thing
See Rate limits for throughput considerations on bulk walks.
Endpoints that paginate
| Endpoint | Style | Page control |
|---|---|---|
| Discovery search | Cursor-based | cursor in body, page_size |
| Catalog get-all-products | Page-number | page + size |
| Search Products by Query | Cursor-based | cursor, page_size |
Get active carts and Get active domains do not paginate — they return a single response scoped to the current session (typically small).
Edge cases
- Empty results on first page (discovery search):
products: []plus nonext_page— a successful “no matches” response, not an error. Search Products by Query differs — no matches there is404with a bare{"error": "No Matching Results Found"}body - Stale cursor: discovery cursors are stateless
search_afterkeys (a base64-encoded copy of the last row’s sort values), not server-side sessions — they do not expire on a timer. A cursor stays usable indefinitely; the only caveat is that if the underlying catalog changes between pages, results may shift (see “Order changes between pages” above) - Order changes between pages: results may shift if the underlying catalog changes mid-walk. Don’t rely on absolute ordering across pages
Related
- Discovery search — primary paginated endpoint
- Catalog list — bulk catalog walk
- Rate limits — throughput considerations for bulk pagination
- Errors & Conventions — handling pagination and validation errors