# Brew Public API v1 — Agent Operations Guide > You are reading `https://brew.new/api/v1/llms.txt`. This file is the > machine-discoverable agent guide for the Brew Public API. It pairs > with the OpenAPI spec at > `https://brew.new/openapi.json` (also `https://brew.new/api/openapi.yaml`), a machine-readable JSON > catalog at `GET /v1/help` (no auth — scopes, credits, rate limits, > and the full endpoint list), and the full human-facing reference at > `https://docs.brew.new`. --- ## TL;DR — the lifecycle ``` design ──► send ──► analyze ──► automate 1. DESIGN POST /v1/emails { prompt } → { emailId, emailVersionId, html } 2. SEND POST /v1/sends { emailId, domainId, subject, audienceId | to } POST /v1/sends { transactionId, to, payload? } (transactional object) POST /v1/sends { test: true, emailId, subject, to } (one-off QA) 3. ANALYZE GET /v1/analytics/sends?sendId= → status + stats GET /v1/analytics/sends?sendId=&include=events → per-recipient feed GET /v1/analytics/campaigns → per-send lifetime KPIs 4. AUTOMATE POST /v1/automations/triggers → mint a trigger contract POST /v1/automations { nodes, connections } PATCH /v1/automations/{automationId} { published: true } POST /v1/automations/triggers/{triggerEventId}/fire { payload } GET /v1/automations/runs?automationRunId=&include=logs → run + per-node logs ``` Emails are pure DESIGNS (no send state, no type). A design can be sent unlimited times — each send is its own `sendId`, the unit of delivery + analytics. `POST /v1/sends` targets EITHER a saved `audienceId` (or the reserved `audienceId: "all"` for EVERY contact in the brand) OR inline `to` addresses (≤ 50); per-recipient event-driven delivery is an automation fire. --- ## 0. Index of authoritative resources - HTTPS base URL: `https://brew.new/api` - OpenAPI spec (well-known JSON): `https://brew.new/openapi.json` - OpenAPI spec (YAML): `https://brew.new/api/openapi.yaml` - OpenAPI spec (generated artifact): `https://brew.new/openapi/public-api-v1.yaml` - Mintlify docs (humans + agents): `https://docs.brew.new` - API introduction: `https://docs.brew.new/api-reference/api/api-introduction` - Endpoint reference (generated): `https://docs.brew.new/api-reference/public-v1` - SDK (npm, TypeScript): `@brew.new/sdk` (latest `8.x`) - SDK quickstart: `https://docs.brew.new/sdks/typescript/quickstart` --- ## 1. Conventions (read once, applies everywhere) - **Reads carry identity in the QUERY; writes carry it in the PATH.** - **Reads are FLAT.** One read endpoint per resource. Omit the id key to LIST (`GET /v1/emails`); pass `?=` to fetch ONE (`GET /v1/emails?emailId=`, `GET /v1/analytics/sends?sendId=`). There are NO get-one paths (`/v1/emails/{emailId}` for reading is gone). - **Writes are PATH-based.** Mutating one resource is `PATCH`/`DELETE /v1//{id}` (`PATCH /v1/emails/{emailId}`). URL-encode path values (`/v1/contacts/jane%40example.com`). - **Detail envelope.** A read with the id key (`?=`) returns `{ data: [row] }` — a one-element array, NO `pagination`. List mode (no id key) returns `{ data, pagination }`. Unknown / cross-brand id → `404`. - **Relationships + sub-reads fold into `?include=`.** Detail-only expansions: `GET /v1/emails?emailId=&include=html,versions`, `GET /v1/analytics/sends?sendId=&include=events`, `GET /v1/automations?automationId=&include=graph,versions`, `GET /v1/automations/runs?automationRunId=&include=logs`, `GET /v1/audiences?audienceId=&include=count,build`. `?include=` is REJECTED with `400` when the id key is absent (the N+1 guard). - **Query params = pagination + filters** (`?status=`, `?from=`, `?to=`, `?limit=`, `?cursor=`) plus the read identity (`?=`) and `?include=`. - **Actions are explicit sub-paths** (writes) — `POST /v1/automations/{automationId}/test`, `POST /v1/domains/{domainId}/verify`, `POST /v1/automations/triggers/{triggerEventId}/fire`. A write MAY use a body discriminator (`POST /v1/sends { test: true }` is a one-off QA send). Lifecycle STATE changes are a `PATCH … { published }` attribute, not an action. - **List envelope.** Every list returns `{ data: Row[], pagination: { limit, cursor: string | null, hasMore } }`. `cursor` is string-or-null, never omitted. Page through with: ```ts let cursor: string | null = null do { const page = await fetchPage({ cursor }) // ?cursor=… when non-null rows.push(...page.data) cursor = page.pagination.cursor } while (cursor !== null) ``` - **Writes return the bare resource.** No wrapper arrays. Creates return `201`. Async accepts: campaign send `202`, automation test `202`. Test send (`POST /v1/sends { test: true }`) `200` (synchronous). - **Deletes return `{ , deleted: boolean }`** and are idempotent — deleting an already-gone resource resolves with `deleted: false`, not a 404. - **Strict bodies.** Unknown keys → `400 INVALID_REQUEST` with `error.param` naming the offender. - **Brand scope.** A key is either BRAND-scoped (pins exactly ONE brand — name nothing) or ORGANIZATION-scoped (name the brand on every brand-scoped call with the `X-Brand-Id: ` HEADER — omitting it is `400 BRAND_ID_REQUIRED`, there is NO default brand). On BOTH kinds, never send `brandId` in a body / query (`400`) — the header is the only way to name a brand. Cross-brand ids surface as `404`. `GET /v1/brands` lists what your key can reach. - **Errors** are always `{ error: { code, type, message, param?, suggestion, docs } }` — branch on the stable `code`, never on `message`. - **ONE legacy exception.** `POST /v1/automations/triggers/{triggerEventId}/fire` responds with the legacy fire envelope `{ success, status, code, message, receivedAt, details }` (shared with internal webhook infrastructure) — see §7. --- ## 2. Endpoints (full surface) Every endpoint is `https://brew.new/api`. JSON content type. ### Emails — designs and sending A design is sent with `POST /v1/sends` — to a saved audience, an inline list, or a single address (campaign, `202`), or as a one-off QA test with `{ test: true }` (synchronous, `200`). Sending is not campaign-specific. Send READS live under Analytics (`GET /v1/analytics/sends`). | Method | Path | Purpose | | -------- | --------------------------------- | ---------------------------------------------------------------------------------------- | | `POST` | `/v1/emails` | Generate a design from `{ prompt, contentUrls?, referenceEmailId?, subjectLine?, targetGroupId? \| targetGroupName? }` → `201 { emailId, emailVersionId, html, previewImage?, group, subjectLine? }` (or `200 { response }` when the agent answered in prose). `subjectLine` sets the design-default inbox subject (`POST /v1/sends` still takes an explicit per-send `subject`). `targetGroupId` (`grp_…` or `ungrouped`) and `targetGroupName` (resolve-or-create) are mutually exclusive; omit to land Ungrouped. | | `POST` | `/v1/emails/import` | Import existing markup as a new EDITABLE design: `{ format: 'html'\|'mjml'\|'jsx', content, title?, subjectLine?, baseUrl? }` → `201 { emailId, emailVersionId, html, previewImage?, assetReport, subjectLine? }`. Brew attempts to re-host every discoverable safe public resource, retains a validated public URL with a warning when re-hosting fails, and strips private/malformed/blocked references. A lossy editor round trip returns `422` and persists nothing. **Free** (deterministic compiler; no model or credits). | | `POST` | `/v1/emails/figma` | Deterministically convert a connected Figma frame: `{ figmaUrl, title?, subjectLine?, format?: 'jsx'\|'html' }` → `201 { emailId, emailVersionId, title, format, content, warningCount, exportedNodeCount, previewImage?, subjectLine? }`. Requires the API-key brand's Figma integration. **Free** (no model or credits). | | `GET` | `/v1/emails` | Unified read. LIST designs (filters `status`, `groupId` (`grp_…` or `ungrouped`), `createdAtFrom/To`, `updatedAtFrom/To`) — or `?emailId=` → `{ data:[row] }`. Each row carries `group: { groupId, groupName } \| null` (null = Ungrouped). Detail rows carry `subjectLine` (design-default inbox subject) and `previewText` — the design's inbox preview line, read from its JSX `` (imported designs: their source-HTML preheader; what a send delivers unless overridden). Detail-only `?include=html` (rendered current-latest HTML) and/or `?include=versions` (lean `{ version, emailVersionId }` refs). `previewImage` is on the row — no separate preview render (on-demand render = `POST /v1/content/html-to-png`). | | `GET` | `/v1/email-groups` | Unified read. LIST folders under `{ data, pagination }` (Ungrouped always included as `{ groupId: "ungrouped", groupName: "Ungrouped", emailCount }`) — or `?groupId=` → `{ data:[row] }` (`404 EMAIL_GROUP_NOT_FOUND`). Named groups use `grp_*`. | | `POST` | `/v1/email-groups` | Create a named folder `{ name }` → `201 { groupId, groupName, emailCount: 0 }`. Reserved Ungrouped names are `400`; duplicate names are `409 EMAIL_GROUP_NAME_CONFLICT`. | | `PATCH` | `/v1/email-groups/{groupId}` | Rename `{ name }`. Ungrouped cannot be renamed (`400`). Unknown / cross-brand ids are `404`. | | `DELETE` | `/v1/email-groups/{groupId}` | Delete a named folder; member emails move to Ungrouped → `{ groupId, deleted }`. Idempotent. Ungrouped cannot be deleted (`400`). | | `PATCH` | `/v1/emails/{emailId}` | `{ prompt?, emailVersionId?, contentUrls?, subjectLine? }` — at least one of `prompt` / `subjectLine`. A `prompt` runs an AI edit → new latest version (usage-metered). `subjectLine` alone is a deterministic in-place envelope patch — no AI run, no new version, **free**. `emailVersionId` requires `prompt`. | | `DELETE` | `/v1/emails/{emailId}` | Delete every version → `{ emailId, deleted }`. | | `POST` | `/v1/emails/{emailId}/clone` | Exact independent copy. Optional `{ emailVersionId }` pins a source version; empty body clones latest. Deterministic byte-for-byte JSX/HTML copy with no AI generation, import conversion, or usage charge → `201 { emailId, emailVersionId, html, previewImage? }`. | | `POST` | `/v1/emails/{emailId}/restore` | `{ version: }` — clone a numbered version into a NEW latest. | | `POST` | `/v1/emails/{emailId}/accessibility-audit` | WCAG 2.1 audit of the rendered HTML → `{ score, summary, issues:[{rule,severity,message,wcag}] }`. **Fixed 5 credits, billed only on success** — if the audit can't complete it returns a retryable `503` and is NOT charged. | | `POST` | `/v1/emails/{emailId}/client-previews` | Render across real inboxes/devices (Gmail, Outlook, Apple Mail, iOS — with dark-mode variants — plus Yahoo). `{ clients?: string[] }` (omit → default spread) → `{ emailId, status, previews:[{id,label,category,os,dark,status,imageUrl}], pending }`. Bounded single call; slow clients return in `pending`. **Fixed 10 credits, billed only when ≥1 client renders** — a zero-preview batch returns a retryable `503` and is NOT charged. | | `POST` | `/v1/emails/{emailId}/inbox-placement-tests` | Inbox-placement (seed) test: send the design's latest version to a Mailgun seed list across real mailbox providers via a VERIFIED `domainId` `{ domainId, subject?, previewText?, emailVersionId?, providers? }` → `202 { testId, status: 'collecting', seedCount, results: null }`. Performs a real (small) seed send; results accrue over minutes — poll the GET below. **Fixed 10 credits, billed on the 2xx** (plus the send's own quota). | | `GET` | `/v1/emails/{emailId}/inbox-placement-tests` | Poll a seed test — `?testId=` → `{ testId, status, seedCount, results: { overall, byProvider:[{provider,inbox,spam,missing,pending,categories?,folders?,authentication?}], authentication, spamFilter?, microsoftFilter?, spoofingDetected?, headers? } \| null, diagnosis?: [{id,severity,provider?,summary,remediation}] }`. Re-poll every ~30s until terminal; omit `testId` to list the design's recent tests for variant comparison. **Free.** | | `POST` | `/v1/sends` | Send the design. Campaign: `{ emailId, emailVersionId?, domainId, subject, previewText?, replyTo?, audienceId \| to, scheduledAt? }` — target is exactly one of `audienceId` (a saved audience, or `"all"` for every contact in the brand) / `to` (inline email or array ≤ 50) → `202 { status: 'queued' \| 'scheduled', sendId, runId, scheduledAt? }`. Transactional object: `{ transactionId, to, payload? }` (optional envelope overrides; domain + design locked) → `202` `kind: transactional`. One-off QA: `{ test: true, emailId, emailVersionId?, subject, previewText?, to, replyTo?, domainId?, fromEmail?, senderName?, variables?, payload? }` → `200 { status: 'sent', recipient }` ([TEST]-prefixed subject; Brew default sender unless a verified `domainId` is supplied — an unverified/foreign domain is rejected, never downgraded; `variables` = example values for `{{ var \| fallback }}` merge tags, a supplied value wins over the fallback; `payload` = the same template data a live transactional fire takes, with live-fire parity — scalars resolve merge tags below `variables`, nested JSON renders via Liquid as `trigger.*` and 400s on a workspace without Liquid; no audience; no send row). | | `GET` | `/v1/transactional/{transactionId}` | Reusable transactional object (locked domain + design, envelope, merge-tag list). `?include=skill` adds a SKILL.md wiring brief. Fire with `POST /v1/sends { transactionId, to }`. `sends` scope. | | `POST` | `/v1/sends/{sendId}/cancel` | Cancel a scheduled/queued send before it goes out, or STOP any in-flight campaign (`sending`/`paused` — drops the remainder, already-sent ones stay sent) → `200 { sendId, status: 'canceled' }`. An in-flight stop is a tourniquet, not an undo: liveness is re-checked at regular points (smart per bucket, gradual per tranche, blast at bounded chunk intervals), so a small tail may still deliver after the cancel lands. Idempotent; `409 SEND_NOT_CANCELLABLE` once the send is `sent`/`failed` or for a non-campaign send; `404` unknown id. | | `POST` | `/v1/sends/{sendId}/pause` | Manually pause a `sending` gradual send → `200 { sendId, status: 'paused' }`. The workflow parks at its next gate and holds until resume/cancel. | | `POST` | `/v1/sends/{sendId}/resume` | Resume a manually paused gradual send. The unsent tail is re-spread and later batches shift so missed intervals never compress into a burst. | Gradual authoring shape: `gradualSend: { startingPercentage, incrementPercentage, interval: { value, unit: 'hour'|'day' }, timeZone }`. Defaults in the product are 10% start, +25%, every 1 day. Percentages allow one decimal; hours are 1–24, days 1–30. The resolved plan is capped at 50,000 recipients, 30 batches, and 30 elapsed days. The read model reports `currentTranche`; no safety threshold is accepted or returned. ### Analytics — read-only | Method | Path | Purpose | | ------ | ----------------------------- | ----------------------------------------------------------------------------------------------- | | `GET` | `/v1/analytics/overview` | Windowed brand overview — the same read as the app's /analytics cards + chart: `{ totals, rates, buckets, granularity, timeZone, range, truncated }` (default last 7 days). Filters `from`, `to`, plus any COMBINATION of: `source` (csv), `automationId` (csv ≤20), `emailId`, `audienceId` (csv ≤20), `triggerEventId` (csv ≤10 — integration trigger-events resolved to their wired automations), `domain` (sending domain), `recipient` (csv ≤10 of recipient rules — same grammar and name as `/v1/analytics/events`: a full address matches exactly, `@clay.com` the domain, other text a substring, `!` prefix excludes). They all compose — ask for "the welcome automation's spring-sale email sent from mail.acme.com" in ONE call rather than merging two (merging cannot reproduce the recipient-deduped opens/clicks). Moving one `recipient` string between this and `/v1/analytics/events` gives totals that describe exactly the rows that feed lists. A single filter reads a pre-aggregated rollup; any combination (or `domain`/`recipient`, which have no rollup dimension) aggregates raw events under a scan cap — check `truncated` on wide windows. `emails` scope. | | `GET` | `/v1/analytics/campaigns` | Lifetime KPIs per campaign SEND (one row per `sendId`). `emails` scope. | | `GET` | `/v1/analytics/automations` | Windowed per-automation performance + brand totals (default last 30 days; live runs only). Filters `from`, `to`, `automationId`, `limit`. Check `truncated`: when true, scan budgets clipped the requested window and the metrics are partial. `automations` scope. | | `GET` | `/v1/analytics/events` | Unified event feed across domains. Filters `from`, `to`, `recipientEmail` (one exact address), `eventType`, `automationId`, `sendId`, `messageClass` (`marketing|transactional` — same event object; absent stamps match as marketing). Recipient rules `recipient` (csv ≤10): full address = exact, `@domain` = domain, other text = substring, `!` prefix excludes (`recipient=@clay.com,!ceo@clay.com`); includes OR, excludes AND. Send-object facets `source` (csv), `audienceId` (csv ≤20), `emailId`, `domain`, `triggerEventId` (csv ≤10), `messageClass` — any facet or recipient rule narrows the feed to EMAIL events and enriches rows with `sendSource` + `sendContext` + `messageClass`. Machine/bot clicks AND opens are EXCLUDED by default; `includeMachineClicks=true` / `includeMachineOpens=true` restore the raw rows. Overview/insights collect transactional events like any other email event. `emails` scope. | | `GET` | `/v1/analytics/sends` | Unified send read. LIST sends (filters `status` (scheduled|queued|sending|paused|sent|failed|canceled), `kind` (campaign|transactional), `transactionId`, leftover `messageClass`, `from`, `to`, `?emailId=` for one design's sends) — default list is campaign-only. `?sendId=` → `{ data:[row] }`. Detail-only `?include=events` inlines the per-recipient event feed. Object-fired rows use `kind: transactional` and omit `messageClass`. `emails` scope. | | `GET` | `/v1/analytics/trigger-instances` | Unified fired-trigger read. LIST fired instances (filter `?triggerEventId=`) — or `?triggerInstanceId=` → `{ data:[row] }` (state, attempts, matched automations, started runs). `automations` scope. | ### Automations | Method | Path | Purpose | | -------- | ------------------------------------------- | -------------------------------------------------------------------------- | | `POST` | `/v1/automations` | Deterministic create `{ name, description?, triggerEventId?, nodes, connections, dryRun? }` → `201` bare row (or `200` dry-run report). Bind exactly one mode: top-level `triggerEventId` for event/integration delivery, OR a trigger node with `config: { mode: 'manualAudience', audienceId }` for explicit cohort runs. | | `GET` | `/v1/automations` | Unified read. LIST (lean rows — graph omitted) — or `?automationId=` → `{ data:[row] }` (lean by default). Detail-only `?include=graph` (full `nodes`/`connections`) and/or `?include=versions` (lean `{ version, automationVersionId }` refs). | | `PATCH` | `/v1/automations/{automationId}` | Update (`name` / `description` / `nodes` / `connections` / `triggerEventId`, `dryRun?`) OR lifecycle: `{ published: true }` go live (validates → `409`; `{ automationVersionId }` pins) / `{ published: false }` unpublish. The two modes are mutually exclusive. Draft writes use optimistic concurrency and may return `409 AUTOMATION_VERSION_CONFLICT`; re-read before retrying. | | `DELETE` | `/v1/automations/{automationId}` | Cascade delete (versions + runs + logs; designs survive) → `{ automationId, deleted }`. | | `POST` | `/v1/automations/{automationId}/test` | End-to-end test run through the real workflow (drafts OK; both trigger types; waits fast-forward) `{ payload?, testRecipient? }` → `202 { automationRunIds, status: 'test_started', … }`. `testRecipient` delivers each send node's email for real to that address (via the Brew test domain); omit for a silent dry-run. | ### Automation runs | Method | Path | Purpose | | ------ | -------------------------------------- | ------------------------------------------------------------------------------ | | `GET` | `/v1/automations/runs` | Unified run read. LIST runs (filters `automationId`, `triggerEventId`, `triggerInstanceId`, `recipientEmail`, `status`, `mode`, `from`, `to`) — or `?automationRunId=` → `{ data:[row] }`. Lean by default; detail-only `?include=logs` attaches the per-node execution `logs[]` (no longer always inlined). | ### Triggers — under automations | Method | Path | Purpose | | -------- | ----------------------------------------- | ----------------------------------------------------------------------------- | | `POST` | `/v1/automations/triggers` | Create `{ title, description?, payloadSchema }` → `201` bare row. Server hardcodes `provider: 'brew_api'`. | | `GET` | `/v1/automations/triggers` | Unified read. LIST every trigger (custom + integration-provisioned) — or `?triggerEventId=` → `{ data:[row] }` (`&include=skill` adds a SKILL.md wiring brief to the row). | | `PATCH` | `/v1/automations/triggers/{triggerEventId}` | Update `title` / `description` / `payloadSchema` (≥ 1 required). | | `DELETE` | `/v1/automations/triggers/{triggerEventId}` | Delete → `{ triggerEventId, deleted }`. `409 TRIGGER_HAS_DEPENDENT_AUTOMATIONS` while referenced. | | `POST` | `/v1/automations/triggers/{triggerEventId}/fire` | Fire `{ payload, idempotencyKey? }` → `200` legacy fire envelope (§7). Starts every published automation on the trigger. | The fired-trigger audit log (`GET /v1/analytics/trigger-instances`) lives under Analytics. ### Contacts + fields | Method | Path | Purpose | | -------- | ----------------------------- | ----------------------------------------------------------------------------------------- | | `POST` | `/v1/contacts` | Upsert single (`201`) OR batch `{ contacts: [...] }` ≤ 1000 (`200`, `207` partial). | | `POST` | `/v1/contacts/search` | The single "Get Contacts" read `{ search?, filters?, logic?, sort?, order?, audienceId?, count?, limit?, cursor? }` → `{ data, pagination }` or `{ count }`. LIST = no filters; by-email = `filters:[{ field:'email', operator:'equals', value:'…' }]`. Optional `audienceId` scopes to a saved audience's members (ANDed with `filters`; unknown id → `400`). | | `PATCH` | `/v1/contacts/{email}` | `{ fields: { : } }` → `{ contact, updated }` (URL-encode the email). | | `DELETE` | `/v1/contacts/{email}` | Delete → `{ email, deleted }`. | | `POST` | `/v1/contacts/validate` | Batch deliverability check `{ emails: [...] }` ≤ 100 → `{ data: [{ email, valid, status, reason?, didYouMean? }] }`. **2 credits PER ADDRESS** (`X-Credit-Cost` = 2 × count), billed only on success — a total provider outage returns a retryable `503` and is NOT charged. | | `POST` | `/v1/contacts/import-csv` | Bulk-import `{ csv, mapping? }` ≤ 1000 rows → `{ summary:{inserted,updated,failed,skipped}, … }` (`207` partial). | | `POST` | `/v1/contacts/batch-delete` | `{ "emails": [...] }` ≤ 1000 → `{ deleted: , notFound? }`. | | `GET` | `/v1/fields` | List field definitions (core + custom). | | `POST` | `/v1/fields` | Create `{ fieldName, fieldType: 'string'|'number'|'date'|'bool' }` → `201` bare row. | | `DELETE` | `/v1/fields/{fieldName}` | Delete a custom field → `{ fieldName, deleted }`. | ### Brands — organization-level lifecycle ORGANIZATION-LEVEL: these act on the org, not one brand, so they take **no** `X-Brand-Id` and a brand-scoped key may call them too (it simply sees only its own brand). `POST` requires the `brands` permission scope, which is deliberately NOT implied by `emails`. | Method | Path | Purpose | | -------- | ------------------------ | ---------------------------------------------------------------------------- | | `GET` | `/v1/brands` | List every brand this credential can reach → `{ data:[{ brandId, domain, status, progress?, phase? }] }`. Filter with `?status=`. THIS is how an organization-scoped credential discovers the ids it passes in `X-Brand-Id`. | | `GET` | `/v1/brands/{brandId}` | One brand → `{ brand }`. Poll this after `POST` until `status: 'completed'`. `404` (never `403`) for a brand outside your reach. | | `POST` | `/v1/brands` | Create from `{ url, instructions?, includePaths?, excludePaths?, excludeSubdomains? }` → `201 { brand:{ status:'extracting' }, extraction:{ chatId, statusUrl } }` + `Location`. ASYNC — extraction crawls the site (1–3 min). Requires an ORGANIZATION-scoped credential (`403 ORG_SCOPE_REQUIRED` otherwise) and the `brands` scope. **Do not** call `POST /v1/emails` until the brand reads `completed`, or you get `BRAND_NOT_READY`. | ### Audiences, domains, templates, misc | Method | Path | Purpose | | -------- | ---------------------------------------- | ---------------------------------------------------------------------------- | | `POST` | `/v1/audiences` | Create `{ name, filters }` → `201` bare row. | | `POST` | `/v1/audiences/from-events` | Create a frozen audience snapshot from `{ cohort: { eventTypes, from, to?, sendId?, emailId?, automationIds?, audienceIds?, recipient?, includeMachineClicks? }, name? }`. Every call generates its own hidden date field and stores each matching contact's latest event timestamp. Bot clicks are excluded by default. Returns `201` with `materializationStatus:'pending'` + `build`; the audience is not sendable until status is `ready`. Requires `contacts`. 409 `AUDIENCE_BUILD_ALREADY_ACTIVE` when the org's build slots are full. | | `GET` | `/v1/audiences` | Unified read. LIST audiences (the row `count` is a CACHED value, 0 until computed) — or `?audienceId=` → `{ data:[row] }`. Detail-only `?include=count` swaps `count` for the authoritative live member total (the send size); `?include=build` attaches the latest event-cohort build status. | | `PATCH` | `/v1/audiences/{audienceId}` | Update `name` / `filters` (≥ 1 required). | | `DELETE` | `/v1/audiences/{audienceId}` | Delete → `{ audienceId, deleted }`. | | `POST` | `/v1/audiences/{audienceId}/duplicate` | Deterministically copy the live filters → `201` bare new row; contacts remain shared. | | `POST` | `/v1/domains` | Add `{ name, sendingPurpose? }` → `201` row in `status: 'pending'` + DNS `records`. `sendingPurpose` is `marketing` (default) or `transactional`. | | `GET` | `/v1/domains` | Unified read. LIST all lifecycle states (`?sendableOnly=true` / `?sendingPurpose=`) — or `?domainId=` → `{ data:[row] }` (status, `sendable`, `sendingPurpose`, DNS `records`). | | `PATCH` | `/v1/domains/{domainId}` | Sender defaults and/or `sendingPurpose` (`defaultSenderName` / `defaultFromEmail` / `defaultReplyToEmail` / `sendingPurpose`). | | `DELETE` | `/v1/domains/{domainId}` | Delete → `{ domainId, deleted }`. | | `POST` | `/v1/domains/{domainId}/verify` | Re-check DNS. Empty body. Poll until `sendable: true`. | | `GET` | `/v1/domains/{domainId}/health` | Aggregate deliverability health → `{ verdict: 'healthy'\|'at_risk'\|'critical', signals:[{id,severity,summary,suggestion,action?}], authentication (incl. DMARC + the exact record when missing), warmup, dailyVolume, domainActivity, orgReputation, recentPlacementTests }`. **Free** — start here for deliverability questions. | | `GET` | `/v1/templates` | ORGANIZATION-WIDE (takes no `X-Brand-Id`). Public template gallery — FULL rows (`html`, `previewImage`, `title`, `category`, `brand`, `updatedAt`). Filters `brand`, `category`, `semantic`. | | `GET` | `/v1/brand` | `{ brand }` — the brand pinned to the key. Check `ready` before `POST /v1/emails`. Embed the design context with `?include=identity,emailDesign,imageStyle,logos` (comma-separated) → `{ brand, identity?, emailDesign?, imageStyle?, logos? }`. | | `PATCH` | `/v1/brand` | Partial update `{ identity?, emailDesign?, imageStyle? }` — `identity` shallow-merges; `emailDesign`/`imageStyle` replace the markdown design system. At least one required. | | `GET` | `/v1/brand/images` | Dual-mode → `{ data, pagination }`. `?q=` → SEMANTIC vector search over indexed assets (`?type` / `?aspectRatio` narrow) — **credit-metered** (op `brand.image.search`, fixed 1; `402 INSUFFICIENT_CREDITS` when out of credits). No `?q=` → BROWSE the stored harvested + generated library (free). Paginated, so kept separate from `?include`. | | `GET` | `/v1/llms.txt` | This guide. No auth. | | `GET` | `/v1/integrations` | Brand-scoped catalog + `connected`. Connect in Settings. No scope. | | `GET` | `/v1/usage` | ORGANIZATION-WIDE. Plan + credit + send quota. | | `GET` | `/v1/api-keys` | ORGANIZATION-LEVEL. Keys this credential may see. | | `POST` | `/v1/api-keys` | ORGANIZATION-LEVEL. Mint a key. Body `brandId` is the new key's binding. | | `DELETE` | `/v1/api-keys/{keyId}` | ORGANIZATION-LEVEL. Revoke → `{ keyId, revoked }`. | ### Chats — resume an in-app conversation | Method | Path | Purpose | | -------- | ---------------------------------------- | ---------------------------------------------------------------------------- | | `GET` | `/v1/chats/{chatId}` | Resume a Brew chat handed off from the app's "Open in <agent>" button. Brand-scoped digest: `title`, the emails + automations the chat created/referenced (latest version + preview), `triggerEventIds`, and a trimmed `recentMessages` transcript. Backs the `get_chat_context` tool. Read-only, free. `404 CHAT_NOT_FOUND` for an unknown OR cross-brand id. Automation/trigger fields require the `automations` scope. | --- ## 3. Authentication ``` Authorization: Bearer brew_your_api_key ``` Alternative header: `X-API-Key: brew_your_api_key`. Get a key at `https://brew.new/settings/api`. A key is either **brand-scoped** (bound to one brand — name nothing) or **organization-scoped** (reaches every brand in the org, and names one per call with the `X-Brand-Id: ` header; without it, brand-scoped routes return `400 BRAND_ID_REQUIRED`). **Never send `brandId`** in any body / query on either kind (returns `400 INVALID_REQUEST`), except `POST /v1/api-keys` where `brandId` is the **new key's binding**, not the request actor. Call `GET /v1/brands` to see what your key reaches. **Bootstrap.** `GET /v1/brand` → `{ brand }` returns the brand in scope — the key's own, or the one named by `X-Brand-Id` (check `ready` before `POST /v1/emails`); `GET /v1/usage` → `{ plan, credits, emailSends, period }` is the billing/quota surface. `GET /v1/health` → `{ status, version }` is public (no auth) liveness. Permission scopes (one of these must be present on the key): | Scope | Routes | | ------------- | -------------------------------------------------------------------------------------- | | `contacts` | `/v1/contacts*`, `/v1/fields*` — also satisfies `audiences` | | `emails` | `/v1/emails*`, `/v1/email-groups`, `/v1/content/*`, `/v1/templates*`, `/v1/brand`, `/v1/usage`, `/v1/analytics/overview`, `/v1/analytics/campaigns`, `/v1/analytics/events` — also satisfies `domains` + `sends` | | `automations` | `/v1/automations*` (incl. `/triggers*`, `/runs*`), `/v1/analytics/automations*`, `/v1/analytics/trigger-instances*` | | `audiences` | `/v1/audiences*` (least-privilege) | | `domains` | `/v1/domains*` (least-privilege) | | `sends` | `/v1/sends` (least-privilege) | | `all` | every endpoint | Scope model: the coarse scopes **imply** the granular ones that used to live under them — `contacts` ⊇ `audiences`; `emails` ⊇ `domains` + `sends`. Existing coarse-scoped keys keep working; mint least-privilege keys when you only need one resource. `transactional` is reserved (no v1 route). --- ## 4. Idempotency (REQUIRED on all retried writes) `Idempotency-Key: <≤100 chars>` HTTP header on every POST your code might retry. 24-hour window. Same key + same body → original cached response. Same key + different body → `409 IDEMPOTENCY_CONFLICT`. `POST /v1/automations/triggers/{triggerEventId}/fire` also accepts a body `idempotencyKey` field for legacy back-compat — prefer the header. Recommended key pattern for webhook-driven fires: ``` Idempotency-Key: -- ``` --- ## 5. Rate limits (per API key, per route, 60s window) Response headers on every rate-limited route: ``` X-RateLimit-Limit: X-RateLimit-Remaining: X-RateLimit-Reset: ``` `429 RATE_LIMITED` adds `Retry-After: `. ### Credits (credit-metered routes) Credit-incurring routes use one of TWO metering modes: - **Usage-metered** — `POST /v1/emails` (generate), `PATCH /v1/emails/{emailId}` (AI `prompt` edit — a `subjectLine`-only PATCH is a deterministic envelope patch and is free), and `POST /v1/content/generate-image` charge the ACTUAL model usage (token input/output for the email agent; image gateway cost). There is no fixed number — cost scales with the work — so these return no `X-Credit-Cost` header. The call still GATES: an empty balance → `402`. - **Fixed** — every other `/v1/content/*` op except the free `POST /v1/content/add-image`, `GET /v1/brand/images?q=` (semantic image search, op `brand.image.search`, cost 1), `POST /v1/emails/{emailId}/client-previews` (op `email.client_preview`, cost 10), `POST /v1/emails/{emailId}/accessibility-audit` (op `email.accessibility_audit`, cost 5), and `POST /v1/emails/{emailId}/inbox-placement-tests` (op `email.inbox_placement_test`, cost 10) charge a FLAT, published credit cost per call — while `POST /v1/contacts/validate` (op `contacts.validate`) charges **2 credits PER ADDRESS** (unit × the number of emails). All are reported via headers on success: ``` X-Credit-Cost: X-Credits-Remaining: ``` Check your balance up front with `GET /v1/usage`. When the balance is too low (below the fixed cost, or empty for a usage op) the call returns `402 INSUFFICIENT_CREDITS` with `details.{cost,remaining,planKey}` (no `Retry-After` — credits reset at the billing-period boundary). The free `subjectLine`-only branch of `PATCH /v1/emails/{emailId}` is never charged. The full per-operation metering table is in `GET /v1/help` → `credits.operations`. Query the balance directly any time with **`GET /v1/usage`** → `{ plan, credits:{limit,used,remaining}, emailSends:{limit,used,remaining}, period }` (`null` limit/remaining = unlimited). This is the BILLING surface. | Policy | Per-min limit | Routes | | ------------------------- | ------------- | ----------------------------------------------------------------------------- | | `emails.read` | 100 | `GET /v1/emails` (list + `?emailId=` + `?include=html,versions`), `GET /v1/email-groups` (list + `?groupId=`), `GET /v1/emails/{emailId}/inbox-placement-tests` | | `emails.generate` | 20 | `POST /v1/emails` | | `emails.edit` | 20 | `PATCH/DELETE /v1/emails/{emailId}`, `POST …/clone`, `POST …/restore`, `POST /v1/email-groups`, `PATCH/DELETE /v1/email-groups/{groupId}` | | `sends.read` | 100 | `GET /v1/analytics/sends?emailId=` (a design's send history) | | `sends.write` | 10 | `POST /v1/sends` (campaign + `{ test: true }`) | | `analytics.read` | 100 | `GET /v1/analytics/*` (overview, campaigns, automations, events, sends, trigger-instances) | | `automations.read` | 100 | `GET /v1/automations` (list + `?automationId=` + `?include=graph,versions`) | | `automations.write` | 60 | `POST /v1/automations`, `PATCH/DELETE /v1/automations/{automationId}` (publish/unpublish = `PATCH … { published }`) | | `automation.runs.read` | 100 | `GET /v1/automations/runs` (list + `?automationRunId=` + `?include=logs`), `GET /v1/analytics/trigger-instances*` | | `automation.runs.write` | 60 | `POST /v1/automations/{automationId}/test` | | `triggers.read` | 100 | `GET /v1/automations/triggers` (list + `?triggerEventId=`) | | `triggers.write` | 60 | `POST /v1/automations/triggers`, `PATCH/DELETE /v1/automations/triggers/{triggerEventId}` | | `contacts.read` | 100 | `POST /v1/contacts/search` (the unified Get Contacts read) | | `contacts.write_single` | 100 | `POST /v1/contacts` (single), `PATCH/DELETE /v1/contacts/{email}` | | `contacts.write_batch` | 10 | `POST /v1/contacts` (batch), `POST /v1/contacts/batch-delete`, `POST /v1/audiences/from-events` | | `fields.read` | 100 | `GET /v1/fields` | | `fields.write` | 100 | `POST /v1/fields`, `DELETE /v1/fields/{fieldName}` | | `audiences.read` | 100 | `GET /v1/audiences` (list + `?audienceId=` + `?include=count`) | | `audiences.write` | 60 | `POST/PATCH/DELETE /v1/audiences*` | | `domains.read` | 100 | `GET /v1/domains` (list + `?domainId=`), `GET /v1/domains/{domainId}/health` | | `domains.write` | 20 | `POST/PATCH/DELETE /v1/domains*`, `…/verify` (hits the sending provider) | | `templates.read` | 100 | `GET /v1/templates` | | `brand.read` | 100 | `GET /v1/brand` (`?include=identity,emailDesign,imageStyle,logos`), `GET /v1/brand/images` | | `brand.write` | 60 | `PATCH /v1/brand` (`{ identity?, emailDesign?, imageStyle? }`) | | `brands.read` | 100 | `GET /v1/brands`, `GET /v1/brands/{brandId}` | | `brands.write` | 5 | `POST /v1/brands` — each one starts a full site crawl AND takes a plan-limited brand slot, so this is the tightest write budget in the API | | `usage.read` | 100 | `GET /v1/usage` | | `integrations.read` | 100 | `GET /v1/integrations` | | `api_keys.read` | 60 | `GET /v1/api-keys` | | `api_keys.write` | 20 | `POST /v1/api-keys`, `DELETE /v1/api-keys/{keyId}` | | `content.generate` | 20 | `POST /v1/content/{generate-image,gif}` | | `content.transform` | 60 | `POST /v1/emails/{import,figma}`, `POST /v1/content/{transform,html-to-png,add-image}`, `POST /v1/emails/{emailId}/accessibility-audit`, `POST /v1/emails/{emailId}/inbox-placement-tests`, `POST /v1/contacts/validate` | UI-backed session traffic bumps every limit to `300/min`. Buckets are keyed on the **credential**, not the brand. So an ORGANIZATION-scoped key has ONE SHARED BUDGET across every brand it reaches — rotating `X-Brand-Id` does not multiply it. If you need per-brand throughput, use one brand-scoped key per brand. --- ## 6. Entity relationships ``` Brand (1 per API key) │ ┌──────────┬─────────────┼─────────────┬──────────┐ ▼ ▼ ▼ ▼ ▼ contacts domains triggerEvents emails audiences │ │ (untyped designs) │ │ │ │ │ │ automations ◄── sendEmail nodes │ │ │ ref: emailId + │ │ │ emailVersionId + │ │ │ domainId │ │ ▼ │ │ runs ◄── fire (one run per │ │ published automation) │ │ │ └── sends ◄── emailId + domainId + audienceId │ └── send events (per-recipient analytics) ``` Hard requirements at the wire boundary: 1. Every trigger's `payloadSchema.fields[]` MUST contain `{ key: 'email', type: 'string', required: true }`. Otherwise → `400 PAYLOAD_SCHEMA_EMAIL_REQUIRED`. 2. Every automation `sendEmail` node MUST carry `{ emailId, emailVersionId, subject }` at authoring. `previewText` is optional — the design's JSX `` is the source of truth. `domainId` is optional on create/save; publishing or live-running without one → `409 PUBLISH_VALIDATION_FAILED` / `422 INVALID_REQUEST`. A present `domainId` that is missing or not sendable → `400 AUTOMATION_GRAPH_INVALID`. Missing required authoring fields → `400 INVALID_REQUEST` (strict Zod). 3. When supplied, `domainId` must be brand-owned AND verified for sending (`sendable: true`). Designs are untyped — ANY design in the brand can be referenced by a `sendEmail` node or a send. 4. `POST /v1/sends` requires a recipient source: a brand-owned `audienceId` OR inline `to` (a single address or an array, ≤ 50). ### Contact upsert on every fire Every successful fire (and automation test) also upserts the contact derived from the resolved trigger payload BEFORE the workflow starts: `email` is the primary key; declared `firstName` / `lastName` / `subscribed` land on core columns; every other declared field lands in `customFields`; undeclared payload keys are dropped. Missing field definitions auto-create with normalised names. Upsert failures never block the run. --- ## 7. Canonical end-to-end examples (HTTP, copy-paste) ### A. One-shot campaign (design → send → analyze) ```bash # 1. Generate a design curl -X POST https://brew.new/api/v1/emails \ -H "Authorization: Bearer $BREW_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: eml-launch-v1" \ -d '{ "prompt": "Product launch announcement for our spring release" }' # → 201 { "emailId": "eml_launch", "emailVersionId": "emv_launch_v1", "html": "…" } # 2. Pick a verified domain + an audience curl "https://brew.new/api/v1/domains?sendableOnly=true" \ -H "Authorization: Bearer $BREW_API_KEY" # → 200 { "data": [{ "domainId": "kx7b…", "sendable": true, … }], "pagination": { … } } curl https://brew.new/api/v1/audiences \ -H "Authorization: Bearer $BREW_API_KEY" # → 200 { "data": [{ "audienceId": "jn7a…", "audienceName": "Subscribers", "count": 1284, … }], … } # 3. (Optional) QA it to yourself first — same endpoint, test: true curl -X POST https://brew.new/api/v1/sends \ -H "Authorization: Bearer $BREW_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "test": true, "emailId": "eml_launch", "subject": "Spring launch", "to": "qa@example.com" }' # → 200 { "status": "sent", "recipient": "qa@example.com" } # 4. Send the campaign curl -X POST https://brew.new/api/v1/sends \ -H "Authorization: Bearer $BREW_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: send-launch-2026-04-08" \ -d '{ "emailId": "eml_launch", "domainId": "kx7b…", "subject": "Our spring release is here", "audienceId": "jn7a…" }' # → 202 { "status": "queued", "sendId": "snd_8fK2mQ4p", "runId": "wrun_abc" } # 5. Poll status + stats, then read the per-recipient feed curl "https://brew.new/api/v1/analytics/sends?sendId=snd_8fK2mQ4p" \ -H "Authorization: Bearer $BREW_API_KEY" # → 200 { "data": [{ "sendId": "snd_8fK2mQ4p", "status": "sent", "stats": { "sent": 1200, "opened": 540, … }, … }] } curl "https://brew.new/api/v1/analytics/sends?sendId=snd_8fK2mQ4p&include=events&eventType=clicked" \ -H "Authorization: Bearer $BREW_API_KEY" # → 200 { "data": [{ "sendId": "snd_8fK2mQ4p", …, "events": [{ "eventType": "clicked", "recipientEmail": "…", "url": "…", … }] }] } ``` ### B. Event-driven automation (trigger → graph → publish → fire) ```bash # 1. Define a trigger (provider hardcoded brew_api) curl -X POST https://brew.new/api/v1/automations/triggers \ -H "Authorization: Bearer $BREW_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: tri-create-user-signup" \ -d '{ "title": "User Signed Up", "payloadSchema": { "type": "object", "fields": [ { "key": "email", "type": "string", "required": true }, { "key": "firstName", "type": "string", "required": false } ] } }' # → 201 { "triggerEventId": "tri_signup", "provider": "brew_api", … } # 2. Generate the design body the sendEmail node references curl -X POST https://brew.new/api/v1/emails \ -H "Authorization: Bearer $BREW_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: eml-welcome-v1" \ -d '{ "prompt": "Friendly welcome email" }' # → 201 { "emailId": "eml_welcome", "emailVersionId": "emv_welcome_v1", "html": "…" } # 3. Assemble the automation graph (deterministic, strict) curl -X POST https://brew.new/api/v1/automations \ -H "Authorization: Bearer $BREW_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: auto-welcome-flow-v1" \ -d '{ "name": "Welcome flow", "triggerEventId": "tri_signup", "nodes": [ { "id": "trg", "label": "On signup", "type": "trigger", "config": { "triggerEventId": "tri_signup" } }, { "id": "send_welcome", "label": "Welcome", "type": "sendEmail", "config": { "emailId": "eml_welcome", "emailVersionId": "emv_welcome_v1", "domainId": "kx7b…", "subject": "Welcome, {{firstName | there}}!", "previewText": "Thanks for signing up." } } ], "connections": [{ "from": "trg", "to": "send_welcome" }] }' # → 201 { "automationId": "auto_abc", "published": false, … } # 4. Test the whole flow, then publish. Add "testRecipient" to deliver each # send node's email for real to your inbox; omit it for a silent dry-run. curl -X POST https://brew.new/api/v1/automations/auto_abc/test \ -H "Authorization: Bearer $BREW_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "payload": { "email": "qa@example.com" }, "testRecipient": "qa@example.com" }' # → 202 { "automationRunIds": ["run_test1"], "status": "test_started", … } curl -X PATCH https://brew.new/api/v1/automations/auto_abc \ -H "Authorization: Bearer $BREW_API_KEY" \ -H "Content-Type: application/json" -d '{ "published": true }' # → 200 { "automationId": "auto_abc", "published": true, … } # 5. Fire from your backend whenever the real event happens curl -X POST https://brew.new/api/v1/automations/triggers/tri_signup/fire \ -H "Authorization: Bearer $BREW_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: signup-jane@example.com-1779292800" \ -d '{ "payload": { "email": "jane@example.com", "firstName": "Jane" } }' # → 200 { "success": true, "status": "triggered", "code": "TRIGGERED", # "receivedAt": "…", # "details": { "triggerInstanceId": "tin_8f2k", # "automationRunIds": ["run_01HZ"], … } } # NOTE: the fire endpoint is the ONE legacy-envelope exception — # read run ids from details.automationRunIds. # 6. Inspect the run + per-node logs (include=logs to attach them) curl "https://brew.new/api/v1/automations/runs?automationRunId=run_01HZ&include=logs" \ -H "Authorization: Bearer $BREW_API_KEY" # → 200 { "data": [{ "automationRunId": "run_01HZ", "status": "completed", "logs": [ … ] }] } ``` --- ## 8. Strict body shapes (the things you'll get wrong) ### `POST /v1/automations/triggers` ```ts { title: string, // 1-120 chars description?: string, // <= 2000 chars payloadSchema: { type: 'object', fields: Array<{ key: string, type: 'string' | 'int' | 'boolean', required: boolean, fallbackValue?: string | number | boolean, pii?: 'none' | 'low' | 'high', }>, }, } ``` **DO NOT** send `provider` or `providerEventKey` — both → `400`. ### `POST /v1/emails` ```ts { prompt: string, contentUrls?: Array, // up to 8 URLs; multi-URL synthesizes ONE email across all sources referenceEmailId?: string, // an existing design or template emailId subjectLine?: string, // design-default inbox subject (1-250 chars) } ``` Response is one of: ```ts // 201 — a design was persisted (happy path); credits charged { emailId: string, emailVersionId: string, html: string, previewImage?: string, subjectLine?: string } // 200 — prose answer, no design written { response: string } ``` `PATCH /v1/emails/{emailId}` (AI `prompt` edit) charges `operation: 'email.edit'`; a `subjectLine`-only body is a free deterministic envelope patch (no new version). ### `POST /v1/content/*` — media & render operations Media and render operations (each returns a CDN-hosted URL). All are POST and idempotency-aware. Image import is free; the other operations are credit-metered. | Endpoint | Body (key fields) | Returns | | --------------------------------- | --------------------------------------------------- | -------------------------------- | | `/v1/content/generate-image` | `{ prompt, mode?, aspectRatio?, model?, image1? }` | `{ url, prompt }` | | `/v1/content/gif` | `{ from:"prompt", prompt, … }` / `{ from:"image", imageUrl, … }` / `{ from:"video", videoUrl, … }` | `{ gifUrl, videoUrl?, altText? }` | | `/v1/content/transform` | `{ operation:"optimize", imageUrl }` / `{ operation:"resize", imageUrl, width, height, … }` | `{ url, width, height, bytes?, fallbackUsed? }` | | `/v1/content/html-to-png` | `{ html, width?, maxHeight? }` | `{ url, width }` | | `/v1/content/add-image` | `{ imageUrl }` or `{ imageUrls }` ≤ 100 | Single: `{ url, width, height, aspectRatio }`; batch: `202 { accepted, skipped, runId? }` — **free; no credits charged** | A processing failure (unreachable / non-decodable source) → `422 CONTENT_OPERATION_FAILED`. Metered operations can also return `402 INSUFFICIENT_CREDITS`. ### `POST /v1/automations` — `nodes[]` union `AutomationNode` is a discriminated union by `type`: ```ts // trigger — exactly one automation-level binding mode { id, label, description?, type: 'trigger', config: | { actionType?, mode?: 'event', triggerEventId?, eventName? } | { actionType?, mode: 'manualAudience', audienceId: string, eventName? } } // sendEmail — STRICT { id, label, description?, type: 'sendEmail', config: { actionType?, emailId: string, // REQUIRED emailVersionId: string, // REQUIRED domainId?: string, // optional on create/save; REQUIRED before publish or live run subject: string, // REQUIRED, supports {{var | fallback}} previewText?: string, // optional — the email design's JSX // is the source of truth and wins when present messageClass?: 'marketing' | 'transactional', // optional — unset behaves // as 'marketing' at send time (unsubscribe // headers + suppression apply) fromName?: string, fromAddress?: string, // resolved from the domain default when unset replyTo?: string, emailTitle?: string, // informational round-trip mirror } } // wait { id, label, description?, type: 'wait', config: { actionType?, duration: number, unit: 'minutes' | 'hours' | 'days' | 'weeks' } } // filter (payload — single output, drop-on-no-match) { id, label, description?, type: 'filter', config: { actionType?, mode?: 'payload', logicalOperator: 'AND' | 'OR', conditions: Array<{ field, operator, value? }> } } // filter (engagement — branch on what the contact did with an UPSTREAM email; // one outgoing edge per branch id + optional 'else' edge) { id, label, description?, type: 'filter', config: { actionType?, mode: 'engagement', sourceNodeId: string, // id of an upstream sendEmail node window: { duration: number, unit: 'minutes' | 'hours' | 'days' | 'weeks' }, // 0 = instant, max 30 days branches: Array<{ // ordered, first match wins id: string, // doubles as the edge's branch value ('else' is reserved) label?: string, condition: | { kind: 'engaged' | 'not_engaged', events?: 'opened' | 'clicked' | 'opened_or_clicked' } | { kind: 'clicked_link', url: string, position?: 'any' | 'first' | 'last' } }> } } // split (percentage) { id, label, description?, type: 'split', config: { actionType?, mode: 'percentage', leftLabel, rightLabel, leftPercentage: number, seed?: string } } // split (condition) { id, label, description?, type: 'split', config: { actionType?, mode: 'condition', leftLabel, rightLabel, logicalOperator: 'AND' | 'OR', conditions: Array<{ field, operator, value? }> } } ``` `AutomationConnection`: ```ts { from: string, to: string, branch?: string } ``` `branch` is REQUIRED on connections sourced from a `split` node ('left' | 'right') and from an engagement-mode `filter` node (a branch id from its config, or 'else' for the everyone-else output). ### `POST /v1/sends` Polymorphic on `test` / `transactionId`. ```ts // CAMPAIGN (default / test:false) → 202 { emailId: string, emailVersionId?: string, // optional — defaults to current 'latest' domainId: string, // marketing domains: audience or to; transactional: inline to only subject: string, previewText?: string, // <= 200 chars; OVERRIDES the design's JSX for this send — omit to deliver the design's own preview line replyTo?: string, // target — provide EXACTLY ONE: audienceId?: string, // brand-owned id from GET /v1/audiences, OR to?: string | string[], // inline email or array, <= 50 scheduledAt?: string, // ISO-8601, future only; omit to send now } // ONE-OFF QA (test:true) → 200, synchronous, no Send row, [TEST]-prefixed subject { test: true, emailId: string, emailVersionId?: string, subject: string, previewText?: string, // overrides the design's , like the campaign branch to: string, // single recipient; no audience replyTo?: string, // omitted on a domainId test -> the domain's saved reply-to domainId?: string, // OPTIONAL verified domain to test from; omit = Brew default // sender (unverified/foreign domainId => 404/422, never downgraded) fromEmail?: string, // local-part override; requires domainId senderName?: string, // display-name override; requires domainId variables?: Record, // example values for {{ var | fallback }} merge tags // (subject/preview/body); a value wins over the fallback; <= 25 } // TRANSACTIONAL OBJECT → 202 kind: transactional { transactionId: string, // txn_… from GET /v1/transactional/{transactionId} to: string | string[], // required, <= 50 payload?: Record, // scalars resolve {{ tag | fallback }}; // nested objects/arrays render via Liquid as // trigger.* (Liquid-enabled workspaces; <= 64 KiB, // <= 10 levels, <= 250 items/array, null allowed) strict?: boolean, // Liquid: fail the fire on any unresolved variable // (customer.io parity); default = object setting replyTo?: string, senderName?: string, fromEmail?: string, subject?: string, previewText?: string } ``` Don't guess the payload shape. `GET /v1/transactional/{transactionId}` returns the contract on Liquid-enabled workspaces: `variableTree` (every `trigger.*` / `customer.*` path the pinned template references, with array/object shape and fallbacks) and `examplePayload` (a nested body you can fire VERBATIM as `payload`), plus `templating.engine` and parse validity. Generate your service's request type from that, QA with `{ test: true, payload }` (test sends apply the same payload rules as the live fire), and re-read the contract after template edits — it is recomputed from the pinned design on every GET. Campaign / transactional response (`202`): `{ status: 'queued' | 'scheduled', sendId, runId, scheduledAt? }` — poll `GET /v1/analytics/sends?sendId=`. Test response (`200`): `{ status: 'sent', recipient }`. Campaign rows copy `messageClass` from the domain `sendingPurpose` (default marketing). A leftover campaign-body `messageClass` is accepted and ignored. The `transactionId` branch rejects `messageClass` and locks domain + design on the object. Transactional domains reject `audienceId` / `gradualSend` (`422 DOMAIN_PURPOSE_NOT_ALLOWED`). ### `POST /v1/automations/triggers/{triggerEventId}/fire` — the legacy-envelope exception Body: `{ payload: Record, idempotencyKey? }` — the trigger id travels in the PATH, not the body. Response envelope (success AND failure — unlike every other endpoint): ```ts { success: boolean, status: 'triggered' | 'idempotent_replay' | 'failed' | 'forbidden' | 'payload_mismatch' | 'trigger_event_not_found' | …, code: string, // e.g. TRIGGERED, NO_PUBLISHED_AUTOMATION message: string, triggerEventId?: string, receivedAt: string, // ISO-8601 details?: { resolvedPayload?: Record, warnings?: unknown[], idempotencyKey?: string, triggerInstanceId?: string, // join to GET /v1/analytics/trigger-instances?triggerInstanceId= publishedTransactionalEmails?: Array<{ emailId }>, publishedAutomations?: Array<{ automationId, title? }>, automationRunIds?: string[], // ← read this for downstream calls counts?: { transactionalEmails, automations }, }, } ``` This shape is shared with internal webhook infrastructure and kept AS IS. Every other v1 endpoint uses bare resources / `{ data, pagination }` / the `{ error: { … } }` envelope. --- ## 9. Error envelope + error code reference Every non-2xx response (except the fire endpoint, §8): ```json { "error": { "code": "AUTOMATION_GRAPH_INVALID", "type": "invalid_request", "message": "...", "param": "nodes[0].config.emailVersionId", "suggestion": "...", "docs": "https://docs.brew.new/api-reference/api/errors", "retryAfter": 42, "details": { "issues": [/* … */] } } } ``` Branch on `code` (stable). Branch on `details.kind` / `details.issues` for sub-classification. | Code | HTTP | Resource | Recovery | | --------------------------------- | ---- | ------------------ | ----------------------------------------------------------------- | | `AUTHENTICATION_REQUIRED` | 401 | any | Set the `Authorization` header. | | `INVALID_API_KEY` | 401 | any | Re-issue a key. | | `API_KEY_REVOKED` | 401 | any | Re-issue a key. | | `INSUFFICIENT_PERMISSIONS` | 403 | any | Grant the missing scope. | | `ACCOUNT_SUSPENDED` | 403 | any | The organization is suspended — not a credential problem. Contact support@brew.new. Do NOT retry. | | `RATE_LIMITED` | 429 | any | Back off using `Retry-After`. | | `IDEMPOTENCY_CONFLICT` | 409 | any POST | Use a fresh key OR don't change the body. | | `INVALID_REQUEST` | 400 | any | Fix the body / query per `param`. | | `METHOD_NOT_ALLOWED` | 405 | any | Use a method from the `Allow` header. | | `TRIGGER_EVENT_NOT_FOUND` | 404 | automations | List with `GET /v1/automations/triggers` to find the id. | | `TRIGGER_IMMUTABLE` | 422 | triggers PATCH/DELETE | Integration trigger — manage from the integration only. | | `TRIGGER_HAS_DEPENDENT_AUTOMATIONS`| 409 | triggers DELETE | Delete / detach the automations first; see `details.referencingAutomations[]`. | | `PAYLOAD_SCHEMA_EMAIL_REQUIRED` | 400 | triggers | Add `{ key: 'email', type: 'string', required: true }`. | | `BRAND_SCOPE_MISMATCH` | 403 | fire, any `X-Brand-Id` | Your credential is bound to ONE brand and you named a different one. Omit `X-Brand-Id`, or use an organization-scoped credential. | | `NO_PUBLISHED_AUTOMATION` | 422 | fire | Publish at least one automation attached to the trigger. | | `AUTOMATION_NOT_FOUND` | 404 | automations, runs | List with `GET /v1/automations`. | | `AUTOMATION_VERSION_NOT_FOUND` | 404 | automations publish | Drop `automationVersionId` or use a known one. | | `AUTOMATION_VERSION_CONFLICT` | 409 | automations PATCH | Re-read the latest version, reapply the intended change, then retry. | | `AUTOMATION_NOT_PUBLISHED` | 422 | automations unpublish | Publish first before unpublishing. | | `AUTOMATION_GRAPH_INVALID` | 400 | automations | Iterate over `details.issues` and fix each one. See kinds below. | | `PUBLISH_VALIDATION_FAILED` | 409 | automations publish | Iterate over `details.blockers[]`; each carries `nodeId / nodeLabel / message`. | | `AUTOMATION_RUN_NOT_FOUND` | 404 | runs | List with `GET /v1/automations/runs`. | | `EVENT_NOT_FOUND` | 404 | automations | List with `GET /v1/analytics/trigger-instances`. | | `EMAIL_NOT_FOUND` | 404 | emails, sends, automations | List with `GET /v1/emails`. | | `EMAIL_GROUP_NOT_FOUND` | 404 | emails | List with `GET /v1/email-groups`. Unknown / cross-brand `groupId` / `targetGroupId`. | | `EMAIL_GROUP_NAME_CONFLICT` | 409 | email-groups | Pick a different name; a folder with that label already exists. | | `EMAIL_VERSION_NOT_FOUND` | 404 | emails, sends | Drop `emailVersionId` / `version` or fetch a valid one via `GET /v1/emails?emailId=&include=versions`. | | `EMAIL_NOT_READY` | 422 | sends, email clone | Design failed or is otherwise not ready for the requested operation. | | `EMAIL_IN_PROGRESS` | 409 | emails PATCH/clone | `status: 'streaming'` — wait and retry. | | `SEND_NOT_FOUND` | 404 | sends, emails | List with `GET /v1/analytics/sends`. | | `DOMAIN_NOT_FOUND` | 404 | sends, automations, domains | List with `GET /v1/domains`. | | `DOMAIN_NOT_READY` | 422 | sends, automations | Domain not verified for sending — `POST /v1/domains/{domainId}/verify`. | | `DOMAIN_PURPOSE_NOT_ALLOWED` | 422 | sends, automations | Transactional domain used on a marketing-only surface. Use inline `to` or a marketing domain. | | `AUDIENCE_NOT_FOUND` | 404 | sends, audiences | List with `GET /v1/audiences`. | | `CONTACT_NOT_FOUND` | 404 | contacts | Upsert the contact first. | | `CORE_FIELD_IMMUTABLE` | 422 | contacts, fields | Don't write read-only columns (e.g. `createdAt`). | | `FIELD_NOT_FOUND` | 404 | fields | Create the field with `POST /v1/fields`. | | `MISSING_EMAIL` | 422 | contacts (single) | Add `email` to the body. | | `BRAND_NOT_FOUND` | 404 | emails, brand | The brand is gone, or the `X-Brand-Id` you sent isn't in your org. | | `BRAND_ID_REQUIRED` | 400 | all brand-scoped | Your credential is ORGANIZATION-scoped, so every brand-scoped call must name a brand: send `X-Brand-Id: `. There is no default brand. | | `ORG_SCOPE_REQUIRED` | 403 | brand lifecycle | The operation acts on the whole organization; use an organization-scoped credential. | | `BRAND_DOMAIN_CONFLICT` | 409 | brands POST | This org already has a brand for that domain — find it with `GET /v1/brands`. | | `BRAND_LIMIT_REACHED` | 402 | brands POST | At the plan's brand cap. Delete an unused brand or upgrade; see `details.{used,limit,planKey}`. | | `BRAND_NOT_READY` | 422 | emails | Brand extraction hasn't finished — poll `GET /v1/brand` for `ready`. | | `INSUFFICIENT_CREDITS` | 402 | emails (generate/edit), content | Balance too low — below the fixed cost (fixed ops) or empty (usage-metered ops: email generate/edit, image generation). Top up / upgrade, or check your balance up front with `GET /v1/usage`. No `Retry-After` (resets at the billing-period boundary). See `details.{cost,remaining,planKey}`. | ### `AUTOMATION_GRAPH_INVALID` issue kinds `error.details.issues: Array<{ kind, nodeId?, nodeLabel?, message }>`: | `kind` | Cause | | ---------------------------- | --------------------------------------------------------------------------------------- | | `duplicate_node_id` | Two nodes share the same `id`. | | `connection_unknown_from` | `connection.from` points to a non-existent node. | | `connection_unknown_to` | `connection.to` points to a non-existent node. | | `connection_targets_trigger` | A connection targets the trigger node (triggers are entry-only). | | `connection_self_loop` | `connection.from === connection.to`. | | `email_not_found` | `emailVersionId` doesn't exist in this brand. | | `email_version_mismatch` | `emailVersionId` exists but belongs to a different `emailId`. | | `domain_not_found` | `domainId` doesn't exist in this brand. | | `domain_not_ready` | Domain not verified for sending. | | `domain_purpose_not_allowed` | Transactional domain used on an automation `sendEmail` node. Use a marketing domain. | --- ## 10. Identifier reference | Identifier | Typical prefix | Notes | | --------------------- | ------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | | `emailId` | `eml_` / nanoid | Stable across edits — the design. | | `emailVersionId` | `emv_` / nanoid | One per persisted design version. Returned by every generate / edit / restore. | | `sendId` | nanoid | One per send. The analytics join key. | | `triggerEventId` | `tri_` (custom) OR composite (`clerk:org_…:brand_…:user.created`) | ≤ 256 chars. URL-encode composite ids in paths. | | `triggerInstanceId` | `tin_` | One per inbound fire; join to `GET /v1/analytics/trigger-instances?triggerInstanceId=`. | | `automationId` | `auto_` / nanoid | Stable across versions. | | `automationVersionId` | `av_` / nanoid | One per persisted graph version. | | `automationRunId` | nanoid | One per workflow run started. | | `domainId` | none (raw Convex id, e.g. `kx7…`) | Opaque string; do not validate a prefix. | | `audienceId` | none (raw Convex id, e.g. `jn7…`) | Opaque string; do not validate a prefix. | | `fieldName` / `email` | caller-defined | Contacts are keyed by email; fields by name. URL-encode in paths. | | `idempotencyKey` | caller-defined | ≤ 100 chars. Namespaced per org server-side. | --- ## 11. TypeScript SDK (`@brew.new/sdk@8.x`) ```bash npm install @brew.new/sdk@latest ``` ```ts import { createBrewClient } from '@brew.new/sdk' const brew = createBrewClient({ apiKey: process.env.BREW_API_KEY! }) ``` SDK resources map 1:1 onto the endpoint table in §2 (the client is generated from the same OpenAPI spec linked in §0). Errors throw `BrewApiError` exposing `code`, `type`, `message`, `requestId`, `param`, `suggestion`, `docs`, `retryAfter`. See `https://docs.brew.new/sdks/typescript/resources` for the current resource map. --- ## 12. Decision tree — "I want to…" - **Send one email to a saved list right now** → `POST /v1/emails { prompt }` → `POST /v1/sends { emailId, domainId, audienceId, subject }` → poll `GET /v1/analytics/sends?sendId=`. - **QA a design before the blast** → `POST /v1/sends { test: true, emailId, subject, to }`. - **Send a welcome flow when a user signs up** → `POST /v1/automations/triggers` → `POST /v1/emails { prompt }` per body → `POST /v1/automations` → `PATCH /v1/automations/{automationId} { published: true }` → from your backend: `POST /v1/automations/triggers/{triggerEventId}/fire { payload }`. - **Send a transactional email (password reset / receipt)** → create the object in-app, then `GET /v1/transactional/{transactionId}` and `POST /v1/sends { transactionId, to, payload }`. List fires with `GET /v1/analytics/sends?transactionId=`. - **QA an automation end-to-end** → `POST /v1/automations/{automationId}/test { testRecipient }` (delivers each send node's email for real to that address via the Brew test domain; omit `testRecipient` for a silent dry-run; drafts + both trigger types work) then `GET /v1/automations/runs?automationRunId=&include=logs`. - **Re-run after fixing the graph** → `PATCH /v1/automations/{automationId}` (new version) → publish → re-fire the trigger (`POST /v1/automations/triggers/{triggerEventId}/fire`). - **See how a campaign performed** → `GET /v1/analytics/sends?sendId=` (stats) / `GET /v1/analytics/campaigns` (all sends) / `GET /v1/analytics/sends?sendId=&include=events` (per recipient). - **Audit what a fire did** → `GET /v1/analytics/trigger-instances?triggerInstanceId=` → `GET /v1/automations/runs?automationRunId=&include=logs`. - **Manage recipient data programmatically** → `POST /v1/contacts/search` (the unified Get Contacts read) + `/v1/contacts*` + `/v1/fields*`. --- ## 13. Hard "do not" list (the most common mistakes) 1. **DO NOT** mix up read vs write identity. READS carry the id in the QUERY (`GET /v1/emails?emailId=`, `GET /v1/analytics/sends?sendId=`) and sub-reads fold into `?include=`; there are NO get-one paths. WRITES carry it in the PATH (`PATCH /v1/emails/{emailId}`). Never put an id in a request body (`{ "automationId": … }` is gone). 2. **DO NOT** send `brandId` in a body or query — on any key. The brand comes from the key itself (brand-scoped) or from the `X-Brand-Id` header (organization-scoped). There is no default brand. 3. **DO NOT** send `provider` or `providerEventKey` on `POST /v1/automations/triggers`. Server hardcodes `provider: 'brew_api'`. 4. **DO NOT** exceed 50 inline `to` addresses on `POST /v1/sends` — for a larger audience, pass a brand-owned `audienceId` instead (provide exactly one of `audienceId` or `to`). 5. **DO NOT** skip `Idempotency-Key` on retried writes — duplicate sends / workflow runs (and duplicate emails) are the result. 6. **DO NOT** omit `emailVersionId` on a `sendEmail` automation node — it is REQUIRED there so the automation pins an exact version. (On `POST /v1/sends` it is optional — omitting sends the current latest.) 7. **DO NOT** branch on the error `message` (human-readable). Branch on `code` (stable) and `details.kind` / `details.issues`. 8. **DO NOT** expect wrapper arrays from get-one or writes — they return the BARE resource. Only collections wrap rows, and always as `{ data, pagination }`. 9. **DO NOT** parse the fire response like other endpoints — it is the legacy envelope; run ids are at `details.automationRunIds`. 10. **DO NOT** stop paginating when `data` is non-empty — loop `while (pagination.cursor !== null)`. --- ## 14. Determinism vs AI authoring The public HTTP API + SDK are **deterministic-only** for trigger / automation authoring. AI is scoped to **email body content** via `POST /v1/emails { prompt }` (and `PATCH /v1/emails/{emailId}` edits). The chat-side orchestrator (Brew dashboard) wraps these endpoints with agentic tools, but those tools are not exposed publicly. When you're building an agent on top of Brew: 1. Use an LLM to interpret the user's intent and **draft** the shape of the trigger / automation / payload. 2. Send the **deterministic** body shape described above. 3. Branch on the error `code` (stable) to recover, not on the `message` (human-readable, can change). --- ## 15. Support Always include the `x-request-id` response header when contacting support — every successful AND failed response carries one. Cross-references: - OpenAPI spec (well-known JSON): `https://brew.new/openapi.json` - OpenAPI spec (YAML): `https://brew.new/api/openapi.yaml` - OpenAPI spec (generated artifact): `https://brew.new/openapi/public-api-v1.yaml` - Mintlify docs mirror of this file: `https://docs.brew.new/llms.txt` - Long-form repo reference (every code, every schema): `sub-agent-orchestrator/docs/V1_API_REFERENCE.md` (private repo)