Skip to main content
Brew list endpoints use opaque cursor pagination. Every paginated response carries a pagination: { limit, cursor, hasMore } envelope so callers can iterate without page-counting math.

The Pagination Envelope

Where it appears (read mode):

Endpoints That Paginate

Every list endpoint accepts limit (1-100) + cursor and returns the pagination envelope. The default limit is 100 except where noted. These are the HTTP limits. The MCP tools read smaller pages; see MCP page sizes.

MCP Page Sizes

An MCP tool result must fit the host’s response budget. A list page that doesn’t fit is refused whole with RESPONSE_TOO_LARGE: no rows and no cursor, so repeating the call with the same cursor and a smaller limit skips nothing. The MCP tools therefore page in smaller steps than HTTP: A contact carries every custom field it has, so a search_contacts page is as wide as your brand’s field list. Raise limit toward 25 only when your contacts carry few custom fields. list_insights is the one list that trims instead of refusing. It shortens long text on each finding and cuts the report, suggestions, and memo where its budget ends. A truncated array names each cut section as { section, shown, total }: items shown of the total for report and suggestions, and characters of its markdown for memo. Every finding on the page stays, so the cursor still continues after the last one. The HTTP read always answers whole.

Single-Resource Reads (Identity in the Path)

Every collection has a real detail read at /{collection}/{id}. It returns the bare row, not a list envelope, so there is no data[0] unwrap and no pagination object. ?include= opt-ins embed the heavier detail:
Passing an id as a list filter is retired. GET /v1/emails?emailId=, ?audienceId=, ?automationId=, ?automationRunId=, ?triggerEventId=, ?sendId=, ?triggerInstanceId= and ?domainId= are no longer accepted query keys and return 400 INVALID_REQUEST with param naming the key. Use the path read above. The one place an id still filters a list is a deliberate join: GET /v1/sends accepts automationId, automationRunId, audienceRunId or triggerInstanceId to list the per-recipient sends that run produced, and GET /v1/automations/runs accepts automationId, triggerEventId or triggerInstanceId for the same reason.
When the id misses, each detail read returns a resource-specific 404: EMAIL_NOT_FOUND, AUDIENCE_NOT_FOUND, SEND_NOT_FOUND, DOMAIN_NOT_FOUND, AUTOMATION_RUN_NOT_FOUND, TRIGGER_INSTANCE_NOT_FOUND, CONTACT_NOT_FOUND, FLOW_NOT_FOUND, INSIGHT_NOT_FOUND. Check the endpoint in the generated reference for its exact code, and see Errors for the envelope.

Canonical Iteration Loop

This is the standard cursor pattern. The contact read is a POST with a JSON body, so the cursor rides in the body (GET lists put cursor in the query instead):

SDK Pagination (TypeScript)

The official @brew.new/sdk returns the raw { data, pagination } shape. Pass the cursor back on the next call:
For automation runs, swap brew.contacts.search → brew.automations.runs.list with the same shape (runs use a GET list, so its filters are query params). To read one run, call brew.automations.runs.get(automationRunId) instead: a detail read is not a one-row page.

Filter Combinations

POST /v1/contacts/search takes search + sort + filter in one JSON body:
GET /v1/automations/runs supports time-range and status filters:
See the per-endpoint pages under Public API v1 for the full filter parameter table.

Cursor Semantics

  • Opaque. Cursors are server-generated tokens. Don’t parse them; don’t synthesize them. The format may change between releases.
  • Safe to change the list while you walk it. A cursor names the last row it returned, not a position, and the next page is the rows that sort after it. Deleting the rows you have read (for example, every audience that matches a search, page by page) skips nothing, and a row created during the walk is never returned twice. A row created where the walk has already passed is not returned; start a new walk to see it.
  • Sort by a field the walk doesn’t change. Lists ordered by updatedAt (emails by default, automations, triggers) move a row to the front when it is edited, so a row edited ahead of the cursor during your walk moves behind it. For a walk that edits what it reads, page emails with sortBy=createdAt, or collect the ids first and then act on them. Comment threads order by their latest message the same way, so a reply during the walk moves its thread to the front.
  • Catalogs page by position. GET /v1/fields, GET /v1/flows, GET /v1/templates and GET /v1/integrations page by offset. Deleting custom fields mid-walk shifts their pages, so collect first, then act.
  • Insights refuse a stale cursor. GET /v1/insights ranks its findings again on every request, and its cursor remembers which findings it already returned. A change below the cursor is served when its page comes. A finding added or removed above the cursor, or re-ranked across it, makes the next page 400 INVALID_REQUEST with param: "cursor" rather than skip or repeat findings, so read again from the first page without cursor, with the same state and severity. A walk that reaches its last page has returned every finding exactly once, as the list stands then. A cursor sent with a different state or severity is refused the same way.
  • A short page is not the last page. GET /v1/notifications reads a bounded slice of stored rows per page and leaves out the rows your key can’t see, so a page can hold fewer rows than limit, even none, while hasMore is true. Stop when hasMore is false, never because a page came back short. A type filter your credential can’t see answers an empty page rather than an error.
  • A comment thread pages its own messages. With include=messages, a thread whose older messages didn’t fit carries a messagesCursor. Send it back with that thread’s commentId (not with cursor) for the next older slice, until it comes back null. See Comments.
  • Brand images page two ways. Browsing GET /v1/brand/images without q continues after the last asset the previous page returned, so uploads and deletes during a walk don’t repeat or skip an asset. A browse cursor keeps its sort and kind: sending it with a different one is a 400 INVALID_REQUEST. A search with q pages its ranked results by position, and its cursor is signed: it only continues the same q and kind, and any other pair is a 400 INVALID_REQUEST.
  • 24-hour TTL. Cursors don’t expire on a strict clock today, but treat them as if they’re good for ~24h, and re-start with no cursor if a job pauses overnight.
  • Rate limits: a tight pagination loop can burn through 100/min quickly; consider parallelizing across keys or honoring X-RateLimit-Remaining.
  • Batch operations: for writing lots of rows fast (POST /v1/contacts accepts up to 1000 rows per request).
  • Errors: 404 on a get-one lookup; 400 INVALID_REQUEST on bad cursors or a retired id filter.
  • Migrating to the cleaned-up v1 surface: every route, field, and status rename in one grep-able table.

Need Help?

Our team is ready to support you at every step of your journey with Brew. Choose the option that works best for you:

Search Documentation

Type in the “Ask any question” search bar at the top left to instantly find relevant documentation pages.

Ask ChatGPT or Claude

Click here to open the Copy page menu at the top of this page, then send the page to ChatGPT, Claude, or your editor.