The Future Demand API uses page + limit pagination, 1-indexed, with a standard envelope. There is no cursor pagination.

The envelope

{
  "total":    423,
  "limit":     50,
  "page":       1,
  "pages":      9,
  "nextPage":   2,
  "prevPage":  null,
  "items":  [ ... ]
}
FieldMeaning
totalTotal number of items matching the filter (across all pages).
limitItems per page.
pageCurrent page, 1-based.
pagesTotal page count.
nextPage / prevPageConvenience — null at the edges.
itemsThe data.
Endpoints that don’t paginate return the array directly (or with a different envelope — /messages/totalunread, /events/statistics, etc.).

Common query params

ParamDefaultNotes
page11-based, not 0-based.
limit50Configurable per endpoint. The reference webapp reads REACT_APP_PAGE_LIMIT. Cap your client at 50 unless an endpoint documents a higher max.
Other filter params are per-endpoint — see the specific guide.

Array params — use the repeat style

?city=Berlin&city=Munich&frontend_status=DEFAULT&frontend_status=FLAGGED
Not city[]=Berlin and not city=Berlin,Munich. The backend rejects both. Use qs.stringify(params, { arrayFormat: "repeat" }) (or equivalent).
import qs from "qs";

const url = `/events/?${qs.stringify({
  page:  1,
  limit: 50,
  city:           ["Berlin", "Munich"],
  frontend_status: ["DEFAULT", "FLAGGED"],
}, { arrayFormat: "repeat" })}`;
// → /events/?page=1&limit=50&city=Berlin&city=Munich&frontend_status=DEFAULT&frontend_status=FLAGGED

Strip empty values

function clean(params: Record<string, unknown>) {
  return Object.fromEntries(
    Object.entries(params).filter(([_, v]) =>
      v != null
      && v !== ""
      && !(Array.isArray(v) && v.length === 0)
    )
  );
}

const url = `/events/?${qs.stringify(clean(params), { arrayFormat: "repeat" })}`;
Sending empty strings pollutes the URL and occasionally causes spurious “no results” responses on filter-strict endpoints. Always clean first.

Pattern: paginated table

function PaginatedTable<T>({ endpoint, filters }: { endpoint: string; filters: any }) {
  const [page, setPage] = useState(1);
  const list = useFd<List<T>>(`${endpoint}?${qs.stringify({ ...filters, page, limit: 50 }, { arrayFormat: "repeat" })}`);

  return (
    <>
      <Table rows={list.data?.items ?? []} />
      <Pagination
        page={list.data?.page}
        pages={list.data?.pages}
        onPage={setPage}
      />
    </>
  );
}
Reset page to 1 on any filter change. If you forget, the user sees “results 401-450 of 12” — confusing.

Pattern: infinite scroll

function InfiniteEvents({ filters }: { filters: any }) {
  const [pages, setPages] = useState<Event[][]>([]);
  const [pageNum, setPageNum] = useState(1);
  const [done, setDone] = useState(false);

  useEffect(() => {
    setPages([]); setPageNum(1); setDone(false);
  }, [filters]);

  useEffect(() => {
    if (done) return;
    let cancelled = false;
    fetchEvents({ ...filters, page: pageNum, limit: 50 }).then(resp => {
      if (cancelled) return;
      setPages(p => [...p, resp.items]);
      if (pageNum >= resp.pages) setDone(true);
    });
    return () => { cancelled = true; };
  }, [filters, pageNum, done]);

  const flat = pages.flat();
  return (
    <>
      <Table rows={flat} />
      {!done && <SentinelRow onVisible={() => setPageNum(n => n + 1)} />}
    </>
  );
}
Two pieces matter for UX:
  1. Reset on filter change. Without it, switching filter shows the new data appended after the old.
  2. Distinguish first-page-loading from page-N-loading — show a full-table skeleton on page 1, a row-level spinner thereafter.

Cancellation on filter change

Use an AbortController to cancel the in-flight request when filters change. The cancelled AbortError should be silently swallowed — it’s expected behaviour.
const controllerRef = useRef<AbortController | null>(null);

function refetch(filters) {
  controllerRef.current?.abort();
  controllerRef.current = new AbortController();
  fetchEvents(filters, controllerRef.current.signal)
    .then(setData)
    .catch(e => {
      if (e.name === "AbortError") return;
      surfaceError(e);
    });
}
The reference webapp uses axios.CancelToken (now deprecated in axios v1+); prefer AbortController in new code.

Total counts on the server

The total field is computed on every request. If your UI doesn’t show totals, you can still ask for limit=1 to get a count cheaply:
GET /events/?limit=1
# → { total: 423, items: [...one item...] }
But there is no count-only endpoint.

Caveats

/events/, /events/flagged/, /notifications/, /messages/partner/ all keep their trailing slash. Without it some deployments return 404.
Don’t ask for limit=10000. The default of 50 is almost always right. Specific endpoints document their max — e.g. /events/{eid}/series?limit=1000.
If you’re walking a large list while items are being inserted, you may skip or repeat items between pages. For “give me everything” use cases, snapshot the criteria with a since / until window.