Skip to main content
Brew’s two delivery primitives — campaign sends and automation runs — are asynchronous by design. The HTTP call accepts the work, persists an audit row, and returns a job identifier; the actual delivery happens on a durable Vercel Workflow runtime that retries, backs off, and emits per-step logs. This page covers the three async primitives, the canonical poll loop, and the agent-friendly recipes for concurrency.
Outbound webhooks for run / send / email-event lifecycle are on the roadmap. Until they ship, polling is the supported pattern. Both surfaces work today.

The three async primitives

PrimitiveEndpointResponsePoll with
Fire an automationPOST /v1/automations/triggers/{triggerEventId}/fire200 { success, status, details: { automationRunIds: [...] } } (legacy fire envelope)GET /v1/automations/runs?automationRunId=…&include=logs
Test an automationPOST /v1/automations/{automationId}/test202 { automationRunIds: [...], status: 'test_started' }Same as fire
Send an emailPOST /v1/sends202 { status, sendId, runId, scheduledAt? }GET /v1/analytics/sends?sendId=…
A POST /v1/emails (AI generation) is also long-running (~30–90s) but blocks the HTTP call until the agent renders the design or refuses. It is not a job-returning async — you get the artifact back in the response.

Fire a trigger, then poll the run

Fire returns a list of automationRunIds (one per matched Published automation). Each id is a workflow run you can inspect. If no bound automation is Published, the call returns 422 NO_PUBLISHED_AUTOMATION instead of 202 and there is nothing to poll — publish at least one bound automation and re-fire.
import { setTimeout as sleep } from 'node:timers/promises'

async function fireAndPoll() {
  // 1. Fire — returns 202 with the run ids (trigger id is in the PATH)
  const fire = await fetch('https://brew.new/api/v1/automations/triggers/tri_signup/fire', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.BREW_API_KEY!}`,
      'Content-Type': 'application/json',
      'Idempotency-Key': `signup-${userId}-${eventTimestamp}`,
    },
    body: JSON.stringify({
      payload: { email: 'jane@example.com', firstName: 'Jane' },
    }),
  })
  const fireBody = await fire.json() as {
    details: { automationRunIds: string[]; triggerInstanceId?: string }
  }
  const [runId] = fireBody.details.automationRunIds

  // 2. Poll the run until it terminates (logs are always attached)
  for (let attempt = 0; attempt < 60; attempt += 1) {
    const res = await fetch(
      `https://brew.new/api/v1/automations/runs/${runId}`,
      { headers: { Authorization: `Bearer ${process.env.BREW_API_KEY!}` } }
    )
    const body = await res.json() as {
      runs: Array<{
        automationRunId: string
        status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled'
      }>
      logs?: Array<{ nodeName: string; status: string; durationMs?: number }>
    }
    const run = body.runs[0]!
    if (run.status === 'completed' || run.status === 'failed' || run.status === 'cancelled') {
      console.log(`Run ${run.automationRunId}${run.status}`)
      for (const log of body.logs ?? []) {
        console.log(`  ${log.nodeName}: ${log.status} (${log.durationMs ?? '?'}ms)`)
      }
      return run
    }
    await sleep(2_000) // poll every 2 seconds
  }
  throw new Error('Run did not terminate within 2 minutes')
}
Latency expectationCadenceCap
Welcome / transactional flow (single sendEmail node)2s × 602 min
Multi-step drip with wait nodes (days)Don’t poll — read the run when you actually need the answer (e.g. nightly cron)n/a
QA / test fire1s × 3030s
The automation.runs.read policy is 100/min per key. A 2-second poll is well under that for a handful of concurrent agents.

Queue a send, then verify

POST /v1/sends returns 202 { status, sendId, runId, scheduledAt? }. The sendId keys the send row for analytics reads; the runId is the Vercel Workflow run id (correlated with delivery analytics on the dashboard). The body takes EITHER an audienceId OR an inline to (a single email or an array of ≤ 50).
const send = await brew.emails.send({
  emailId: 'eml_promo',
  emailVersionId: 'emv_promo_v2',
  domainId: 'dom_brand_primary',
  audienceId: 'aud_subscribers',
  subject: 'Black Friday — 40% off',
})

console.log(send)
// { status: 'queued', sendId: 'snd_…', runId: 'wrun_…' }
Poll the send row with GET /v1/analytics/sends?sendId=…; add &include=events to inline the per-recipient feed (delivered / opened / clicked / bounced) on the same row. For one-shot validations, the dashboard at brew.new/analytics is also a source of truth.

Idempotency on async retries

Because retries are guaranteed-safe with idempotency keys, your agent / cron loop can re-fire the same operation without double-delivery. Same key + same body → same automationRunIds[], same runId, same response payload. See Idempotency for the full contract.

Agent concurrency patterns

Pattern 1 — fan out, fan in

An agent that runs 50 different welcome flows in parallel:
const targets = await loadNewSignups()         // 50 users

const fires = await Promise.all(
  targets.map((user) =>
    brew.automations.triggers.fire(
      {
        triggerEventId: 'tri_signup',
        payload: { email: user.email, firstName: user.firstName },
      },
      { idempotencyKey: `signup-${user.id}-${user.signedUpAt}` }
    )
  )
)

// Each fire returns its own automationRunIds[]; collect for later inspection
const allRunIds = fires.flatMap((f) => f.automationRunIds)
The automation.runs.write policy is 60/min. For 50 parallel fires, you’re well under; for 200+ you’d batch with Promise.allSettled + a 429-aware retry helper.

Pattern 2 — long-poll a single run to terminal

Use a generator so the agent can yield other work between polls:
async function* watchRun(runId: string) {
  while (true) {
    const { data } = await brew.automations.runs.list({
      automationRunId: runId,
      include: 'logs',
    })
    const run = data[0]!
    yield run
    if (['completed', 'failed', 'cancelled'].includes(run.status)) return
    await new Promise((r) => setTimeout(r, 2_000))
  }
}

for await (const snapshot of watchRun(runId)) {
  await mySideEffect(snapshot)   // e.g. update a UI badge
}

Pattern 3 — bulk inspect by automation

For dashboards / agents that want “the last 100 runs of this automation”:
const { data: runs } = await brew.automations.runs.list({
  automationId: 'auto_abc',
  limit: 100,
})
const failed = runs.filter((r) => r.status === 'failed')

Why no webhooks today (and what’s next)

The fastest path to an A-grade Events & Streaming score is outbound webhooks; we’re shipping them. See the Webhooks & events page for the planned contract (webhook.run.completed, webhook.send.delivered, webhook.send.opened, …) and the roadmap. Until then, the poll loop on /v1/automations/runs is the supported way to wait for terminal status.

Endpoints that are already async-friendly

EndpointJob indicatorStatus read
POST /v1/automations/triggers/{triggerEventId}/firedetails.automationRunIds[] (one per matched automation)GET /v1/automations/runs?automationRunId=…
POST /v1/automations/{automationId}/testautomationRunIds[]Same
POST /v1/sendssendId + runId (Vercel Workflow run id)GET /v1/analytics/sends?sendId=…
POST /v1/contacts (batch)summary.{inserted,updated,failed} — synchronousn/a

See also

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.