Official TypeScript SDK for the Brew public API.
- Resource-oriented surface —
brew.contacts.upsert(...)instead of raw HTTP plumbing. - One typed error class (
BrewApiError) for every non-2xx path, and typed transport errors (BrewTimeoutError,BrewConnectionError) when no answer arrives. - A deadline (
timeoutMs) and cancellation (signal) that cover the whole request, response body included. - Safe retries with exponential backoff + jitter.
- Auto-generated
Idempotency-Keyon POST so retries never double-write. - Node 20+, server-first. API keys are secrets — do not use this SDK directly in a browser.
bun add @brew.new/sdk
# or
npm install @brew.new/sdkimport { createBrewClient } from '@brew.new/sdk'
const brew = createBrewClient({
apiKey: process.env.BREW_API_KEY!,
})
const contact = await brew.contacts.upsert({
email: 'jane@example.com',
firstName: 'Jane',
customFields: { plan: 'enterprise' },
})
const found = await brew.contacts.get('jane@example.com')
const { data } = await brew.contacts.search({
filters: [
{ field: 'customFields.plan', operator: 'equals', value: 'enterprise' },
],
})That's the whole shape. Every other method follows the same pattern:
list(query) for a page, get(id) for the bare row, and a 404 — not
an empty page — when the id is unknown.
Upgrading from 10.x? 11.0.0 turns
emails.previewClientsinto a rendering job you poll withemails.getClientPreview, and drops the deprecatedverificationStatusmirror on contacts. From 9.x? 10.0.0 tracks the public API v1 cleanup. Read the CHANGELOG for both migrations.
The brand image library is what the email agent searches when it designs. Adding and deleting images is free.
import { openAsBlob } from 'node:fs'
// A local file, in one call: opens an upload, sends the bytes, adds it.
const logo = await brew.content.uploadImage({
file: await openAsBlob('./assets/logo.png'), // or a Buffer / ArrayBuffer
fileName: 'logo.png', // contentType inferred from the extension
})
// → { url: 'https://cdn.brew.new/…', width, height, aspectRatio, assetId }
// A public URL (or up to 100 with `imageUrls`, imported in the background).
const hero = await brew.content.addImage({
imageUrl: 'https://acme.com/hero.jpg',
})
// Remove one from the library. Emails already using it keep rendering.
await brew.brand.deleteImage(hero.assetId) // → { assetId, deleted: true }uploadImage takes a Blob/File, an ArrayBuffer or a Uint8Array
up to 20 MB (2 MB for SVG) as PNG, JPEG, GIF, WebP, AVIF, TIFF or SVG.
Pass contentType when the file name has no such extension. An empty
file, or one over those limits, throws a TypeError before any request.
The bytes go straight to the upload's own URL without your API key, and
that step is retried like any other (a repeat never replaces the first
file). Opening the upload is not retried by default, because the API never
replays it and each retry would hold one of the brand's 20 upload slots
for 15 minutes; if it fails, call uploadImage again. To drive the steps
yourself, call brew.content.createImageUpload({ fileName, contentType, size }), POST the raw bytes to its uploadUrl within 15 minutes, then
brew.content.addImage({ uploadId }).
baseUrl is configurable. By default it points at production
(https://brew.new/api), but you can override it for staging, local
development, or a custom proxy:
const brew = createBrewClient({
apiKey: process.env.BREW_API_KEY!,
baseUrl: process.env.BREW_API_URL ?? 'https://brew.new/api',
})Common values:
https://brew.new/api— production (default)https://staging.brew.new/api— staginghttp://localhost:3000/api— your own dev server
Trailing slashes are normalized either way. See
docs/configuration.md for the full list of
config options (timeoutMs, maxRetries, retryOnTimeout, signal,
userAgent, custom fetch, etc.).
| Topic | File |
|---|---|
| Analytics resource | docs/analytics.md |
| Automations resource | docs/automations.md |
| Audiences resource | docs/audiences.md |
| Brand resource | docs/brand.md |
| Brands (lifecycle) | docs/brands.md |
| Chats resource | docs/chats.md |
| Client configuration | docs/configuration.md |
| Contacts resource | docs/contacts.md |
| Domains resource | docs/domains.md |
| Emails resource (+ send) | docs/emails.md |
| Fields resource | docs/fields.md |
| Insights resource | docs/insights.md |
| Notifications resource | docs/notifications.md |
| Error handling | docs/errors.md |
| Retries + idempotency | docs/retries-and-idempotency.md |
| Sends resource (reads + lifecycle) | docs/sends.md |
| Templates resource | docs/templates.md |
| Flows resource | docs/flows.md |
| Development + OpenAPI sync | docs/development.md |
| Releasing a new version | RELEASING.md |
Note:
brew.brand.*(singular) reads the brand the CURRENT request acts on — its design system, identity, and extraction readiness.brew.brands.*(plural) is the organization-level lifecycle: list, create, and poll brands.An API key is scoped either to ONE BRAND or to the whole ORGANIZATION. A brand-scoped key resolves its brand automatically. An organization-scoped key must name one per request — pass
brandIdin the client config, or pin one withclient.withBrand(id). There is no default brand: omitting it returns400 BRAND_ID_REQUIRED.const brew = createBrewClient({ apiKey: process.env.BREW_API_KEY! }) const { data: brands } = await brew.brands.list() const acme = brew.withBrand(brands[0].brandId) await acme.emails.list()
Use a modern Node 20 or newer runtime for SDK development. On this
machine, Vitest worked with Node 20.19.2 and newer, but failed on the
older system Node 20.10.0.
bun install
bun tsc # typecheck
bun lint # eslint
bun run format # prettier
bun run test # vitest — NOT `bun test`, that hits Bun's built-in runner and bypasses MSW setup
bun run build # tsup: dist/ with esm + cjs + .d.tsFull contribution + testing conventions live in AGENTS.md.
The big one: every change is driven red → green via vitest + MSW, no
exceptions for "trivial" pure functions.
MIT