Cursor Pagination
HMAC-scoped cursors and unpaginated bounded sets on the REST API.
List endpoints on the API are cursor-paginated with an opaque HMAC token, except two bounded sets served whole and three per-token and per-pool lists that take a page number.
Cursor style (default)
GET /activity?cursor=<opaque>
GET /dex/pools?cursor=<opaque>
GET /explore/tokens?cursor=<opaque>
GET /explore/farms?cursor=<opaque> # array is named "farms"
GET /explore/creators?cursor=<opaque> # array is named "creators"
GET /swap/candidates # (unpaginated: bounded candidate set)
GET /farms # (unpaginated: bounded pool set)Response shape:
{
"items": [ ... ],
"nextCursor": "ZXlKcFpDSTZOREkyTURkOQ.WZ6-aoIxCHpYZLEc",
"hasMore": true
}items- page of records (farmson/explore/farms,creatorson/explore/creators).nextCursor- pass to?cursor=on the next request.nullwhenhasMore == false. The token is two URL-safe base64 parts joined by a dot, a payload and a signature. Treat it as opaque and URL-encode it anyway.hasMore- derived by over-fetchingPAGE_SIZE + 1records.
Page size is a server constant per endpoint. There is no ?limit= on
cursor endpoints.
Scope binding: a cursor cannot be replayed
The cursor is an HMAC-signed token over (keyset value, scope string).
The scope string includes the endpoint path AND its query filters. So:
GET /activity?user=0xA&cursor=X # OK
GET /activity?user=0xB&cursor=X # 403 - cursor was signed for user=0xA
GET /dex/pools?cursor=X # 403 - cursor was signed for /activityA tampered or replayed cursor returns 403, not 400. This means:
always pass the exact same filters on every page. Changing ?user= or
?type= mid-pagination requires a fresh first page (no cursor).
Iterating a list
The unfiltered feed is long. Filter it, keep the same filter on every page, and stop when you have what you need:
type Activity = { transactionId: string; kind: string; timestamp: number };
const base = "https://api.motoswap.org/activity?type=farm_harvest";
const MAX_PAGES = 3;
let cursor: string | null = null;
const all: Activity[] = [];
for (let i = 0; i < MAX_PAGES; i++) {
const q = cursor ? `&cursor=${encodeURIComponent(cursor)}` : "";
const r = await fetch(base + q);
if (!r.ok) throw new Error(`activity ${r.status}`);
const page = await r.json();
all.push(...page.items);
if (!page.hasMore) break;
cursor = page.nextCursor;
}/activity pages hold 25 rows. /dex/pools, /explore/tokens and
/explore/farms hold 100.
First page
Just omit ?cursor=. The server returns the first page and sets
nextCursor for whatever comes next.
Unpaginated (bounded set)
Two routes are unpaginated because the set behind them is small and fixed by the team:
GET /farms- every farm pool in one response.GET /swap/candidates?tokenIn=&tokenOut=- the candidate pair set for a route pair. Amount-independent, cache-friendly, small.
Both are safe to load in full. The Motoswap app filters and sorts them in the browser.
Page-numbered lists
Three lists take ?page= (1 to 200, 50 rows per page) instead of a cursor, and
their envelopes differ:
| Route | Envelope |
|---|---|
GET /tokens/{addr}/swaps | { items, page, pageSize, hasMore } |
GET /tokens/{addr}/pools | { items, page, totalPages, total } |
GET /dex/pools/{pair}/swaps | { items, page, totalPages, total } |
/dex/pools/{pair}/swaps reaches back 200 pages, so total tops out at
10000 on a busy pool.
Combining with ETags
Cursor URL + ETag = per-page cache. Consecutive pages under a stable cursor
each get their own ETag. See
caching-and-etags.
Sort direction & keyset
You cannot re-sort a cursor list by passing a different sort=. The cursor
encodes the keyset it was cut from (e.g. (tvl_usd, pair_address)), so
switching the sort mid-pagination fails the signature. Start fresh (no
cursor) with the new sort.
Common errors
| Status | Cause |
|---|---|
403 | Any cursor failure - malformed token, bad signature, or scope mismatch (endpoint, filters, or query changed). Body: {"error":"invalid cursor"}. Refetch the first page. |
400 | Validation failure on a non-cursor field (bad address, bad range enum). |