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

type Pagination = {
  limit: number          // page size used for this response
  cursor?: string        // opaque token — pass back as `?cursor=…` to get the next page
  hasMore: boolean       // true when more rows exist
}
Where it appears (read mode):
type ListResponse<Row> = {
  data: Array<Row>     // the rows for this page — always under `data`
  pagination?: Pagination
}

Endpoints that paginate

Every list endpoint accepts limit (1–100) + cursor and returns the pagination envelope. The default limit is 100 except where noted.
EndpointDefault limitMax limitNotes
POST /v1/contacts/search50100The contact read. Filterable + sortable (search, sort, order, logic, filters, audienceId) in the JSON body.
GET /v1/fields50100Custom contact-field definitions for the brand.
GET /v1/automations/runs25100Filterable on automationId, triggerEventId, status, from, to. Logs are attached per run.
GET /v1/audiences100100Saved audiences for the brand.
GET /v1/domains100100All sending domains; ?sendableOnly=true for verified-only.
GET /v1/emails100100Latest version per emailId. Filterable on status, createdAtFrom/To, updatedAtFrom/To.
GET /v1/templates100100Public template catalog; each row is a full template (html + previewImage + title/category/brand). Filterable on brand, category, semantic.
GET /v1/automations/triggers100100Integration-provisioned + API-created triggers.
GET /v1/automations100100Latest version per automationId; lean by default (?automationId= + ?include=graph,versions for one).
GET /v1/analytics/sends100100Campaign sends + stats. Filterable on status, emailId, from, to; ?sendId= + ?include=events for one.
GET /v1/analytics/campaigns100100Lifetime per-campaign KPIs.
GET /v1/analytics/events50100Unified event explorer. Filterable on recipientEmail, eventType, automationId, from, to.
GET /v1/analytics/trigger-instances100100Fired-trigger audit log. Filterable on triggerEventId, from, to.

Single-resource reads (identity in the query)

Reads are flat: there is no separate get-one path. To fetch one row, pass its id key to the resource’s list endpoint and read data[0]. The response is the same { data, pagination } envelope as the list — just scoped to one row. ?include= opt-ins embed the heavier detail:
Readinclude opt-ins
GET /v1/emails?emailId=eml_xxx (carries previewImage)html, versions
GET /v1/audiences?audienceId=aud_xxxcount
GET /v1/automations?automationId=auto_xxxgraph, versions
GET /v1/automations/runs?automationRunId=run_xxxlogs
GET /v1/automations/triggers?triggerEventId=tri_xxx
GET /v1/analytics/sends?sendId=snd_xxxevents
GET /v1/analytics/trigger-instances?triggerInstanceId=tin_xxx
GET /v1/domains?domainId=dom_xxx
When the id misses, you get an empty data array (and 404 <RESOURCE>_NOT_FOUND only on the path-based write endpoints, e.g. PATCH /v1/automations/{automationId}). The contact read is POST /v1/contacts/search — look one address up with a { field: 'email', operator: 'equals' } filter.

Canonical iteration loop

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):
async function* paginateAllContacts() {
  let cursor: string | undefined
  while (true) {
    const res = await fetch('https://brew.new/api/v1/contacts/search', {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${process.env.BREW_API_KEY!}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ limit: 100, ...(cursor ? { cursor } : {}) }),
    })
    if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`)
    const body = (await res.json()) as {
      data: Array<{ email: string }>
      pagination: { limit: number; cursor?: string; hasMore: boolean }
    }

    for (const contact of body.data) yield contact
    if (!body.pagination.hasMore) return
    cursor = body.pagination.cursor
  }
}

for await (const contact of paginateAllContacts()) {
  console.log(contact.email)
}

SDK pagination (TypeScript)

The official @brew.new/sdk returns the raw { data, pagination } shape — pass the cursor back on the next call:
import { createBrewClient } from '@brew.new/sdk'

const brew = createBrewClient({ apiKey: process.env.BREW_API_KEY! })

let cursor: string | undefined
do {
  const page = await brew.contacts.search({ limit: 100, cursor })
  for (const contact of page.data) {
    console.log(contact.email)
  }
  cursor = page.pagination?.hasMore ? page.pagination.cursor : undefined
} while (cursor)
For automation runs, swap brew.contacts.searchbrew.automations.runs.list with the same shape (runs use a GET list, so its filters are query params).

Filter combinations

POST /v1/contacts/search takes search + sort + filter in one JSON body:
POST /v1/contacts/search
{ "search": "jane", "sort": "createdAt", "order": "desc", "limit": 50 }

POST /v1/contacts/search
{ "logic": "and", "filters": [{ "field": "plan", "operator": "equals", "value": "pro" }], "limit": 100 }
GET /v1/automations/runs supports time-range and status filters:
GET /v1/automations/runs?automationId=auto_abc&status=completed&from=2026-04-01T00:00:00.000Z&to=2026-04-08T00:00:00.000Z&limit=100
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 synthesise them. The format may change between releases.
  • Stable within a page. A cursor returned on page N points to “the next batch of rows that existed when N was rendered”. New rows inserted concurrently may show up; deleted rows may be skipped. This is fine for analytics / bulk export; if you need strict snapshot reads, freeze a time bound with ?from=&to=.
  • 24-hour TTL. Cursors don’t expire on a strict clock today, but treat them as if they’re good for ~24h — re-start with no cursor if a job pauses overnight.

See also

  • 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).
  • Errors404 on a get-one lookup; 400 INVALID_REQUEST on bad cursors.

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.

ChatGPT/Claude Integration

Click “Open in ChatGPT” at the top right of any page to analyze documentation with ChatGPT or Claude for deeper insights.