# Brew Public API v1 — Agent Guide > Brew is an AI email platform. This file teaches agents (Claude, > GPT, Lovable, Devin, Cursor, etc.) how to use the public REST API > at `/api/v1/*` to define event triggers, generate emails, assemble > automation graphs, fire automation runs, and send campaigns — > without the dashboard UI. The Zod schemas in > `lib//contracts.ts` and the generated OpenAPI at > `https://brew.new/openapi/public-api-v1.yaml` are the source of > truth; this file is the conceptual map. --- ## 0. Index of authoritative resources - HTTPS base URL: `https://brew.new/api` - OpenAPI spec (machine-readable): `https://brew.new/openapi/public-api-v1.yaml` - API-host agent guide (mirror of this file, dynamic): `https://brew.new/api/v1/llms.txt` - Mintlify docs (this site): `https://docs.brew.new` - Endpoint reference (browsable): the "Public API v1" group in the docs sidebar, generated from the OpenAPI spec above. - API introduction (humans + agents): `https://docs.brew.new/api-reference/api/api-introduction` - SDK (npm, TypeScript): `@brew.new/sdk` (latest `6.x`) - SDK quickstart: `https://docs.brew.new/sdks/typescript/quickstart` - SDK resource map: `https://docs.brew.new/sdks/typescript/resources` - SDK agentic cookbook (recipes 1-7): `https://docs.brew.new/sdks/typescript/agentic-cookbook` - Lovable / Supabase Edge Function recipe: `https://docs.brew.new/integrations/ai-tools/lovable` - Long-form internal reference (every code, every schema): `sub-agent-orchestrator/docs/V1_API_REFERENCE.md` in the repo. --- ## 1. Authentication (every request) ``` 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 bound to exactly **one brand** at creation time. **Never include a `brandId` field** in any body or query — it returns `400 INVALID_REQUEST`. Each key carries a permission scope set. Coarse scopes imply the granular least-privilege scopes, so existing keys keep working: | Granted scope | Satisfies | | -------------- | -------------------------------------------------------------------------------------------------------- | | `contacts` | `contacts`, `audiences` | | `emails` | `emails`, `domains`, `sends` | | `automations` | `automations` | | `all` | everything | | granular (`audiences`, `domains`, `sends`) | itself only (least privilege) | Per-route required scope (or an implier): | Route | Scope | | --------------------------------------------------------------- | ---------------------- | | `/v1/contacts`, `/v1/fields` | `contacts` | | `/v1/audiences` | `audiences` (or `contacts`) | | `/v1/domains` | `domains` (or `emails`) | | `/v1/sends` | `sends` (or `emails`) | | `/v1/emails`, `/v1/templates`, `/v1/brand`, `/v1/usage` | `emails` | | `/v1/analytics/campaigns`, `/v1/analytics/events` | `emails` | | `/v1/analytics/automations`, `/v1/integrations` | `automations` | | `/v1/triggers`, `/v1/automations`, `/v1/automation/runs` | `automations` | Missing permission → `403 INSUFFICIENT_PERMISSIONS`. --- ## 2. Idempotency (always set on retried writes) `Idempotency-Key: <≤100 chars>` header on every POST / PATCH where your code might retry (timeouts, network errors, redelivered webhooks). 24 h window. Same key + same body → original cached response. Same key + different body → `409 IDEMPOTENCY_CONFLICT`. `POST /v1/automation/runs` (fire branch) additionally accepts a body `idempotencyKey` field for back-compat with the legacy `/v1/events` route. Pattern for webhook-driven fires: ``` Idempotency-Key: -- ``` --- ## 3. 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: `. | Policy | Per-min limit | Routes | | ------------------------- | ------------- | ------------------------------------------------------------ | | `triggers.read` | 100 | `GET /v1/triggers` | | `triggers.write` | 60 | `POST/PATCH/DELETE /v1/triggers` | | `automations.read` | 100 | `GET /v1/automations` | | `automations.write` | 60 | `POST/PATCH/DELETE /v1/automations` | | `automation.runs.read` | 100 | `GET /v1/automation/runs` (+ legacy `/v1/executions`) | | `automation.runs.write` | 60 | `POST/PATCH /v1/automation/runs` (+ legacy `/v1/executions`) | | `emails.read` | 100 | `GET /v1/emails` | | `emails.generate` | 20 | `POST /v1/emails` | | `emails.edit` | 20 | `PATCH /v1/emails` | | `sends.read` | 100 | `GET /v1/sends` | | `sends.write` | 10 | `POST /v1/sends` (campaign + test) | | `brand.read` | 100 | `GET /v1/brand` | | `usage.read` | 100 | `GET /v1/usage` | | `contacts.read` | 100 | `GET /v1/contacts` | | `contacts.write_single` | 100 | `POST/PATCH/DELETE /v1/contacts` (single-row body) | | `contacts.write_batch` | 10 | `POST/DELETE /v1/contacts` (batch body) | | `audiences.read` | 100 | `GET /v1/audiences` | | `domains.read` | 100 | `GET /v1/domains` | | `fields.read` | 100 | `GET /v1/fields` | | `fields.write` | 100 | `POST/DELETE /v1/fields` | | `templates.read` | 100 | `GET /v1/templates` | --- ## 4. Resource entity model (relationships + ordering) ``` ┌─────────────────────┐ │ Brand (1 / API key) │ └─────────────────────┘ │ ┌────────┬───────────────┼───────────────┬────────┐ ▼ ▼ ▼ ▼ ▼ contacts domains triggerEvents emails audiences │ │ │ │ │ │ │ │ emailType: │ │ │ │ • campaign ────┤ │ │ │ • automation │ │ │ │ • transactional │ │ │ │ │ │ │ │ │ ▼ │ │ │ automations ◄──── sendEmail nodes │ │ │ reference emailId + │ │ │ emailVersionId + │ │ │ domainId │ │ ▼ │ │ automation runs ◄──── fire (POST /v1/automation/runs) │ │ │ │ │ │ │ ▼ ▼ │ used as creates 1 workflow run per │ domainId matched published automation │ │ custom contact fields (used by filter / split nodes) ▼ fields (string | number | date | bool) sends (POST /v1/sends) — separate from automations. Audience-only one-shot campaign delivery. Requires a campaign or transactional email + a brand-owned audienceId + a verified domainId. Does NOT use automation graphs. ``` ### Ordering of operations — event-driven automation (most common) 1. `POST /v1/triggers` → mint `triggerEventId` (server hardcodes `provider: 'brew_api'`; integration triggers like clerk / stripe / shopify are listed but not authored here). 2. `POST /v1/emails` once per body the automation will send, with `emailType: 'automation'` (or `'transactional'`). Capture `{ emailId, emailVersionId }` from each response. 3. `GET /v1/domains` → pick `domainId` of a verified sending domain. 4. `POST /v1/automations` with a deterministic graph: `{ name, triggerEventId, nodes, connections, dryRun? }`. Every `sendEmail` node requires `{ emailId, emailVersionId, domainId, subject, previewText }`. 5. `PATCH /v1/automations { automationId, published: true }` to publish. Returns `409 PUBLISH_VALIDATION_FAILED` with `details.blockers[]` if not ready. 6. From your backend, on the real event: `POST /v1/automation/runs { triggerEventId, payload, idempotencyKey }`. The response carries `details.automationRunIds[]`. **The same call also auto-upserts the contact** — `email` is the key, every declared `firstName` / `lastName` / `subscribed` field lands in core columns, and every other declared payload field becomes a `customFields[key]` entry on the contact (with the field definition auto-created on the brand if missing). 7. `GET /v1/automation/runs?automationId=…` or `?automationRunId=…&include=logs` to inspect. ### Contact upsert on every fire (DRY for API + in-app) Every successful fire AND test triggers a side-effect upsert of the contact derived from the resolved trigger payload. The upsert runs inside `startAutomationExecution` so the contract is identical for the public API (`POST /v1/automation/runs`), the dashboard test dialog, the fire panel, manual execute, retry, and the per- automation legacy trigger route. | Payload key | Lands in | | ---------------------------------------------- | ------------------------------------- | | `email` | Contact's primary key (`_email`) | | `firstName` (when declared `type: 'string'`) | Core column `_firstName` | | `lastName` (when declared `type: 'string'`) | Core column `_lastName` | | `subscribed` (when declared `type: 'boolean'`) | Core column `_subscribed` | | Every other declared field | `customFields[]` on the contact | | Undeclared payload keys | Dropped (already filtered out by the trigger payload resolver) | If a custom field doesn't have a definition on the brand yet, the upsert auto-creates one with a normalised name (`first_name` → `firstName`, `plan-name` → `planName`, etc.) so the new column is immediately filterable / sortable / searchable in the dashboard. The upsert is best-effort: a Mongo blip degrades to a structured `[triggers/contact-sync] upsert failed` log line and the workflow still starts. Re-firing the same payload with the same `Idempotency-Key` replays the original `automationRunIds[]` and re-runs the upsert idempotently (same email key, same fields). Inbound integration paths (Clerk, Stripe, Shopify, Stytch, Supabase, WorkOS, RevenueCat) skip this generic upsert via `skipContactSync: true` — they already invoked a richer per-provider mapper (`syncInboundContactToOrg` / `syncStripeContactToOrg`) upstream, so re-running the generic mapper would clobber `_*` columns with a thinner view. ### Ordering of operations — one-shot audience campaign 1. `POST /v1/emails { prompt, emailType: 'campaign' }` → capture `{ emailId, emailVersionId }`. 2. `GET /v1/domains` → pick `domainId`. 3. `GET /v1/audiences` → pick `audienceId`. 4. `POST /v1/sends { emailId, emailVersionId?, domainId, audienceId, subject, previewText? }` → returns `{ status, runId }`. `audienceId` is REQUIRED on `/v1/sends`. The old `emails: string[]` ad-hoc recipient list is **removed**; for per-recipient delivery use the event-driven automation flow above. --- ## 5. Endpoint summary > Every endpoint is `https://brew.new/api`. JSON content type. > Strict bodies — unknown keys → `400 INVALID_REQUEST`. ### `/v1/triggers` - `POST` — create. Body: `{ title, description?, payloadSchema }`. Returns `201 { triggers: [TriggerRow] }`. Server hardcodes `provider: 'brew_api'`. Sending `provider` / `providerEventKey` → `400`. - `GET` — list. Always returns `{ triggers: TriggerRow[] }`. `?triggerEventId=…` returns one-element list (or `404`). - `PATCH` — metadata-only update (`title`, `description`, `payloadSchema`). Returns `200 { trigger: TriggerRow }`. Triggers don't carry a status field; whether they fire is gated by the bound automation being `published: true`. Sending `{ status }` → `400 INVALID_REQUEST`. - `DELETE` — `{ triggerEventId }`. Refused with `409 TRIGGER_HAS_DEPENDENT_AUTOMATIONS` when non-archived automations still reference it. `payloadSchema.fields[]` MUST contain `{ key: 'email', type: 'string', required: true }` — otherwise `400 PAYLOAD_SCHEMA_EMAIL_REQUIRED`. ### `/v1/emails` - `POST` — generate. Body: `{ prompt, emailType, contentUrl?, referenceEmailId? }`. `emailType` is REQUIRED: `'campaign' | 'automation' | 'transactional'`. Returns either `{ emailId, emailVersionId, emailHtml, emailPng? }` (artifact branch) or `{ response: string }` (text branch, agent declined to render). 30–90 s typical; client timeout default 4 min. - `GET` — list latest emails. Query: `?emailType=…&status=…&createdAtFrom=…&createdAtTo=…&updatedAtFrom=…&updatedAtTo=…`. Returns `{ emails: EmailListItem[] }`. - `PATCH` — edit. Body: `{ emailId, emailVersionId?, prompt, contentUrl? }`. Same response union as POST. Always surfaces a fresh `emailVersionId`. ### `/v1/automations` - `POST` — create. Body: `{ name, description?, triggerEventId, nodes, connections, dryRun? }`. `dryRun: true` validates only. Returns `201 { automations: [AutomationRow] }`. - `GET` — list. Always `{ automations: AutomationRow[] }`. `?automationId=…` returns a one-element list. `?include=versions` (single-row only) attaches `automations[0].versions[]`. - `PATCH` — body union: - Update: `{ automationId, name?, description?, nodes?, connections?, triggerEventId?, dryRun? }`. At least one editable field required. - Publish: `{ automationId, published: boolean, automationVersionId? }`. `published: true` validates + publishes; `false` unpublishes. Returns `200 { automations: [AutomationRow] }`. - `DELETE` — `{ automationId }`. Cascade-deletes automation + every version + owned automation emails + executions + execution logs + canvas layouts. Idempotent (no `404` on already-deleted). Strict `sendEmail` config (every node): ``` { emailId: string, // REQUIRED — from POST /v1/emails emailVersionId: string, // REQUIRED — pin to exact version domainId: string, // REQUIRED — from GET /v1/domains subject: string, // REQUIRED — supports {{var | fallback}} previewText: string, // REQUIRED — supports {{var | fallback}} fromName?: string, replyTo?: string, } ``` `wait` config: `{ duration: number, unit: 'minutes' | 'hours' | 'days' | 'weeks' }`. `filter` config: `{ logicalOperator: 'AND' | 'OR', conditions: Array<{ field, operator, value? }> }`. `split` config: `percentage` mode or `condition` mode (see `V1_API_REFERENCE.md` §7). `AUTOMATION_GRAPH_INVALID` (400) reports issues via `details.issues: Array<{ kind, nodeId?, nodeLabel?, message }>`. Issue `kind` values: `duplicate_node_id`, `connection_unknown_from`, `connection_unknown_to`, `connection_targets_trigger`, `connection_self_loop`, `email_not_found`, `email_version_mismatch`, `email_wrong_type` (must be `automation` or `transactional`), `domain_not_found`, `domain_not_ready`. ### `/v1/automation/runs` - `POST` — 3-branch union (body-discriminated): - **Fire**: `{ triggerEventId, payload, idempotencyKey?, dryRun? }` — `metadata` is REMOVED. **Side effect:** every fire ALSO auto-upserts the contact keyed on `payload.email` and creates any missing custom-field definitions on the brand. - **Test**: `{ automationId, mode: 'test', payload? }` — `metadata` removed. **Same upsert side effect runs in test mode** (DRY contract — test fires mirror live). - **Replay**: `{ automationRunId, mode: 'replay' }` — re-runs a prior run with the same payload + mode against the automation's current saved draft. Returns the flat envelope `{ status: 'replay_started', automationRunIds: [newRunId], receivedAt, warnings? }`. `404 AUTOMATION_RUN_NOT_FOUND` on an unknown / cross-brand id. Returns the uniform envelope: ``` { success: true, status: 'triggered' | 'idempotent_replay' | 'test_started' | 'replay_started', triggerEventId?: string, receivedAt: string, details: { resolvedPayload?: Record, warnings?: unknown[], idempotencyKey?: string, triggerInstanceId?: string, publishedTransactionalEmails?: Array<{ emailId }>, publishedAutomations?: Array<{ automationId, title? }>, automationRunIds?: string[], counts?: { transactionalEmails, automations }, }, } ``` **Note** — fire returns `automationRunIds` under `details.automationRunIds` (not at the top level). - `GET` — list. Always `{ runs: AutomationRunRow[] }`. Filters: `automationRunId / automationId / triggerEventId / triggerInstanceId / recipientEmail / status / mode / from / to / limit / cursor`. `?include=logs` adds sibling `logs: AutomationRunLogRow[]`. `404 AUTOMATION_RUN_NOT_FOUND` on unknown id. - `PATCH` — cancel (P7 — `501 NOT_IMPLEMENTED`). ### `/v1/sends` - `POST` — body is a discriminated union (`mode: 'test'` → test send; otherwise campaign send). - **Campaign** (strict): ``` { emailId: string, emailVersionId?: string, // pin to a specific version (defaults to latest) domainId: string, subject: string, previewText?: string, // <= 50 chars replyTo?: string, audienceId: string, // REQUIRED — from GET /v1/audiences scheduledAt?: string, // ISO-8601, future only } ``` Returns `202 { status: 'queued' | 'scheduled', runId, scheduledAt? }`. The removed `emails: string[]` recipient list → `400 INVALID_REQUEST`. For per-recipient event-driven delivery use `POST /v1/automation/runs` (fire branch) instead. - **Test/preview**: `{ mode: 'test', emailId, emailVersionId?, subject, previewText?, to, replyTo? }`. Forces the Brew default sender (no verified domain / audience), does NOT consume the email's live-send slot. Returns `200 { status: 'sent', recipient }`. - `GET` — list campaign sends + stats. `{ sends: SendRow[], pagination }`. Filters: `emailId / status / from / to / limit / cursor`. `?emailId=` is single-fetch → one-element `{ sends: [row] }` (no `pagination`) or `404 SEND_NOT_FOUND`. ### `/v1/brand`, `/v1/usage`, `/v1/integrations`, `/v1/analytics/events` - `GET /v1/brand` — singleton `{ brand: { brandId, domain, status, ready, createdAt?, updatedAt? } }`. `ready` ⇔ `status: 'completed'`. `404 BRAND_NOT_FOUND` if the bound brand was deleted. Check `ready` before generating / sending (else `422 BRAND_NOT_READY`). - `GET /v1/usage` — `{ usage: { overview, trend[], routes[] } }`. `overview` = rolling 24h totals; `trend` = 30 daily points; `routes` = per-route 7d rollup. - `GET /v1/integrations` — triggerable integration-event catalog `{ integrations: [{ provider, connected, events: [{ eventType, title, category, fieldKeys, requiredFieldKeys, provisioned }] }] }`. Lists every triggerable event even when not connected. `?provider=` filters; unknown provider → `400`. - `GET /v1/analytics/events` — unified event explorer `{ events: EventRow[], pagination, range }`. Filters: `from / to / recipientEmail / eventType / automationId / limit / cursor`. Per-contact engagement = `?recipientEmail=`. ### `/v1/contacts`, `/v1/fields`, `/v1/audiences`, `/v1/domains`, `/v1/templates` CRUD / read endpoints — see `V1_API_REFERENCE.md` for full shapes. Highlights: - `POST /v1/contacts` accepts single OR batch (up to 1000 rows). Custom field columns auto-create on first write; declare them upfront via `POST /v1/fields` for filter / sort / search support. - `GET /v1/contacts` supports `?email=…` (single lookup), `?count=true` (count only), or list mode (paginated). - `GET /v1/audiences` and `GET /v1/domains` are read-only listings; manage in the dashboard. ### Legacy aliases — sunset 2026-12-01 | Legacy | Successor | | ----------------------------------------------------- | ------------------------------------------------------------ | | `POST/GET/PATCH /v1/executions` | `POST/GET/PATCH /v1/automation/runs` | | `POST /v1/events` | `POST /v1/automation/runs` (fire) | | `GET /v1/events[/triggerInstanceId]` | `GET /v1/automation/runs?triggerInstanceId=…` | | `PATCH /v1/emails/{emailId}` | `PATCH /v1/emails` | | `GET/PATCH/DELETE /v1/triggers/{triggerEventId}` | flat (id → body / query) | | `POST /v1/triggers/{id}/enable \| /disable` | **Removed (404).** Triggers are always on; gate via `automation.published`. | | `GET/PATCH /v1/automations/{automationId}` | flat | | `POST /v1/automations/{id}/publish` | `PATCH /v1/automations { automationId, published: true }` | | `POST /v1/automations/{id}/test` | `POST /v1/automation/runs { automationId, mode: 'test' }` | | `GET /v1/automations/{id}/versions` | `GET /v1/automations?automationId=…&include=versions` | Every legacy alias adds response headers: ``` Deprecation: true Sunset: 2026-12-01T00:00:00Z Link: ; rel="successor-version" ``` --- ## 6. Error envelope (every non-2xx response) ```json { "error": { "code": "AUTOMATION_GRAPH_INVALID", "type": "invalid_request", "message": "...", "param": "nodes[0].config.emailVersionId", "suggestion": "...", "docs": "https://docs.brew.new/api/automations", "retryAfter": 42, "details": { "issues": [/* … */] } } } ``` Branch SDK / agent logic on `code` (stable). Foundational codes: | Code | HTTP | Use | | ----------------------------- | ---- | ---------------------------------------------------- | | `AUTHENTICATION_REQUIRED` | 401 | Missing `Authorization`. | | `INVALID_API_KEY` | 401 | Malformed key. | | `API_KEY_REVOKED` | 401 | Key revoked in dashboard. | | `INSUFFICIENT_PERMISSIONS` | 403 | Key lacks `requiredPermission`. | | `METHOD_NOT_ALLOWED` | 405 | Wrong verb on the resource. | | `INVALID_REQUEST` | 400 | Zod failure / unknown key / malformed JSON. | | `IDEMPOTENCY_CONFLICT` | 409 | Same key, different body. | | `RATE_LIMITED` | 429 | Window exhausted. `Retry-After` set. | | `NOT_IMPLEMENTED` | 501 | `PATCH /v1/automation/runs` cancel (replay now ships).| | `INTERNAL_ERROR` | 500 | Unhandled crash. Include `x-request-id` in support. | Resource-specific codes: `TRIGGER_EVENT_NOT_FOUND`, `TRIGGER_IMMUTABLE`, `TRIGGER_HAS_DEPENDENT_AUTOMATIONS`, `PAYLOAD_SCHEMA_EMAIL_REQUIRED`, `AUTOMATION_NOT_FOUND`, `AUTOMATION_VERSION_NOT_FOUND`, `AUTOMATION_NOT_PUBLISHED`, `AUTOMATION_GRAPH_INVALID`, `PUBLISH_VALIDATION_FAILED`, `NO_PUBLISHED_AUTOMATION`, `AUTOMATION_RUN_NOT_FOUND`, `EMAIL_NOT_FOUND`, `EMAIL_VERSION_NOT_FOUND`, `EMAIL_NOT_READY`, `EMAIL_ALREADY_SENT`, `EMAIL_IN_PROGRESS`, `DOMAIN_NOT_FOUND`, `DOMAIN_NOT_READY`, `AUDIENCE_NOT_FOUND`, `SEND_NOT_FOUND`, `CONTACT_NOT_FOUND`, `CORE_FIELD_IMMUTABLE`, `FIELD_NOT_FOUND`, `MISSING_EMAIL`, `BRAND_NOT_FOUND`, `BRAND_NOT_READY`. --- ## 7. Identifier prefixes | Identifier | Typical prefix | Notes | | --------------------- | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | | `triggerEventId` | `tri_` for `brew_api`; composite (`clerk:org_…:brand_…:user.created`) for integrations | ≤ 256 chars. | | `triggerInstanceId` | `tin_` | One per inbound fire; surfaced on idempotent replays. | | `automationId` | `auto_` | Stable across versions. | | `automationVersionId` | `av_` | One per persisted graph version (`'latest'` or numeric). | | `emailId` | `eml_` | Stable across edits. | | `emailVersionId` | `emv_` | One per persisted email version. Returned by every generate / edit. | | `automationRunId` | nanoid | One per workflow run started. Internal Convex column is still `executionId`. | | `domainId` | none (raw Convex id, e.g. `kx7…`) | Convex document id — NO `dom_` prefix. Treat as opaque; don't validate a prefix. | | `audienceId` | none (raw Convex id, e.g. `m57…`) | Convex document id — NO `aud_` prefix. Treat as opaque; don't validate a prefix. | | `idempotencyKey` | caller-defined | ≤ 100 chars. Namespaced per org server-side. | --- ## 8. TypeScript SDK (`@brew.new/sdk@6.x`) Install + use: ```ts import { createBrewClient } from '@brew.new/sdk' const brew = createBrewClient({ apiKey: process.env.BREW_API_KEY! }) // 1. Trigger — no `provider`, server hardcodes `brew_api` const { triggers } = await brew.triggers.create({ title: 'User Signed Up', payloadSchema: { type: 'object', fields: [ { key: 'email', type: 'string', required: true }, { key: 'firstName', type: 'string', required: false }, ], }, }) // 2. Email — `emailType` required const welcome = await brew.emails.generate({ prompt: 'Friendly welcome email', emailType: 'automation', }) if (!('emailId' in welcome)) throw new Error('agent declined') // 3. Domain const { domains } = await brew.domains.list() const domainId = domains[0]!.domainId // 4. Automation — list envelope const { automations } = await brew.automations.create({ name: 'Welcome flow', triggerEventId: trigger.triggerEventId, nodes: [ { id: 'trg', label: 'On signup', type: 'trigger', config: { triggerEventId: trigger.triggerEventId } }, { id: 'send_welcome', label: 'Welcome', type: 'sendEmail', config: { emailId: welcome.emailId, emailVersionId: welcome.emailVersionId, domainId, subject: 'Welcome — {{firstName | there}}', previewText: 'Thanks for signing up.', } }, ], connections: [{ from: 'trg', to: 'send_welcome' }], }) const automation = automations[0]! // 5. Publish await brew.automations.publish({ automationId: automation.automationId }) // 6. Fire — `metadata` is REMOVED; payload carries everything const fire = await brew.automationRuns.fire({ triggerEventId: trigger.triggerEventId, payload: { email: 'jane@example.com', firstName: 'Jane' }, idempotencyKey: 'signup-jane@example.com-1779292800', }) console.log(fire.automationRunIds) // 7. Inspect — list envelope const detail = await brew.automationRuns.get({ automationRunId: fire.automationRunIds[0]!, include: ['logs'], }) console.log(detail.runs[0]?.status, detail.logs) ``` SDK resource → endpoint map: | SDK property | HTTP endpoint | | ------------------------- | -------------------------------------------------------------- | | `brew.triggers.create` | `POST /v1/triggers` | | `brew.triggers.list` | `GET /v1/triggers` | | `brew.triggers.get` | `GET /v1/triggers?triggerEventId=…` | | `brew.triggers.patch` | `PATCH /v1/triggers` (metadata-only: `title`, `description`, `payloadSchema`) | | `brew.triggers.delete` | `DELETE /v1/triggers` | | `brew.emails.generate` | `POST /v1/emails` | | `brew.emails.edit` | `PATCH /v1/emails` | | `brew.emails.list` | `GET /v1/emails` | | `brew.automations.create` | `POST /v1/automations` | | `brew.automations.patch` | `PATCH /v1/automations` (update branch) | | `brew.automations.publish`| `PATCH /v1/automations { published: true }` | | `brew.automations.unpublish` | `PATCH /v1/automations { published: false }` | | `brew.automations.get` | `GET /v1/automations?automationId=…` | | `brew.automations.list` | `GET /v1/automations` | | `brew.automations.delete` | `DELETE /v1/automations` | | `brew.automationRuns.fire`| `POST /v1/automation/runs` (fire branch) | | `brew.automationRuns.test`| `POST /v1/automation/runs { mode: 'test' }` | | `brew.automationRuns.replay` | `POST /v1/automation/runs { mode: 'replay' }` | | `brew.automationRuns.list`| `GET /v1/automation/runs` | | `brew.automationRuns.get` | `GET /v1/automation/runs?automationRunId=…` | | `brew.automationRuns.cancel` | `PATCH /v1/automation/runs` (501 today) | | `brew.sends.create` | `POST /v1/sends` (campaign) | | `brew.sends.test` | `POST /v1/sends { mode: 'test' }` | | `brew.sends.list` / `listAll` | `GET /v1/sends` | | `brew.sends.get` | `GET /v1/sends?emailId=…` | | `brew.brand.get` | `GET /v1/brand` | | `brew.usage.get` | `GET /v1/usage` | | `brew.integrations.list` | `GET /v1/integrations` | | `brew.analytics.campaigns`| `GET /v1/analytics/campaigns` | | `brew.analytics.automations` | `GET /v1/analytics/automations` | | `brew.analytics.events` / `eventsAll` | `GET /v1/analytics/events` | | `brew.contacts.*` | `/v1/contacts` | | `brew.fields.*` | `/v1/fields` | | `brew.audiences.list` | `GET /v1/audiences` | | `brew.domains.list` | `GET /v1/domains` | | `brew.templates.list` | `GET /v1/templates` | | `brew.events.fire` | DEPRECATED — alias for `brew.automationRuns.fire` | SDK throws `BrewApiError` on every non-2xx response. The error exposes `code`, `type`, `message`, `requestId`, `param`, `suggestion`, `docs`, `retryAfter`. --- ## 9. Decision tree — "I want to…" - **Send one email to a saved list right now** → `brew.emails.generate({ emailType: 'campaign' })` → `brew.sends.create({ emailId, emailVersionId?, domainId, audienceId, subject })`. - **Send a welcome flow when a user signs up** → `brew.triggers.create(...)` → `brew.emails.generate({ emailType: 'automation' })` per body → `brew.automations.create(...)` → `brew.automations.publish(...)` → from your backend: `brew.automationRuns.fire({ triggerEventId, payload, idempotencyKey })`. - **Send a transactional email (password reset / receipt) on an event** → same as welcome flow. Use `emailType: 'transactional'` for the body (so it stays visible on the email canvas). - **QA an automation without delivering mail** → `brew.automationRuns.test({ automationId, payload })` then `brew.automationRuns.get({ automationRunId, include: ['logs'] })`. - **Edit a published automation safely** → `brew.automations.patch( { automationId, nodes, connections })` (creates a new `automationVersionId`) → `brew.automations.publish({ automationId, automationVersionId })` when ready. - **Manage recipient data programmatically** → `brew.contacts.*` + `brew.fields.*`. --- ## 10. Things to avoid - DO NOT send `provider` or `providerEventKey` to `POST /v1/triggers` → `400 INVALID_REQUEST`. Server hardcodes `brew_api`. Integration triggers come from the integration. - DO NOT send `metadata` to `POST /v1/automation/runs` (fire OR test branch) → `400 INVALID_REQUEST`. Use the trigger `payload`. - DO NOT send `emails: string[]` to `POST /v1/sends` → `400 INVALID_REQUEST`. Use `audienceId` (audience-only) OR fire an automation per-recipient. - DO NOT include `brandId` in any body / query → `400 INVALID_REQUEST`. Brand is resolved from the API key. - DO NOT call `/v1/executions` for new code — it's a deprecated alias of `/v1/automation/runs` sunset 2026-12-01. - DO NOT skip `Idempotency-Key` on retried writes — duplicate workflow runs (and duplicate emails) are the result. - DO NOT reference `emailType: 'campaign'` from an automation `sendEmail` node → `400 AUTOMATION_GRAPH_INVALID` / `kind: 'email_wrong_type'`. Use `automation` or `transactional`. - DO NOT skip `emailVersionId` when authoring `sendEmail` nodes — it's required on the strict API authoring path. --- ## 11. Determinism vs AI authoring The public HTTP API + SDK are **deterministic-only** for trigger / automation authoring. AI is scoped to **email body content** via `brew.emails.generate({ emailType, prompt })` only. 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). --- ## 12. Support - Always include `x-request-id` from the response headers. - Status / changelog: `https://docs.brew.new/changelog` - Repo / contracts (private): the Zod schemas under `lib//contracts.ts` are the canonical truth — anything here is derived from them.