Guides

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 (farms on /explore/farms, creators on /explore/creators).
  • nextCursor - pass to ?cursor= on the next request. null when hasMore == 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-fetching PAGE_SIZE + 1 records.

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 /activity

A 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:

RouteEnvelope
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

StatusCause
403Any cursor failure - malformed token, bad signature, or scope mismatch (endpoint, filters, or query changed). Body: {"error":"invalid cursor"}. Refetch the first page.
400Validation failure on a non-cursor field (bad address, bad range enum).

On this page