How it works
- Request the first page. Pass
limit(1 to 100, default 50) and any filters. - The response is
{"data": [...], "has_more": true | false}. - If
has_moreistrue, request the next page with the same filters plusstarting_afterset to theidof the last item indata. - Stop when
has_moreisfalse.
starting_after=8b3a4e72-1c2d-4e5f-9a0b-2c3d4e5f6a7b.
The rules
- Newest first. Items are ordered by
created_atdescending, then byiddescending for items created in the same microsecond. The order is total: two requests never order the same items differently. - The cursor is an ID.
starting_afteris theidof an item you already have, not a page number or an opaque token. You can store it, log it and read it. It stays valid if you change filters: the next page starts after that item’s position, whether or not the item itself matches the new filters. - Pages don’t shift. Each page starts from the cursor item’s position, not from a count of rows. Items created while you page through can’t push an item onto two pages or make one disappear. New items appear at the front of the list, so a loop that started before they arrived doesn’t see them; run it again with
created_afterto pick them up. has_moreis exact. When it’sfalsethere is nothing after the last item. A page can be shorter thanlimitonly when it’s the last one, and an emptydataalways comes withhas_more: false.limitis strict. Outside 1–100 is422 validation_error, not rounded to the nearest valid value.- Unknown parameters are refused. A query parameter the endpoint doesn’t know, or one given twice, returns
422 validation_errornaming it. This is stricter than request bodies on purpose: a misspelt cursor such asstartingAfter, quietly ignored, would return the first page forever and your loop would never end. - An unknown cursor is an error. A
starting_afterthat isn’t one of your items’ IDs returns422 invalid_cursor. Another account’s ID is answered the same way, and so is a contact deleted after you received it: start again from the first page. - Time filters are exclusive and need a time zone (on lists that have them, such as List messages).
created_afterandcreated_beforeexclude the exact time given. Write times as ISO 8601 withZ(2026-11-10T09:30:00Z);created_atvalues come back in that form, so you can pass them back unchanged. In a URL, an unencoded+(as in+00:00) reads as a space, so encode it as%2Bor useZ. - Filters are applied before paging. A rare filter value still returns every match across pages. A filter that matches nothing returns an empty page, not
404.
Read every page
The loop below fetches page after page untilhas_more is false, and yields items one at a time. Pass any filters the endpoint takes. If it goes over the list rate limit, it waits as long as Retry-After says and asks for the same page again.
- Keep the filters the same on every page, and change only
starting_after. The loops above do this by building each request from the samefilters. - Fetch pages one at a time. Each page needs the last ID of the one before it, so pages can’t be fetched in parallel.
- A list request is safe to retry. On a network error, a timeout or a
5xx, request the same page again with the samestarting_after. Listing never changes anything. Don’t retry a4xx: fix the request. The one exception is429 rate_limited: your account made more than 120 list requests in 60 seconds. Wait the number of seconds in theRetry-Afterheader, then send the same request again. The refused request didn’t count, and the cursor is still valid. See Rate limits. - One budget per account. Every API key on your account and every list endpoint share the 120 requests a minute. Two loops running side by side each get about half.
- Big ranges. Narrow the range with
created_after/created_beforerather than reading your whole history on every run. The reconciliation recipe shows how to keep a checkpoint.
Next
List messages
Every filter and the response shape.
List contacts
Search, tag, opt-out and block filters.
Recover from downtime
Catch up on what your webhook endpoint missed.

