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, asave_automationthat publishes, unpauses or stops contacts mid-flow, acontrol_email_sendorcontrol_audience_runresume, and a test to anyone but the connected person now returnconfirmation_requiredfirst, like campaigns. The same call withconfirmed: trueand the request’sconfirmation_idruns it; the id holds for those exact arguments for 15 minutes and runs one call. See Confirmation. - No contacts from tool arguments.
send_emailno longer takesconsent: inlinetorecipients must already be subscribed contacts.save_contactupdates existing contacts only (createIfMissingandconsentare gone).import_contacts_csvupdates existing contacts and refuses unknown rows one by one; it no longer takesconsentorvalidate(usevalidate_contacts).test_automationno longer creates the test payload’s contact. Afire_trigger_eventfor an address that unsubscribed is refused withRECIPIENT_UNSUBSCRIBED. Over the API,POST /v1/contactsandPOST /v1/sendskeepconsent. - Removed from MCP:
generate_image,create_gifandtransform_image(the API keepsPOST /v1/content/generate-image,POST /v1/content/gifandPOST /v1/content/transform), andremove_domain_unsubscribe(the API keepsDELETE /v1/domains/{domainId}/unsubscribes/{email}). - Imagery made with Brew stays in the design. Over MCP,
create_emailandedit_emailno longer save the scenes they generate to the brand library, andsearch_brand_imageslists logos and brand images only (kindislogoorbrand).GET /v1/brand/imagesand the app still list every kind. sendingPurpose.create_domainandupdate_domainno longer take it. Set it in the app or withPATCH /v1/domains/{domainId}.send_flaretakes 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_audiencewithsourceAudienceIdtakes an optionalnamefor the copy.DELETE /v1/emails/{emailId}on an already-deleted design now returns the documenteddeletedAt.
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 theDataCommandResponseschema- The MCP tool
query_brew_data(first shipped asrun_data_command) brew.data.run()in the TypeScript SDK, from SDK 12.0.0brew-cli data run, from the next CLI release- The
datatopic of the MCP toolget_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.eventsandbrew.analytics.eventCounts. Since SDK 11.6.0:brew.insights.list(includeaddspulse,report,suggestions, andmemo) andbrew.insights.get,brew.emails.comments.list,brew.chats.list,brew.notifications.list,brew.domains.health({ domainId, include: ['scoreHistory', 'scoreRuns'] }), andbrew.contacts.get(email, { include: ['openProfile'] })orbrew.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 eventsandanalytics event-counts. Since CLI 0.13.0:insights list,insights get,emails comments list,chats list,notifications list, and--includeondomains health,contacts get, andcontacts search.brew-cli apistill 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()andbrew.insights.get(insightId),brew.emails.comments.list({ emailId }),brew.chats.list(), andbrew.notifications.list().brew.chats.listAll()andbrew.notifications.listAll()page through everything (the notifications pager keeps going through short pages), andbrew.emails.comments.listAllMessages({ emailId, commentId })walks one thread back to its first message.includeonbrew.domains.health(),brew.contacts.get(), andbrew.contacts.search(). - CLI 0.13.0:
brew-cli insights listandinsights get,brew-cli emails comments list,brew-cli chats list, andbrew-cli notifications list.--includeondomains health,contacts get, andcontacts 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:POST /v1/content/image-uploadswith{ fileName, contentType, size }answers201with{ 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 is429 RATE_LIMITED.- POST the raw bytes to
uploadUrlwithin 15 minutes, with noAuthorizationheader: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. POST /v1/content/add-imagewith{ uploadId }within 15 minutes of the bytes landing. The bytes decide the format, and repeating the call returns the same answer for 24 hours.
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 }), andbrew.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 carriesassetId. - CLI 0.12.0:
brew-cli content upload-image <file>,brew-cli content create-image-upload,brew-cli content add-image --upload-id, andbrew-cli brand delete-image <assetId>. Also fixed:templates listwithcountorgroupByno longer crashes.
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:
FlowsListResponsecarriestotalandisTotalExact.brew.emailGroups.createand.updatetakeemailIds(move up to 50 designs into a folder in one call) and returnmovedandnotMoved;updateneedsname,emailIds, or both. The creator fields (createdBy,createdByUserId,publishedBy,publishedByUserId) are typed on emails, email groups and automations. - CLI 0.11.0:
flows listleads with how many flows match (167 flows in total; 25 on this page), and--jsonpassestotalandisTotalExactthrough.emails groups createandupdatetake--email-ids, andupdateno 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') orBrewConnectionError— instead of a bareAbortErrororTypeError, and a 2xx that is not JSON throwsBrewParseError. Every error,BrewApiErrorincluded, carries theidempotencyKeyits request used, so a write whose outcome is unknown can be replayed instead of repeated.retryOnTimeout: falsemakes a timeout final, andcreateBrewClient({ signal })cancels every request on the client. A cancel is never retried and also stops a retry backoff.content.gif,content.generateImageandemails.importget 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-retriessets the retry count. A write whose outcome is unknown prints itsidempotencyKeyand aretryCommandthat 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, ... })andbrew.domains.unsubscribes.list|add|remove|import|export, plusdateOrderandconsentonbrew.contacts.importCsv,consentonupsert,upsertManyandpatch, anddryRunonbrew.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, andcontacts 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.kindnarrows tologo,brand, orgenerated. Omit it for every kind.sortorders a browse:newest(the default) oroldest.qsearches brand and generated images by what they show, in relevance order, and ignoressort. It costs 1 credit for the first page, and cursor pages after it are free. Logos are not searchable, soqwithkind=logois a400 INVALID_REQUEST, refused before any charge.
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 }.typeandaspectRatioare gone. - CLI 0.9.0:
brew-cli brand get-imagestakes--kindand--sortin place of--typeand--aspect-ratio.
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 contactsearch 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:
BrewApiErrormaps the legacy fire envelope — the realcode, atypederived from the HTTP status, fix-the-request advice for a 4xx — and gainsdetailsandbody. Before 9.3.0 a fire refusal surfaced asunknown_error/internal_errorwith retry advice. - CLI, from the next release:
automations triggers fireandapi POST …/fireprint the field errors, and--jsoncarriesdetailsinside 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-keysPOST /v1/api-keysDELETE /v1/api-keys/{keyId}
- MCP never exposed key management and still does not. An MCP
credential cannot prove that the human behind it holds the
organization’s
org:adminrole. - SDK:
brew.apiKeys.list(),create(), andrevoke()are marked@deprecated. They compile and call, and the platform answers403. They are removed in the next major. - CLI:
brew-cli api-keys list|create|deleteare 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.
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 carriesbrand,title,type,category,emailCount,spanDays,remixCount, andpreviewImages. - Fetch one with
GET /v1/flows/{slug}(the brand domain) → the bare flow withanchor(what day 0 means) andsteps[]:order,dayOffset,delayDays(the wait since the previous email),subject,previewText,category,previewImage, andemailId. Add?include=htmlfor each step’s rendered HTML. An unknown slug is a404 FLOW_NOT_FOUND. - A step’s
emailIdis a template reference: pass it asreferenceEmailIdonPOST /v1/emailsto rebuild that email for your brand, or look it up onGET /v1/templates.
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
transactionIdfield onPOST /v1/sends - The
TRANSACTIONAL_EMAIL_NOT_FOUNDerror code brew.transactional.get()in the TypeScript SDK, theget_transactional_emailMCP tool, andbrew-cli types --transaction
Migrate a fire
Replace the send call with a trigger fire. The recipient moves into the payload asemail, and the design reads the rest as {{ trigger.* }}.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 returns404 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(MCPcreate_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(MCPimport_email_design)POST /v1/emails/figma(MCPimport_figma_design)PATCH /v1/emails/{emailId}(MCPedit_email_with_ai)
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:completereturnsreadiness, a 0 to 100score, andX-Credit-Cost: 5;partialpreserves finished checks, returnscompletion.score: nullandX-Credit-Cost: 0, and never establishes readiness.
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_keyplaceholder 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_KEYauth pattern, the SDK method, the equivalent MCP tool, every request field, and an example body.
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/validatenow writes the verdict back onto matching contacts (it was read-only before), and each result addsrisk,isDisposable, andisRolesignals alongside the existingreasonanddidYouMean.POST /v1/contactsandPOST /v1/contacts/import-csvaccept an optionalvalidate: 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 avalidationcount summary); larger submissions upsert first and validate as a background job, returning avalidationJobId.- The contact’s verdict field is renamed
verificationStatus→validationStatus.verificationStatusis dual-emitted with the same value for back-compat, so existing integrations keep working. - Also available via the
create_contact,import_contacts_csv, andvalidate_contactsMCP 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/figmatakes{ figmaUrl, title?, format?: 'jsx' | 'html' }and returns201 { emailId, emailVersionId, title, format, content, warningCount, exportedNodeCount, previewImage? }. ThefigmaUrlmust include anode-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.
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()andbrew.emails.getInboxPlacementResults(), the seed-list test of whether a design lands in the inbox or in spambrew.sends.pause()andbrew.sends.resume(), the reversible pair for an in-flight gradual sendbrew.analytics.overview()for the exact totals, rates, and timeseries shown in Brewbrew.automations.run()plusbrew.automations.audienceRuns.list()and.control()for manual-audience launch, scheduling, pause, resume, and cancelbrew.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/validatenow runs a real deliverability check on up to 100 addresses at once, each comes backvalid,risky(role account, disposable, catch-all), orinvalid, with a machine-readablereasonand adidYouMeantypo correction. Metered 2 credits per address, charged only on success.- Contact validation remains available as the
validate_contactsMCP tool.
API v1, preview a design across real inboxes & devices
Public API v1: See How an Email Renders in Real Inboxes
NewPOST /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_dtfor 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 retryable503and is not billed; unknown client ids are rejected with a422before 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_clientsMCP tool and asbrew.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-canceledsend returns200. Once the send issending,sent, orfailedit is409 SEND_NOT_CANCELLABLE; an unknown / cross-brand id is404 SEND_NOT_FOUND.sendsscope. The SDK method isbrew.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 theemailsscope. The SDK method moves frombrew.account.get()tobrew.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 frombrew.content.hostImage()tobrew.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 runsGET /v1/automations/runs?automationRunId=(?include=logs), triggers?triggerEventId=, trigger instances?triggerInstanceId=, and sendsGET /v1/analytics/sends?sendId=(?include=events, also?emailId=). The single-send detail row carriespreviewImage, soPOST /v1/emails/{emailId}/preview,GET /v1/emails/{emailId}/versions, andGET /v1/emails/{emailId}/sendswere removed (on-demand rendering isPOST /v1/content/html-to-png). - One polymorphic send.
POST /v1/sends/testfolded intoPOST /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/contactsandGET /v1/contacts/{email}were replaced byPOST /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 existinghtml,mjml, orjsxinto 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-qbrowse stays free. - Also removed:
POST /v1/audiences/{audienceId}/duplicate.
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.
automationsowns/v1/automations/*plus/v1/automations/triggers(/{id}/fire)(renamed from/v1/triggers) and/v1/automations/runs(/{runId})(moved from/v1/analytics/automations/runs).analyticsowns all reporting, including/v1/analytics/sends(/{sendId}/events)(send reads, moved offGET /v1/sends) and/v1/analytics/trigger-instances(the fired-trigger log, moved off/v1/events).sendsis the action only:POST /v1/sendsandPOST /v1/sends/test. POST /v1/sendsnow takes either a savedaudienceIdor an inlinetolist (≤ 50) and returns asendIdyou 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, alongsideGET /v1/llms.txt.- No more
dry_run. Credit-metered operations just charge on success (402 INSUFFICIENT_CREDITSwhen short); check your balance withGET /v1/account. POST /v1/content/host-imageis now credit-metered and saves the image into the brand image library.- Removed:
/v1/me,/v1/usage(useGET /v1/account),/v1/integrations, the single-templateGET /v1/templates/{emailId}(the listGET /v1/templatesstays and now returnshtml+previewImageper row), and automation-run replay.
@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 byrecipientEmailfor a contact’s full timeline), andGET /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/cursorand returns apaginationenvelope. - Lean lists +
include=:GET /v1/templatesandGET /v1/automationsare lean by default. Pass?include=html/?include=graphto opt into the heavy fields. - Granular scopes: new least-privilege
domains,sends, andaudiencesscopes; the coarse scopes still satisfy them, so existing keys are unaffected.
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 theirfilters, membercount, and ISO timestamps. - Domains gained a full lifecycle: add → verify → set sender defaults → delete.
GET /v1/domainsnow lists every domain (incl.pendingrows + the DNSrecordsto publish);?sendableOnly=truereturns just the send-ready set. - Analytics is now queryable:
GET /v1/analytics/campaigns(lifetime per-campaign KPIs) andGET /v1/analytics/automations(windowed per-automation performance + totals). - Emails gained delete, version history (
?include=versions), and non-destructive version restore.
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).