Skip to main content
What this is. Every v3 list endpoint returns results in chunks. You navigate forward or backward using an opaque cursor the server returns to you. Cursors are tokens — treat them as strings, pass them back unchanged. When it matters. Any time you list trades, deposits, or withdrawals. Two common patterns: “load all records” (back-fill into your DB) and “fetch what’s new since I last polled” (continuous sync). What you’ll learn. The request parameters, response envelope, forward and backward iteration patterns, and why page numbers (and total counts) don’t exist here. All v3 list endpoints (GET /v3/fx/trades, GET /v3/fx/deposits, GET /v3/fx/withdrawals) use cursor pagination. Results are sorted newest-first by createdAt and you page through them using cursors returned in the response. Default page size is 25; maximum is 100.

Request parameters

startingAfter and endingBefore are mutually exclusive. Set one or the other, not both. On the first request, omit both — you get the newest page.
Cursors are opaque. The cursor value the server returns is a string token. Treat it as opaque — do not decode, trim, re-encode, parse, or substitute its contents. Pass it back verbatim. The token’s internal format is an implementation detail and OpenFX reserves the right to change it.
Mental model. Lists are newest-first, so “starting after this cursor” means “older than the records this cursor points at” and “ending before this cursor” means “newer than the records this cursor points at”. Think of the cursor as a position on a vertical timeline with newest at the top.

Response envelope

Paginated responses wrap the array of records in a pagination envelope that carries the cursors for navigation:
hasNext / hasPrev are the booleans you branch on; nextCursor / prevCursor are the tokens you pass back. A page with both hasNext: false and hasPrev: false is the entire paginated result set.
Bounded lists do not paginate. GET /v3/fx/pairs, GET /v3/fx/balances, and GET /v3/fx/withdrawal-accounts return their complete data array without cursor parameters or a pagination object. Apply the cursor flow on this page only to trades, deposits, and withdrawals.
What’s not in the response. total, total_count, and total_pages are intentionally not part of the envelope — running a COUNT(*) on every list call would defeat the cursor-seek index plan that keeps list endpoints fast at scale. Use hasNext / hasPrev to detect whether more pages exist; there is no total-count or page-number field.

Decoding cursors

Don’t. Cursors are opaque — clients should never decode, parse, trim, re-encode, or substitute them. The cursor format is an implementation detail of the server, and OpenFX reserves the right to change it at any time without notice (different encoding, different payload fields, different length). Code that decodes cursors will break the day the encoding changes. The contract is: the server returns a cursor string; the client passes that same string back verbatim on the next request via ?startingAfter= or ?endingBefore=. Round-trip it as opaque bytes.

Cursor direction visualized

The timeline below shows a list of trades with the newest at the top. Suppose pagination.nextCursor from a previous response is currently positioned at trade C (highlighted). Each cursor parameter selects a different window: The records the cursor refers to are not included in the result page — startingAfter returns records older than the cursor’s position; endingBefore returns records newer than it.

Worked example: fetch all trades (forward iteration)

Walk forward (newest → oldest) until hasNext is false. JWT TTL is 60s; a full back-fill across thousands of records can outlive it, so each of these signs per page rather than capturing one token — see Rate limiting → Handling rate limits pattern.

Worked example: walk backward from a known cursor

The mirror image of the forward walk: page from a starting point toward the newest record, stopping when hasPrev is false (equivalently, when prevCursor is null). Useful for catch-up loops that recover a gap older than your most recent sync.

Worked example: fetch “what’s new since last seen”

The typical sync-job pattern: you’ve persisted the most recent cursor from your last poll; on this poll you want only the new records that have appeared since.
For a continuous sync loop, persist newLastSeenCursor only after you’ve successfully handled the records in page.data. If your handler crashes between the API call and the DB write, the next poll re-fetches the same records. That’s safe, because your handler should be idempotent on resource ID.

Why cursor pagination

Page-based pagination (?page=2&limit=25) breaks down at scale:
  • New records appearing during pagination cause records to shift between pages (you see the same trade twice or miss one)
  • Deep pages get progressively slower (the database has to skip every prior row)
  • “How many pages total?” requires a separate COUNT(*) query
Cursors fix all three. The cursor encodes the row’s position; the server seeks directly to that position and reads the next page from there. The pagination envelope makes the cursor a first-class field of the response, so clients never have to guess where the cursor “is” — the server hands it back to you.

Common mistakes

  • Decoding or parsing the cursor. Cursors are opaque. Don’t decode them, don’t try to extract anything from them, don’t munge them. Pass the string back exactly as you received it. The format is an implementation detail and will change.
  • Picking the wrong cursor for the direction you want. For “load all” (forward), use pagination.nextCursor and pass it as ?startingAfter=. For “what’s new since” (backward), use pagination.prevCursor and pass it as ?endingBefore=. Mixing them sends you in the wrong direction.
  • Sorting the data array client-side and then deriving a cursor. The cursor is in the response envelope, not in the array. Read pagination.nextCursor / pagination.prevCursor directly; sort data for display second.
  • Re-deriving “is there more?” from the cursor when the boolean is right there. pagination.hasNext and pagination.hasPrev are the canonical end-of-list signals — branch on them. (nextCursor / prevCursor flip to null in lockstep, so a null-check works too, but the booleans read clearer.)
  • Setting both startingAfter and endingBefore. They are mutually exclusive. Pick a direction.
  • Treating limit as a guarantee. It’s a max. The server may return fewer records. Paginate until pagination.hasNext is false, not until data.length < limit.
  • Persisting cursors longer than the resource lives. Cursors point to a position in the underlying sort. If the record they point to is purged (rare on Trade / Trade-account resources at any meaningful timescale), paginating past it returns the next-newest record.

Gotchas

  • Cursors are stable across requests. A cursor you persisted yesterday continues to point to the same position in the list today (subject to record retention).
  • limit is a max, not a guarantee. The server may return fewer records than requested if filters apply.
  • nextCursor and prevCursor reflect the chosen direction independently. A page returned via ?startingAfter= still carries a prevCursor you can use to walk back if you need to. The envelope is symmetric — only the request parameter says which direction you’re moving.
  • Empty page on the boundary. Calling ?startingAfter=<cursor at oldest record> returns data: [] with pagination.hasNext: false and pagination.nextCursor: null — no error.

What’s next

Rate limiting

The back-off pattern for back-fill loops that hit RATE_LIMIT_EXCEEDED.

Idempotency

Make sync jobs safe across crashes and restarts.

List trades

Paginate trades via the canonical list endpoint.

Retrieve a trade

Fetch a single record by ID without paging.