Skip to main content
Looking for user-facing product updates? See the main changelog. Everything below is scoped to the Public API v1 surface and the TypeScript SDK.
MCP: more calls ask for approval, no contacts created from tool arguments, no AI media tools, technical-only flares

MCP Changes for the Claude Directory

The MCP connector changed for the Claude directory review (GetBrew/brew-v2#1854). The HTTP API and the SDK are unchanged except where noted.
  • Confirmation on more calls. On OAuth and organization connections, fire_trigger_event, delete_contacts, delete_contact_field, a save_automation that publishes, unpauses or stops contacts mid-flow, a control_email_send or control_audience_run resume, and a test to anyone but the connected person now return confirmation_required first, like campaigns. The same call with confirmed: true and the request’s confirmation_id runs it; the id holds for those exact arguments for 15 minutes and runs one call. See Confirmation.
  • No contacts from tool arguments. send_email no longer takes consent: inline to recipients must already be subscribed contacts. save_contact updates existing contacts only (createIfMissing and consent are gone). import_contacts_csv updates existing contacts and refuses unknown rows one by one; it no longer takes consent or validate (use validate_contacts). test_automation no longer creates the test payload’s contact. A fire_trigger_event for an address that unsubscribed is refused with RECIPIENT_UNSUBSCRIBED. Over the API, POST /v1/contacts and POST /v1/sends keep consent.
  • Removed from MCP: generate_image, create_gif and transform_image (the API keeps POST /v1/content/generate-image, POST /v1/content/gif and POST /v1/content/transform), and remove_domain_unsubscribe (the API keeps DELETE /v1/domains/{domainId}/unsubscribes/{email}).
  • Imagery made with Brew stays in the design. Over MCP, create_email and edit_email no longer save the scenes they generate to the brand library, and search_brand_images lists logos and brand images only (kind is logo or brand). GET /v1/brand/images and the app still list every kind.
  • sendingPurpose. create_domain and update_domain no longer take it. Set it in the app or with PATCH /v1/domains/{domainId}.
  • send_flare takes technical diagnostics only: tool names, request ids, status and error codes, Brew ids, the client, and a summary of up to 500 characters. The narrative fields are gone.
  • create_audience with sourceAudienceId takes an optional name for the copy.
  • DELETE /v1/emails/{emailId} on an already-deleted design now returns the documented deletedAt.
Breaking: the data command is removed from the API, MCP, SDK, and CLI; typed reads answer the same questions

The Data Command Is Removed

Breaking. POST /v1/data is gone (GetBrew/brew-v2#1825). It ran a command line such as db find contacts --fields email | jq … over the brand’s tables and answered { exitCode, output, truncated }. Everyday reads now go through typed endpoints, each with its own request and response shape, scope, and rate limit.Removed:
  • POST /v1/data (runDataCommand) and the DataCommandResponse schema
  • The MCP tool query_brew_data (first shipped as run_data_command)
  • brew.data.run() in the TypeScript SDK, from SDK 12.0.0
  • brew-cli data run, from the next CLI release
  • The data topic of the MCP tool get_brew_capabilities

What to Use Instead

No equivalent: table discovery (db ls, db schema) and jq pipelines. Read the typed endpoint and filter its result; writes go through each resource’s own endpoints. The reads that only the data command used to serve (insights, comment threads, the chat list, notifications, score history, and open-time profiles) shipped first, in the entry below.A question that joined tables becomes two typed reads. For example, read an audience with GET /v1/audiences/{audienceId}, then its members with POST /v1/contacts/search and that audienceId.
  • SDK: brew.emails.list, brew.emailGroups.list, brew.contacts.search, brew.contacts.count, brew.contacts.countBy, brew.audiences.list, brew.audiences.get, brew.automations.list, brew.automations.runs.list, brew.domains.list, brew.sends.list, brew.analytics.overview, brew.analytics.events and brew.analytics.eventCounts. Since SDK 11.6.0: brew.insights.list (include adds pulse, report, suggestions, and memo) and brew.insights.get, brew.emails.comments.list, brew.chats.list, brew.notifications.list, brew.domains.health({ domainId, include: ['scoreHistory', 'scoreRuns'] }), and brew.contacts.get(email, { include: ['openProfile'] }) or brew.contacts.search({ include: ['openProfile'] }).
  • CLI: emails list, emails groups list, contacts search, contacts count, contacts count-by, audiences list, audiences get, automations list, automations runs list, domains list, sends list, analytics overview, analytics events and analytics event-counts. Since CLI 0.13.0: insights list, insights get, emails comments list, chats list, notifications list, and --include on domains health, contacts get, and contacts search. brew-cli api still sends any raw request.
New: read insights, comment threads, chats, and notifications; domain score history and contact open-time profiles; SDK 11.6.0 and CLI 0.13.0

Read Insights, Comments, Chats, and Notifications

New typed reads, all free, cover what the app shows on the Insights page, in comment threads, in the chat list, and in the notifications bell. Two existing reads gain a domain’s score history and a contact’s open-time profile.New: GET /v1/insights lists the brand’s Brew Insights findings as the Insights page ranks them, most severe first. state=open (the default) lists active findings, and state=all adds resolved, cleared, dismissed, and stale ones. severity keeps one severity. Every page carries freshness: dataAsOf says how current the data behind the findings is, and latestAttempt.status: "failed" means they may be stale. include adds what the page shows beside the findings: pulse (the last 7 days against the 7 before), report (the latest intelligence report), suggestions (its open suggestions, up to 25), and memo (the analysis agent’s memory across runs). pulse, report, and memo are null until they exist, and suggestions is an empty array when there are none. The engine keeps at most 200 findings in view. The list is ranked again on every request, so its cursor remembers which findings it has returned: if findings are added, removed, or re-ranked above it, the next page is 400 INVALID_REQUEST with param: "cursor" instead of a page that skips or repeats findings, and you read again from the first page with the same state and severity. A change below the cursor is served when its page comes, so a finished walk returns every finding exactly once. A cursor sent with a different state or severity is refused the same way.New: GET /v1/insights/{insightId} reads one finding in full. Its frozen metrics are the only numbers to quote about it. It also carries evidence links, rationale, the detector’s method (what it measures and what resolves it), and generatedBy (the run that produced it and what that run could not see). An unknown id and another brand’s id both answer 404 INSIGHT_NOT_FOUND. Both insight reads need the emails scope and count against analytics.read. See Read Brew Insights.New: GET /v1/emails/{emailId}/comments lists a design’s open comment threads, newest activity first: commentId, target (the whole email or one element), the 12 most recent participants with participantCount, messageCount, lastMessagePreview, and a link. include=messages adds each thread’s newest messages and caps the page at 3 threads, which share a 12,000-character message budget. A thread with older messages returns messagesCursor: send it back with that thread’s commentId for the next older slice, until it comes back null. Resolving a thread deletes it, so only open threads are listed, and a commentId that isn’t an open thread answers 404 COMMENT_NOT_FOUND. An email with no threads is an empty page; an unknown email, or another brand’s, is 404 EMAIL_NOT_FOUND. In body and lastMessagePreview, each mention reads @ plus the name mentions gives with its userId. See Comments.New: GET /v1/chats lists the brand’s Brew chats, most recently active first: chatId, title, the opening prompt, Brew’s latest reply, status, origin, and a link. No transcript; GET /v1/chats/{chatId} still resumes one chat with its artifacts and recent messages.New: GET /v1/notifications lists what the app’s bell shows, newest first: generations, sends, imports, domain checks, and score runs finishing or failing. Filter with type. Any key can call it, and each row is shown only when the credential may read what it describes: contacts for imports and validations, sends or domains for sends and domain checks, and so on. api_key_created needs the all scope, and send_limit_reached reaches organization admins only, never a brand key. Comment mentions and replies reach only the person they’re addressed to, so an API key never sees one. An empty page can therefore mean nothing happened or that the credential can’t see that kind of row; a type it can’t see is an empty page, not an error. Reading marks nothing read. A page can hold fewer rows than limit while hasMore is true, so follow the cursor until hasMore is false. The full per-type table is in Authentication.Changed: GET /v1/domains/{domainId}/health takes include. include=scoreHistory adds scoreHistory, up to 50 saved score snapshots, newest first, each with its grade, confidence, the event that saved it, and every pillar’s score and weight. include=scoreRuns adds scoreRuns, the last 5 automated domain score runs, each with its status, the score it ended on, the credits it cost, and every variant’s placement test.Changed: contact reads take openProfile. GET /v1/contacts/{email}?include=openProfile and POST /v1/contacts/search with include: ["openProfile"] attach the open-time profile that Intelligent send’s per-recipient timing reads. It holds open counts per UTC half hour and, once there is enough history, the best send time. It is null before the first open. It needs the emails scope as well as contacts (403 INSUFFICIENT_PERMISSIONS without it). A search with it returns at most 10 contacts per page, and it can’t be combined with count: true. Machine opens can’t be told apart in the profile, so it is not bot detection. See Explore Per-Contact Engagement.MCP: list_insights, get_insight, list_email_comments, list_chats, and list_notifications are new, get_domain_health takes include: ["scoreHistory", "scoreRuns"], and search_contacts takes include: ["openProfile"]. list_insights reads 10 findings per page and cuts long text and expansions to fit one tool result; truncated names what it cut, and the HTTP read always answers whole.
  • SDK 11.6.0: brew.insights.list() and brew.insights.get(insightId), brew.emails.comments.list({ emailId }), brew.chats.list(), and brew.notifications.list(). brew.chats.listAll() and brew.notifications.listAll() page through everything (the notifications pager keeps going through short pages), and brew.emails.comments.listAllMessages({ emailId, commentId }) walks one thread back to its first message. include on brew.domains.health(), brew.contacts.get(), and brew.contacts.search().
  • CLI 0.13.0: brew-cli insights list and insights get, brew-cli emails comments list, brew-cli chats list, and brew-cli notifications list. --include on domains health, contacts get, and contacts search.
New: delete a brand image, and upload a local image file to the brand library; add-image returns assetId; SDK 11.5.0 and CLI 0.12.0

Delete a Brand Image, and Upload a Local File

New: DELETE /v1/brand/images/{assetId} removes one image from the brand library, as Delete image on the Assets page does. Pass the assetId that GET /v1/brand/images returns. It answers { assetId, deleted }, and an assetId that isn’t in the library answers deleted: false and changes nothing, so a repeat is safe. The image leaves the library and image search, but its file stays hosted at its URL, so emails already using it keep rendering. Logos are refused with 400 INVALID_REQUEST; manage them on the Assets page. Free, and on the brand.write rate limit. MCP: delete_brand_image.New: upload an image file from your machine. A JSON body can’t carry a file that size, so it takes three calls, all free:
  1. POST /v1/content/image-uploads with { fileName, contentType, size } answers 201 with { uploadId, uploadUrl, expiresAt, maxBytes }. PNG, JPEG, GIF, WebP, AVIF, TIFF, or SVG, up to 20 MB (SVG 2 MB). A brand can hold 20 uploads open at once, and the next one is 429 RATE_LIMITED.
  2. POST the raw bytes to uploadUrl within 15 minutes, with no Authorization header: curl -X POST --data-binary @logo.png "<uploadUrl>". The URL carries its own credential, so keep it private. Sending again never replaces the first file.
  3. POST /v1/content/add-image with { uploadId } within 15 minutes of the bytes landing. The bytes decide the format, and repeating the call returns the same answer for 24 hours.
New error codes: UPLOAD_NOT_FOUND (404, unknown or expired), UPLOAD_NOT_RECEIVED (409, the bytes were never sent), and UPLOAD_IN_PROGRESS (409, another call is converting it; retry). See Upload a Local Image.Changed: POST /v1/content/add-image returns assetId for imageUrl and uploadId, so you can delete what you just added. It still takes imageUrl or imageUrls, and it stays free.MCP: create_image_upload opens the upload, and add_image takes uploadId. Only a client with a shell or an HTTP tool on the machine holding the file (Claude Code, Cursor) can send the bytes; a chat-only client asks for a public link and uses add_image with imageUrl.
  • SDK 11.5.0: brew.brand.deleteImage(assetId), brew.content.createImageUpload({ fileName, contentType, size }), and brew.content.uploadImage({ file, fileName, contentType? }), which runs all three steps (it checks the size before sending and doesn’t retry opening the upload). brew.content.addImage() takes { uploadId }, and its answer carries assetId.
  • CLI 0.12.0: brew-cli content upload-image <file>, brew-cli content create-image-upload, brew-cli content add-image --upload-id, and brew-cli brand delete-image <assetId>. Also fixed: templates list with count or groupBy no longer crashes.
See Assets for the app and agent paths.
Flows count themselves and one flow has its own MCP tool; SDK 11.4.0 and CLI 0.11.0

Flows Count Themselves, and One Flow Has Its Own Tool

GET /v1/flows returns total and isTotalExact. total counts every flow the query matches across all pages: the filters narrow it, semantic only orders it. One limit=1 call answers how many flows there are. isTotalExact is false when the read was cut at 500 flows (the newest, or the 500 nearest a semantic query), and total is then a floor.A semantic search that cannot run is now 503 SERVICE_UNAVAILABLE with Retry-After: 300, instead of an empty page that read as “no flows match”. Retry without semantic: brand, category, type and sort still narrow and order the list.MCP: get_flow reads one flow. list_flows is text only and takes no slug or include: it names every flow on the page and leads with the total. get_flow takes slug and include: ["html"] and shows the sequence’s emails. A list_flows call that passes slug is refused; call get_flow instead.
  • SDK 11.4.0: FlowsListResponse carries total and isTotalExact. brew.emailGroups.create and .update take emailIds (move up to 50 designs into a folder in one call) and return moved and notMoved; update needs name, emailIds, or both. The creator fields (createdBy, createdByUserId, publishedBy, publishedByUserId) are typed on emails, email groups and automations.
  • CLI 0.11.0: flows list leads with how many flows match (167 flows in total; 25 on this page), and --json passes total and isTotalExact through. emails groups create and update take --email-ids, and update no longer requires --name.
Fixed: SDK timeouts and cancellation cover the response body; typed transport errors carry the idempotency key

Timeouts and Cancellation Cover the Response Body

Fixed in SDK 11.3.0. timeoutMs and a caller’s AbortSignal stopped at the response headers: the SDK cleared its timer and detached the signal as soon as the headers arrived, so a body that stalled afterwards had no deadline and could not be cancelled, and a connection that dropped mid-body skipped every retry. Both now cover the whole attempt, body included. Reproduced in SDK 10.0.0, 11.0.0 and 11.2.0; the API is unchanged. A response that takes longer than timeoutMs to stream now fails where it used to succeed: raise timeoutMs if you relied on that.
  • SDK 11.3.0: a request with no usable answer throws BrewTransportError — BrewTimeoutError (name: 'TimeoutError') or BrewConnectionError — instead of a bare AbortError or TypeError, and a 2xx that is not JSON throws BrewParseError. Every error, BrewApiError included, carries the idempotencyKey its request used, so a write whose outcome is unknown can be replayed instead of repeated. retryOnTimeout: false makes a timeout final, and createBrewClient({ signal }) cancels every request on the client. A cancel is never retried and also stops a retry backoff. content.gif, content.generateImage and emails.import get their own per-call timeouts (300, 180 and 300 s). See Timeouts and cancellation.
  • CLI 0.10.0: Ctrl-C and SIGTERM stop the request in flight (exit 130 / 143), --timeout <duration> bounds a whole command, body included, and --max-retries sets the retry count. A write whose outcome is unknown prints its idempotencyKey and a retryCommand that replays it. See Replaying an unknown outcome.
New: grouped email-event counts on GET /v1/analytics/events; per-domain unsubscribe lists

Event Counts and Domain Unsubscribe Lists

New: count events instead of listing them. GET /v1/analytics/events takes groupBy (one or two comma-separated fields: eventType, emailId, automationId, sendId, source, link, recipientDomain, unsubscribeReason) and bucket (day, week or month, UTC, weeks start Monday), alone or together. With either one it counts the email events the same filters list and answers { count, groups: [{ key, bucket?, count }], otherCount, range, truncated, coveredFrom? }, largest 200 groups first. Clicks per link is groupBy=link&eventType=clicked, and an unsubscribe counts once per reason it gave. It counts events, not unique recipients; GET /v1/analytics/overview opened and clicked count unique human recipients. One count reads the newest 20,000 events in the window, and a busier window answers truncated: true with coveredFrom. cursor and automationRunId are refused with 400, and limit is ignored. MCP get_email_analytics takes the same groupBy and bucket on report.kind: "events". See Explore per-contact engagement.New: every marketing domain has its own unsubscribe list. With the domains scope, the list lives under /v1/domains/{domainId}/unsubscribes: GET pages it (?scope=any|domain|all, ?q= to search), POST adds up to 1,000 addresses, DELETE …/{email} takes one off, POST …/import reads another ESP’s CSV (10,000 rows per call), and GET …/export returns it as CSV. An address on a domain’s list stops only that domain’s mail. subscribed: false on the contact stays the brand-wide opt-out, and removing an address from a domain’s list never clears it. An added address with no contact is created already unsubscribed brand-wide. A transactional domain has no list (422 DOMAIN_PURPOSE_NOT_ALLOWED). MCP: add_domain_unsubscribes, import_domain_unsubscribes, remove_domain_unsubscribe.
  • SDK 11.2.0: brew.analytics.eventCounts({ groupBy, bucket, ... }) and brew.domains.unsubscribes.list|add|remove|import|export, plus dateOrder and consent on brew.contacts.importCsv, consent on upsert, upsertMany and patch, and dryRun on brew.automations.create (typed as the dry-run report).
  • CLI 0.9.0: brew-cli analytics event-counts --group-by --bucket, brew-cli domains unsubscribes list|add|remove|import|export, and contacts import-csv --date-order --validate --consent-source.
Breaking: brand images list the whole asset library by kind and sort; type and aspectRatio are retired; generated images are saved to it

Brand Images Cover the Whole Asset Library

Changed: everything the Assets page shows. GET /v1/brand/images and MCP search_brand_images now list the brand’s whole asset library: logos, brand images (from the site or uploaded, including the social preview and the site screenshot), and images made with Brew.
  • kind narrows to logo, brand, or generated. Omit it for every kind.
  • sort orders a browse: newest (the default) or oldest.
  • q searches brand and generated images by what they show, in relevance order, and ignores sort. It costs 1 credit for the first page, and cursor pages after it are free. Logos are not searchable, so q with kind=logo is a 400 INVALID_REQUEST, refused before any charge.
Breaking: type and aspectRatio are retired. Sending either is a 400 INVALID_REQUEST. The message for type points you to kind, and the one for aspectRatio says it is no longer a filter. Every row still carries width and height, so filter by shape on your side.Breaking: a new row shape. Each row carries assetId, kind, and url, plus what is known: description, width, height, category, pageUrl, addedAt (ISO 8601), and on a logo, logo ({ type, theme, background, format }). assetId is 8 hex characters, the id the app opens at /assets?image=<assetId>. description is the one the app shows; a search result falls back to the machine-written caption only when an image has none. Rows no longer carry aspectRatio or prompt.Changed: browsing pages by key. A browse cursor names the last rows it returned, so uploads and deletes during a walk don’t shift its pages (see Pagination). It only continues the same sort and kind. A search pages with a signed cursor that only continues the same q and kind.Changed: generated images are saved. POST /v1/content/generate-image and MCP generate_image now save each image to the brand’s generated images, as images made in the app’s chat already were. List them with GET /v1/brand/images?kind=generated.
  • SDK 11.2.0: brew.brand.getImages() takes { q, kind, sort, limit, cursor }. type and aspectRatio are gone.
  • CLI 0.9.0: brew-cli brand get-images takes --kind and --sort in place of --type and --aspect-ratio.
The app’s Assets page shows the same list. It filters by All, Brand images, Generated, and Logos, sorts newest or oldest first, and no longer has a Shape filter. See Assets.
Breaking: publish refuses a merge tag the trigger doesn't declare; a fire names every automation it could not start

Undeclared Merge Tags and Fires That Start Nothing

Breaking: publish refuses every unresolvable reference. An automation whose email body, subject, preview text, fromName or replyTo uses a trigger field the trigger doesn’t declare, such as {{ trigger.code }} on a trigger that declares only email, now fails PATCH /v1/automations/{automationId} with published: true (and MCP save_automation) as 422 PUBLISH_VALIDATION_FAILED, with or without a | fallback. It used to publish, and then every live fire was refused without saying so. The same applies to a path under a key the trigger doesn’t declare ({{ trigger.order.total }} with no order), a contact property in fromName or replyTo (they resolve once per fire, from trigger fields only), and a dotted triple-brace token such as {{{ trigger.code }}}, which never renders. Every blockingIssues[] entry of a dry run now blocks publish; fatal only says what the reference would do at send time. Fix it by declaring the field on the trigger or changing the tag.New: notStarted[] on a fire. POST /v1/automations/triggers/{triggerEventId}/fire and MCP fire_trigger_event add notStarted: [{ automationId, reason }] for each matched automation whose run failed to start. A fire used to answer 202 triggered with empty automationRunIds and no explanation. A refusal in reason, such as a merge tag the trigger does not declare, repeats on every fire until fixed, and a receipt whose every failed start was a refusal moves to dead_letter at once instead of being retried. An unexpected error is retried automatically.Trigger edits that would break a published automation are refused. Removing or retyping a field a published automation uses was already refused; declaring the shape of an open object or list so one of its tags no longer resolves now is too. PATCH /v1/automations/triggers/{triggerEventId} answers these with 409 CONTRACT_LOCKED_BY_PUBLISHED_AUTOMATIONS, like the contract endpoint, instead of 500.
Fixed: day-first dates import in the right month; new dateOrder on CSV import and a DATE_ORDER_ASSUMED warning

Day-First Dates

Fixed: a day-first date column imports in the right month. Brew now reads every date in a CSV column, or in one field across a batch (POST /v1/contacts, POST /v1/contacts/import-csv, MCP import_contacts_csv), in one day/month order. A day over 12 anywhere in the column settles it, so a UK file’s 03/04/2026 is 3 April, like its 13/04/2026. Each date used to be read on its own, and 03/04/2026 was stored as 4 March.New: dateOrder on CSV import. day_first or month_first decides a column whose dates all read either way. Without it, such a column reads month first and the response carries a DATE_ORDER_ASSUMED warning that names the field.Changed: more formats, and nothing guessed. Dotted dates (03.04.2026) read day first, and year-first (2026/04/03) and compact (20260403) dates are accepted. A value in none of the listed formats, such as a bare 2026, is refused with FIELD_TYPE_MISMATCH instead of being guessed. A single PATCH /v1/contacts/{email} value still reads an ambiguous date month first. See date formats.
Changed: a contact search for one whole email address returns only that contact; other text matches words on every brand

Contact Search by Address

Changed: a whole address is an exact lookup. A contact search that is one whole email address (POST /v1/contacts/search, GET /v1/contacts?search=, MCP search_contacts) now returns only the contact with that address, or nothing when there is none. It matches like filters: [{ field: "email", operator: "equals", value }], so letter case and surrounding spaces don’t matter. It used to match any word of the address (alice@gmail.com matched every contact with gmail or com), newest first, so the contact searched for was often not the first row and an address with no contact still returned other people.Changed: other text matches words on every brand. Any other search text matches the contacts whose email, first or last name contains any of its words, in sort order. A brand whose contacts all came through the API used to match it as one exact substring instead; the first search on such a brand may still answer that way while its index builds. It can return other contacts, so update or delete a contact you found by its address.In GET /v1/contacts?search=, encode an address’s + as %2B: an unencoded + reads as a space, which makes the address a word search.
Breaking: re-subscribing an opt-out and immediate sends that reach nobody are refused; verificationStatus removed; valid means a deliverability check ran

Subscriptions, Verdicts, and Who a Send Reaches

Breaking: re-subscribing is refused. subscribed: true only applies to a new contact. A single write that asks to re-subscribe a contact who opted out (PATCH /v1/contacts/{email}, a single POST /v1/contacts, MCP update_contact) answers 422 RESUBSCRIBE_NOT_ALLOWED and writes nothing. A batch or CSV row keeps its other fields and adds a RESUBSCRIBE_SKIPPED warning naming the contact. A contact who asks to receive marketing email again is re-subscribed by a team member from their contact page in the app.Changed: subscribed: false reaches existing contacts. An upsert, batch or CSV import that sets subscribed: false (or an email platform’s status word such as unsubscribed or cleaned) now unsubscribes a contact that already exists. It used to apply to new contacts only.Breaking: an immediate send that would reach nobody is refused. POST /v1/sends and MCP send_email answer 422 NO_ELIGIBLE_RECIPIENTS (details.eligibility carries the counts) when every contact the send targets is unsubscribed, suppressed or undeliverable, and no send is created. Such a send used to be created and then fail. A scheduled send is still accepted, since its audience can change before it goes out. A 202 whose send will skip contacts carries a RECIPIENTS_EXCLUDED warning with the counts by reason.Changed: valid means a deliverability check ran. validationStatus: "valid" now only comes from a deliverability check (validate: true on ingest, or POST /v1/contacts/validate), which also sets lastValidatedAt. Without one, the free format check stores risky or invalid and otherwise leaves the field unset. A valid with no lastValidatedAt came from the format check. Filters that negate the verdict, such as “is not invalid”, include contacts that carry none.Breaking: verificationStatus removed. Contact rows no longer carry the deprecated verificationStatus mirror; read validationStatus. Filters that name verificationStatus still resolve.Changed: one email rule. Every address a write or send accepts follows the rule imports use (dot-atom, up to 254 characters), published in the spec as a pattern instead of format: email, so an address that could be imported can also be looked up, deleted and sent to. Lookups by email accept any address and report an unknown one as not found.
Fixed: the credit balance fell twice as fast past the plan cap; GET /v1/usage now readable by every key

Credit Balance Past the Plan Cap, and Usage for Every Key

Fixed. Once an organization had used its plan credits and was spending bonus credits, every balance readout subtracted the overflow twice: X-Credits-Remaining on a 1-credit call moved by 2, the Billing & Usage page understated the balance by the overflow, and organizations still holding bonus credits could be refused with 402 INSUFFICIENT_CREDITS. Charges themselves were always correct: one ledger entry per call at the published cost, and bonus credits debited exactly once. Nothing is owed and no balance changed; the readouts and the gate now report the real balance, which is the plan credits left this period plus the bonus balance.Changed. GET /v1/usage and the MCP get_usage tool are readable by every API key and MCP connection in the organization, brand-scoped keys and brand members included. Previously they answered 403 INSUFFICIENT_ROLE for anything but an organization-scoped key, which left a brand key no way to measure a usage-metered call. Read the balance before and after the call as the credits page describes.
Fixed: automation runs filter by recipientEmail; trigger-fire refusals keep their field errors in the SDK

Automation Runs Filter By Recipient

GET /v1/automations/runs?recipientEmail= (and the MCP list_automation_runs tool) now applies the filter. It was accepted and documented but ignored: the unfiltered page came back with no warning. The match is case-insensitive and combines with automationId, status, mode, and the from/to window.

Trigger-Fire Refusals Keep Their Details

POST /v1/automations/triggers/{triggerEventId}/fire answers a payload mismatch with 400 INVALID_PAYLOAD (status: "payload_mismatch") and details.errors[] naming every offending field, plus details.payloadSchema to repair against. The reference now documents that shape; its example previously showed a PAYLOAD_MISMATCH code the API never sent. The same body fails the same way on retry, so read details instead of retrying.
  • SDK 9.3.0: BrewApiError maps the legacy fire envelope — the real code, a type derived from the HTTP status, fix-the-request advice for a 4xx — and gains details and body. Before 9.3.0 a fire refusal surfaced as unknown_error / internal_error with retry advice.
  • CLI, from the next release: automations triggers fire and api POST …/fire print the field errors, and --json carries details inside the error envelope.
Deprecated: API key management is dashboard only on every programmatic surface

API Key Management Is Dashboard Only

The three /v1/api-keys operations now require a signed-in Clerk session whose active organization role is exactly org:admin. API key and OAuth actors receive 403, so a compromised key can never mint or revoke another. The spec marks all three sessionAuth:
  • GET /v1/api-keys
  • POST /v1/api-keys
  • DELETE /v1/api-keys/{keyId}
Nothing changes in the dashboard. Create, rotate, and revoke keys at Settings, then API, the way the key lifecycle has always described.What this means on each surface:
  • MCP never exposed key management and still does not. An MCP credential cannot prove that the human behind it holds the organization’s org:admin role.
  • SDK: brew.apiKeys.list(), create(), and revoke() are marked @deprecated. They compile and call, and the platform answers 403. They are removed in the next major.
  • CLI: brew-cli api-keys list|create|delete are deprecated. Rather than spend a round trip on a guaranteed rejection, they now fail immediately with exit code 2 and a message pointing at the dashboard. The command names, flags, and routes stay documented until the next major removes them.
No migration is needed for code that reads or sends email. If you provision keys from a script, move that step to the dashboard, or tell us about your SOC2 or SAST pipeline and we will scope a real answer.
Public email flows on the API, MCP, and CLI

Email Flows

GET /v1/flows exposes the public flows gallery: one brand’s real onboarding or newsletter sequence, with the day each email landed. It is organization-wide, like GET /v1/templates, shares the templates.read budget, and returns the { data, pagination } envelope.
  • List cards with ?brand=, ?category=, ?type=signup|newsletter, ?semantic= (relevance-ranked search), and ?sort=newest|emails|span|remixes. Each card carries brand, title, type, category, emailCount, spanDays, remixCount, and previewImages.
  • Fetch one with GET /v1/flows/{slug} (the brand domain) → the bare flow with anchor (what day 0 means) and steps[]: order, dayOffset, delayDays (the wait since the previous email), subject, previewText, category, previewImage, and emailId. Add ?include=html for each step’s rendered HTML. An unknown slug is a 404 FLOW_NOT_FOUND.
  • A step’s emailId is a template reference: pass it as referenceEmailId on POST /v1/emails to rebuild that email for your brand, or look it up on GET /v1/templates.
Over MCP these are the list_flows and get_flow tools and the brew://flows resource; in the CLI, brew-cli flows list (--all) and brew-cli flows get <slug> (--include html). In the TypeScript SDK, brew.flows.list() and brew.flows.get(slug, { include }) (SDK 10; the 9.2 list({ slug }) mode is gone with the v1 cleanup).
Breaking: the transactional email object and its routes are removed

Transactional Email Is an Automation

Breaking. The standalone transactional email object is gone, along with every /v1/transactional* route. A transactional email is now an automation whose Send Email step uses a domain with sendingPurpose: 'transactional'. The domain purpose is what removes the unsubscribe requirement and delivers to unsubscribed contacts. The delivery class now comes from the domain, not from a dedicated object.Removed:
  • GET /v1/transactional/{transactionId}, including ?include=skill
  • The transactionId field on POST /v1/sends
  • The TRANSACTIONAL_EMAIL_NOT_FOUND error code
  • brew.transactional.get() in the TypeScript SDK, the get_transactional_email MCP tool, and brew-cli types --transaction

Migrate a fire

Replace the send call with a trigger fire. The recipient moves into the payload as email, and the design reads the rest as {{ trigger.* }}.
In the SDK, brew.emails.send({ transactionId, to, payload }) becomes brew.automations.triggers.fire({ triggerEventId, payload }).Read the contract with GET /v1/automations/triggers?triggerEventId= (MCP list_triggers). Pre-flight a fire with GET on the fire URL itself (MCP check_trigger_ready). That returns the payload schema and the published automations a fire would match, without sending anything.

What else changed

An unknown id now returns 404 TRIGGER_EVENT_NOT_FOUND. A payload that fails its schema returns 400 INVALID_PAYLOAD (status: "payload_mismatch") in the fire envelope, with details.errors[] naming the offending fields. A trigger with nothing published returns 422 NO_PUBLISHED_AUTOMATION.Payload contracts are declared on the trigger as payloadSchema, not derived from a pinned design. A field is optional when required: false. brew-cli types now emits a type per trigger and needs only the automations scope.Set a domain’s purpose with POST /v1/domains { sendingPurpose }, and change it later with PATCH /v1/domains/{domainId}. Filter with GET /v1/domains?sendingPurpose=.See Emails vs Automations for the model and Build an Automation for the walkthrough.
Set an email's subject line from the API, MCP, and CLI

Subject Lines on Every Email Write

Every design carries a default inbox subject (subjectLine), and until now only the app could set it, so designs created through the API, MCP, or CLI landed without one. All four write surfaces now accept an optional subjectLine of 1 to 250 characters:
  • POST /v1/emails (MCP create_email_design). The email agent also sees the subject, so the preview text and opening copy complement it instead of repeating it.
  • POST /v1/emails/import (MCP import_email_design)
  • POST /v1/emails/figma (MCP import_figma_design)
  • PATCH /v1/emails/{emailId} (MCP edit_email_with_ai)
Each response echoes the persisted subjectLine, and GET /v1/emails?emailId= already returns it on the detail row.prompt is now optional on PATCH /v1/emails/{emailId}. Send subjectLine on its own and Brew sets the subject in place: no AI run, no new version, no credits. Send both and the subject lands on the new version the edit produces. At least one of the two is required, and emailVersionId still requires prompt, because pinning a source version means nothing for a subject change.A subject line is the design’s default, not the send. POST /v1/sends still takes its own subject per delivery.In the CLI this is --subject-line on emails generate, emails import, emails import-figma, and emails edit.
A raw-content email audit with a versioned complete or partial response

Unified Email Audit

POST /v1/emails/audit replaces the saved-design accessibility endpoint. It accepts { emailHtml, subject?, previewText?, sendingPurpose? } and checks unsubscribe content, links, images, loaded size, accessibility, compatibility, markup, subject copy, and preview copy in parallel.The response has a versioned schema and a completion discriminator:
  • complete returns readiness, a 0 to 100 score, and X-Credit-Cost: 5;
  • partial preserves finished checks, returns completion.score: null and X-Credit-Cost: 0, and never establishes readiness.
The old email-id route is removed. Use brew.emails.auditEmail(...) in the TypeScript SDK.See Audit an Email for the product workflow and examples for every interface.
Run and copy any API capability from the dashboard

The API Catalog

The dashboard now has an API tab at brew.new/api that runs eleven v1 capabilities against your own brand and hands you the exact call afterwards. No endpoints changed, and nothing here is new API surface.
  • Covers Generate Email, Edit Email, Figma to Email, HTML to PNG, Image to GIF, Optimize Image, Resize Image, Validate Contacts, Email Audit, Email Inbox Preview, and Inbox Placement, with each one’s credit cost on the row.
  • Every parameter you set rewrites a live request snippet, switchable between cURL, TypeScript (SDK), TypeScript, and Python. Copied snippets always carry the brew_your_api_key placeholder rather than the key the in-app run used.
  • Each capability also carries a copyable implementation prompt for a coding agent: endpoint, docs link, cost, the BREW_API_KEY auth pattern, the SDK method, the equivalent MCP tool, every request field, and an example body.
Runs are real API calls, so they charge credits and write to your brand exactly as a call from your backend would. Full walkthrough in API Catalog.
Validate contacts on ingestion & write-back; validationStatus rename

Public API v1: Validate Contacts as You Add Them

Contact deliverability validation now persists and reaches the ingestion endpoints:
  • POST /v1/contacts/validate now writes the verdict back onto matching contacts (it was read-only before), and each result adds risk, isDisposable, and isRole signals alongside the existing reason and didYouMean.
  • POST /v1/contacts and POST /v1/contacts/import-csv accept an optional validate: true, each address is deliverability-checked as it’s ingested and the verdict is saved. Metered 2 credits per address, charged only on success. Up to 100 addresses validate inline (the response carries a validation count summary); larger submissions upsert first and validate as a background job, returning a validationJobId.
  • The contact’s verdict field is renamed verificationStatus → validationStatus. verificationStatus is dual-emitted with the same value for back-compat, so existing integrations keep working.
  • Also available via the create_contact, import_contacts_csv, and validate_contacts MCP tools.
Figma imports on the API, and full SDK parity

Figma to Email on the API, MCP, and SDK

Converting a Figma frame into an editable email was previously only possible in chat. It is now a first-class operation on every surface:
  • POST /v1/emails/figma takes { figmaUrl, title?, format?: 'jsx' | 'html' } and returns 201 { emailId, emailVersionId, title, format, content, warningCount, exportedNodeCount, previewImage? }. The figmaUrl must include a node-id, which is the link to one specific frame rather than the whole file.
  • The conversion is deterministic, with no model in the loop, so the same frame always converts the same way and the call is free.
  • Every surface uses the API-key brand’s connected Figma integration. Credentials are never accepted in the request or retained in API/MCP history. Without a usable connection you get 422 FIGMA_NOT_CONNECTED.
Also available as the import_figma_design MCP tool and as brew.emails.importFigma(...) in the SDK.

Every v1 operation now has an SDK method

The TypeScript SDK had drifted behind the API. These methods are new:
  • brew.emails.importFigma(), brew.emails.clone(), brew.emails.export()
  • brew.emails.createInboxPlacementTest() and brew.emails.getInboxPlacementResults(), the seed-list test of whether a design lands in the inbox or in spam
  • brew.sends.pause() and brew.sends.resume(), the reversible pair for an in-flight gradual send
  • brew.analytics.overview() for the exact totals, rates, and timeseries shown in Brew
  • brew.automations.run() plus brew.automations.audienceRuns.list() and .control() for manual-audience launch, scheduling, pause, resume, and cancel
  • brew.domains.health() for the aggregate deliverability score and signals
API v1, email checks and contact deliverability validation

Public API v1: Check an Email and Your List Before You Send

Two pre-send quality checks shipped on the Public API, the MCP tools, and the in-app agent:
  • The first email audit checked WCAG 2.1 accessibility for a saved design. The August unified audit replaced it with production-readiness checks and a versioned complete or partial response. See the August entry above.
  • POST /v1/contacts/validate now runs a real deliverability check on up to 100 addresses at once, each comes back valid, risky (role account, disposable, catch-all), or invalid, with a machine-readable reason and a didYouMean typo correction. Metered 2 credits per address, charged only on success.
  • Contact validation remains available as the validate_contacts MCP tool.
API v1, preview a design across real inboxes & devices

Public API v1: See How an Email Renders in Real Inboxes

New POST /v1/emails/{emailId}/client-previews renders a design’s latest version across real email clients & devices: Gmail, Outlook, Apple Mail, iOS (with dark-mode variants), plus Yahoo, and returns a screenshot per client, rehosted on the Brew CDN.
  • Pass clients (ids from the supported catalogue) to target specific inboxes/devices, e.g. outlook2021_win11_dm_dt for Outlook 2021 on Windows in dark mode, or send {} for a popular default spread.
  • Fixed cost of 10 credits, charged only when at least one client renders (X-Credit-Cost: 10). A batch where zero clients finish in time returns a retryable 503 and is not billed; unknown client ids are rejected with a 422 before any paid work.
  • Slow clients that outlive the bounded render window come back in pending. Call again to retry just those.
  • Also available as the preview_email_across_clients MCP tool and as brew.emails.previewClients(...) in the SDK.
API v1, new POST /v1/sends/{sendId}/cancel

Public API v1: Cancel a Send

New endpoint to pull back a send before it goes out.
  • POST /v1/sends/{sendId}/cancel (“Cancel a send”). Cancels a scheduled or queued send → 200 { sendId, status: 'canceled' }. Idempotent, an already-canceled send returns 200. Once the send is sending, sent, or failed it is 409 SEND_NOT_CANCELLABLE; an unknown / cross-brand id is 404 SEND_NOT_FOUND. sends scope. The SDK method is brew.sends.cancel(sendId).
API v1 renames, /v1/account → /v1/usage, host-image → add-image

Public API v1: Two Operation Renames

Two endpoints (and their SDK methods) were renamed for clarity. The request and response shapes are unchanged.
  • GET /v1/account → GET /v1/usage (“Get usage”). The billing/quota surface, { plan, credits, emailSends, period }: keeps the same shape and the emails scope. The SDK method moves from brew.account.get() to brew.usage.get().
  • POST /v1/content/host-image → POST /v1/content/add-image (“Add image”). Still optimizes the source image and saves it to the brand image library (fixed credit cost). The SDK method moves from brew.content.hostImage() to brew.content.addImage().
API v1 read-collapse, flat reads, polymorphic send, email import

Public API v1: Flat Reads + Unified Send

The v1 surface collapsed from 71 to 55 endpoints around one rule: one flat read per resource, identity in the query, ?include= opt-ins for the heavy detail. Plus a new way to bring existing designs into Brew.
  • Reads are flat. The per-resource get-one paths are gone. Pass the id key to the list endpoint instead: emails GET /v1/emails?emailId= (?include=html,versions), domains ?domainId=, audiences ?audienceId= (?include=count), automations ?automationId= (?include=graph,versions), automation runs GET /v1/automations/runs?automationRunId= (?include=logs), triggers ?triggerEventId=, trigger instances ?triggerInstanceId=, and sends GET /v1/analytics/sends?sendId= (?include=events, also ?emailId=). The single-send detail row carries previewImage, so POST /v1/emails/{emailId}/preview, GET /v1/emails/{emailId}/versions, and GET /v1/emails/{emailId}/sends were removed (on-demand rendering is POST /v1/content/html-to-png).
  • One polymorphic send. POST /v1/sends/test folded into POST /v1/sends. Pass { test: true } for the synchronous one-off QA send (200 { recipient }); omit it for the campaign send (202 { sendId }).
  • One contact read. GET /v1/contacts and GET /v1/contacts/{email} were replaced by POST /v1/contacts/search ({ filters, audienceId?, search?, sort, count?, cursor }); a by-email lookup is a { field: 'email', operator: 'equals' } filter.
  • New, POST /v1/emails/import. Bring existing html, mjml, or jsx into an editable Brew design (external images are re-hosted on the CDN). Usage-metered.
  • Semantic brand-image search. GET /v1/brand/images?q= runs a credit-metered vector search (plus ?type / ?aspectRatio); the no-q browse stays free.
  • Also removed: POST /v1/audiences/{audienceId}/duplicate.
The TypeScript SDK keeps its factory names: every read is one list() (id/include/filters in the args), send/sendTest merged into send(input) with test?, the contact read is search(), and emails.import() is new. See the updated API Introduction, SDK Overview, and the @brew.new/sdk changelog.
API v1 restructure, decoupled sends, 3-domain surface, /v1/help

Public API v1: Decoupled-Send Restructure

A breaking restructure of the v1 surface around a single insight: emails are pure designs, and a send is the unit of delivery and analytics. A design now carries no type and no send state. It can be sent any number of times.
  • Sends, unified. Campaign sends and automation sends are one entity. A campaign records one send; an automation records one send per recipient. Every delivery event attaches to its sendId.
  • Three clear domains. automations owns /v1/automations/* plus /v1/automations/triggers(/{id}/fire) (renamed from /v1/triggers) and /v1/automations/runs(/{runId}) (moved from /v1/analytics/automations/runs). analytics owns all reporting, including /v1/analytics/sends(/{sendId}/events) (send reads, moved off GET /v1/sends) and /v1/analytics/trigger-instances (the fired-trigger log, moved off /v1/events). sends is the action only: POST /v1/sends and POST /v1/sends/test.
  • POST /v1/sends now takes either a saved audienceId or an inline to list (≤ 50) and returns a sendId you poll under /v1/analytics/sends.
  • GET /v1/help: a no-auth, structured-JSON catalog of the whole API (scopes, credits, rate limits, every endpoint) for MCP / agent discovery, alongside GET /v1/llms.txt.
  • No more dry_run. Credit-metered operations just charge on success (402 INSUFFICIENT_CREDITS when short); check your balance with GET /v1/account.
  • POST /v1/content/host-image is now credit-metered and saves the image into the brand image library.
  • Removed: /v1/me, /v1/usage (use GET /v1/account), /v1/integrations, the single-template GET /v1/templates/{emailId} (the list GET /v1/templates stays and now returns html + previewImage per row), and automation-run replay.
See the updated API Introduction and the @brew.new/sdk changelog. The entries below describe earlier iterations of the v1 surface, paths noted there have since moved as summarized above.
API v1 hardening, 7 new endpoints, pagination, scopes

Public API v1: Hardening Pass

Seven new endpoints plus cross-cutting normalization across the whole API.
  • New observability + discovery endpoints: GET /v1/sends (campaign send list + stats), GET /v1/brand (the key’s brand + readiness), GET /v1/usage (API request volume + trend), GET /v1/analytics/events (unified event explorer, filter by recipientEmail for a contact’s full timeline), and GET /v1/integrations (triggerable integration-event catalog).
  • Test/preview sends: POST /v1/sends { mode: 'test' } sends a one-off preview to a single inbox, no verified domain or audience required, and it doesn’t consume the email’s live-send slot.
  • Automation run replay: POST /v1/automations/runs { automationRunId, mode: 'replay' } re-runs a prior run against the current saved draft.
  • Uniform cursor pagination: every list endpoint now accepts limit/cursor and returns a pagination envelope.
  • Lean lists + include=: GET /v1/templates and GET /v1/automations are lean by default. Pass ?include=html / ?include=graph to opt into the heavy fields.
  • Granular scopes: new least-privilege domains, sends, and audiences scopes; the coarse scopes still satisfy them, so existing keys are unaffected.
New TypeScript SDK methods: brew.brand.get(), brew.usage.get(), brew.integrations.list(), brew.analytics.sends.{list,listAll,get}() + brew.emails.sendTest(), and brew.analytics.{events,eventsAll}(). See the updated API reference, the new API Guides, and the @brew.new/sdk changelog.
API v1 lifecycle expansion

Public API v1: Full Lifecycle

The v1 API now covers the whole loop end-to-end for an org + brand API key.
  • Audiences are now full CRUD (POST/PATCH/DELETE + single fetch), and rows carry their filters, member count, and ISO timestamps.
  • Domains gained a full lifecycle: add → verify → set sender defaults → delete. GET /v1/domains now lists every domain (incl. pending rows + the DNS records to publish); ?sendableOnly=true returns just the send-ready set.
  • Analytics is now queryable: GET /v1/analytics/campaigns (lifetime per-campaign KPIs) and GET /v1/analytics/automations (windowed per-automation performance + totals).
  • Emails gained delete, version history (?include=versions), and non-destructive version restore.
Breaking: POST/PATCH /v1/triggers (since renamed to /v1/automations/triggers) now return the uniform { triggers: [row] } envelope (was { trigger }); contact timestamps are ISO-8601 strings. See the updated API reference + the @brew.new/sdk changelog.
OpenAPI 3.1 spec + TypeScript SDK

API Specification

Published OpenAPI 3.1 specification covering contacts, automations, triggers, automation runs, emails, sends, audiences, domains, fields, and templates. Official TypeScript SDK (@brew.new/sdk) is available; generate clients for other languages from the OpenAPI spec at https://brew.new/openapi/public-api-v1.yaml (see Generate Your Own SDK).