5 min read·Run it here

Search Past Payments

Cursor pagination against a list API that returns newest first: walk on the last id, push the filters into the request, resume from a cursor, and keep the money in integer minor units.

Pattern
Cursor walk on the last id + filters pushed into the query
Time
O(matches / page size) round trips
Space
O(rows kept)

Almost every payments interview has a list call in it somewhere. It looks like the easy part of the question, and it is where most candidates leave a bug on the whiteboard, because a Stripe list returns newest first and the cursor is named starting_after. Those two facts fight each other in your head until you have said them out loud once.

The recipe

Say before you type: "Stripe lists come back newest first, so the cursor is the id of the last element of data. I loop while has_more."

let cursor: string | undefined;
while (true) {
  const query = { ...params, limit: pageSize };
  if (cursor !== undefined) query.starting_after = cursor;

  const page = await client.paymentIntents.list(query);
  out.push(...page.data);

  if (!page.has_more) break;
  cursor = page.data[page.data.length - 1].id;
}

Four lines carry the whole idea. Everything after this is a way to get one of them wrong.

data[0] is the row you already have

A list response is { object: "list", data, has_more }, and data is reverse chronological. So data[0] is the newest row on the page, the one nearest the top of the list you are walking away from. Pass that as starting_after and page two begins one row into page one: you re-read almost everything, the walk advances one row per round trip, and every id you collect shows up several times.

starting_after reads like "start after the beginning of this page" and means "resume after this object". It is the last element of data, and nothing else.

Two more things about the loop:

  • limit is a page size, not a result cap. It defaults to 10 and Stripe rejects anything over 100 with an invalid_request_error. Clamp it yourself and say the number.
  • The filters ride on every request, not just the first. starting_after is resolved against the filtered list, so a filter you drop halfway through is a cursor being resolved against a different list than the one you are reading.

Push the filter down or pay for the rows

const created = { ...(params.created ?? {}) };
if (created.lte === undefined) created.lte = Math.floor(nowMs / 1000);

Fetching everything and running .filter() over it is the version that passes a unit test and gets you rate limited in production. Anything the API can filter on belongs in the request: status, customer, created. The local filter is only for what the API cannot express.

Then the two numbers that share a name. The caller's limit is how many results they want. Stripe's limit is how many rows come back on one page, capped at 100. Ask for Math.min(remaining, 100) and stop the moment the caller is satisfied. A caller who wanted 3 should cost one request for 3 rows, not one request for 100.

Seconds, not milliseconds

created is in unix seconds. Date.now() is milliseconds. That is a factor of a thousand between a window covering last Tuesday and a window covering the next fifty thousand years.

The rule that keeps it straight: numbers that came from the caller are already seconds and go through untouched. The only conversion in the whole class is the one you apply to the clock, and it is Math.floor(nowMs / 1000). Skip the floor and you send a float, which is a different kind of invalid.

While you are there, pin the top of the window even when the caller did not ask for one. An open-ended lte means new payments keep landing in the result set while you page, and a row inserted at the top shifts every cursor behind it. Pinning lte to the moment the walk started makes the pagination reproducible, which is the entire reason anyone paginates instead of streaming.

The cursor you hand back

return {
  data,
  nextCursor: more && data.length > 0 ? data[data.length - 1].id : null,
};

nextCursor is the resume token for a Load more button. It is the last id you returned when has_more was still true, and null when the walk finished. Returning the last id unconditionally is the bug that gives your UI a Load more button that is never done.

Say the naming rule out loud while you write it: startingAfter is your API, starting_after is Stripe's. Do not spread the caller's params into the request to save a line. An unknown parameter is an invalid_request_error, not a key the API politely ignores, so { ...params } ships your camelCase straight into a 400.

The money stays an integer

Amounts are integer minor units. 1999 is nineteen dollars and ninety nine cents, and it is an integer for a reason.

total += intent.amount - refunded;

Divide by 100 on the way in and the sum stops being exact. Five real payments summed in dollars and multiplied back gives 4305.999999999999 where the honest answer is 4306. Nobody notices until finance reconciles, and then everybody notices. Convert at presentation or not at all.

Refunds are the other half. They do not live on the PaymentIntent: they live on the charge, which means expand: ["data.latest_charge"] on the list call. Without it latest_charge is a bare id string, latest_charge.amount_refunded is undefined, and your total is NaN if you are lucky and silently too large if you defaulted it to zero.

And a total is over everything that matched. limit and a resume cursor are paging concerns; a total over one page is not a total.

What the interviewer is listening for

They are not checking whether you can write a while loop. They are checking three sentences:

  • "Newest first, so the cursor is the last id."
  • "Filter in the request, not after it, and only ask for as many rows as the caller wants."
  • "Seconds not milliseconds, and cents not dollars."

Each one is a bug they have personally shipped. Say them before you are asked.