> ## Documentation Index
> Fetch the complete documentation index at: https://docs.brew.new/llms.txt
> Use this file to discover all available pages before exploring further.

# Build a White-Label Email Editor

> Provision customer brands, generate and edit emails on your backend, and embed an authorized email canvas inside your own admin application.

Add an email editor to your existing admin app. Keep your app's sign-in and
customer records. Use one server-side Brew organization key to extract a
customer's brand, generate one email, authorize its canvas, and read the saved
HTML. Groups and design-context reads are optional.

The canvas is a Brew-hosted iframe. Customers need no Brew login. Your app
controls navigation, prompts, and actions around it. The iframe has no SDK
options for a custom theme, logo, or domain.

## The Implementation Plan

Build these four pieces in order. Use your existing authentication, data
store, framework, and deployment. Add background work only if your host's
request limit cannot accommodate generation. Start with one email per editing
screen.

| Task | Deliverable | Done When |
| - | - | - |
| 1. Provision the brand | One server client and a saved customer-to-`brandId` mapping | The key stays server-side, extraction status is visible, and another customer's brand cannot be selected. |
| 2. Create the email | Authorized generation and edit endpoints that save email ownership | Generation waits for a ready brand, response variants are handled, and an interrupted edit cannot silently run twice. |
| 3. Authorize the canvas | A session endpoint using your existing login and CSRF protection | Wrong-customer targets and session IDs are denied; create, renewal, read-only access, and revocation work. |
| 4. Edit and retrieve HTML | One embedded component and your app's save action | Save awaits `flush()`, failures keep the editor open, and the backend reads the persisted HTML. |

[Copy the complete agent prompt](#copy-the-complete-agent-prompt) to implement
all four tasks, or use the [individual task prompts](#individual-task-prompts).

## Prepare Your Backend

Install the SDK:

```bash theme={null}
npm install @brew.new/sdk@^13.0.0
```

Use SDK 13.0.0 or newer for the bounded brand waiter and session-provider
`headers` option used below.

Create one organization-scoped API key with `brands` and `emails` permissions
at [**Settings → API**](https://brew.new/settings/api). Store it in the server's
`BREW_API_KEY` environment variable:

```ts theme={null}
// Server only. Never import this client into browser code.
import { createBrewClient } from '@brew.new/sdk'

const platform = createBrewClient({ apiKey: process.env.BREW_API_KEY! })
```

Keep your own customer-to-`brandId` and email-ownership records. Resolve them
from the signed-in customer on every backend request. A browser-supplied Brew
ID does not establish ownership. Private admin apps use server-authorized
canvas sessions, not public client IDs.

[Authentication](/api-reference/api/authentication) owns permissions,
[Organization and Brand Credentials](/api-reference/api/organization-and-brand-scope)
owns brand selection, and [Credits](/api-reference/api/credits) owns usage
charges.

## Extract a Customer's Brand

After authenticating the caller and authorizing customer setup, start
extraction on your backend:

```ts theme={null}
const created = await platform.brands.create(
  { url: 'https://example.com' },
  { idempotencyKey: 'customer-42-brand-onboarding-001' },
)

console.log(created.brandId, created.status)
```

Persist `created.brandId` against the customer immediately. The response is a
flat object. It returns before extraction finishes. Reuse the same retry key
when recovering the same provisioning request. Use a new key for a new action.
[Idempotency](/api-reference/api/idempotency) owns replay guarantees.

Wait for the saved brand on your backend when your request or existing worker
has enough time:

```ts theme={null}
const status = await platform.brands.waitForExtraction(created.brandId, {
  maxWaitMs: 300_000,
})
if (!status.ready) {
  throw new Error(status.error ?? `Brand is ${status.status}.`)
}
```

The helper checks immediately, then every five seconds while the status is
`extracting`. It stops at any other status; proceed only if `ready` is true.
Otherwise show the status and error, including `completed` with `ready: false`.
It accepts a cancellation `signal`. A `BrewBrandExtractionTimeoutError` ends
the wait without canceling extraction. Keep the saved brand ID and read its
status again instead of creating another brand.

Extraction can take a few minutes. For a short-lived host, expose
`platform.brands.get({ brandId })` through your existing status endpoint and
let your app poll every five seconds with a deadline. Stop when the status
leaves `extracting` and require `ready: true`. No new job system is needed.
Once ready, pin a new client for this customer's calls:

```ts theme={null}
const customer = platform.withBrand(created.brandId)
```

Creation needs an available brand slot and a non-empty credit balance. A
domain already in your organization returns `409 BRAND_DOMAIN_CONFLICT`.
Reuse a saved brand only when your records authorize this customer to use it.
A matching domain does not authorize access to another customer's brand.
There is no public method to restart extraction on an existing brand.

## Create and Edit Emails

Resolve the saved `brandId` from the authenticated customer's records and use
`platform.withBrand(brandId)`. This returns a new client without changing the
shared organization client. Generate one email:

```ts theme={null}
const generated = await customer.emails.generate(
  {
    prompt: 'Create a product update for customers who subscribed to our newsletter.',
    subjectLine: 'What is new this month',
  },
  { idempotencyKey: 'customer-42-product-update-001' },
)
if (!('emailId' in generated)) {
  throw new Error(generated.response)
}

console.log(generated.emailId, generated.emailVersionId)
```

Save the email ID and its customer ownership before returning the ID to your
app. No group or design-context request is required. The email uses the saved
brand automatically.

To make a prompt edit, authorize the saved email and call:

```ts theme={null}
const edited = await customer.emails.edit({
  emailId: generated.emailId,
  prompt: 'Shorten the opening paragraph and make the main button say Explore the update.',
})
if (!('emailId' in edited)) {
  throw new Error(edited.response)
}
```

REST and SDK generation and editing wait for completion. The SDK's default
request timeout is four minutes. Use your existing request or worker model
and configure the host's budget accordingly. Both methods can return
`{ response }` without saving a design, so check the union before using IDs.
Prompt edits create a version. Metadata-only edits with `title`,
`subjectLine`, or `groupId` patch the saved design without a generation run.

### Recover an Interrupted Request

PATCH edits have no replay protection and the SDK does not automatically
retry them. An idempotency key does not change that. After an ambiguous
timeout, inspect `customer.emails.get(emailId, { include: 'versions' })` and
wait for any running generation to settle before deciding whether another
paid edit is needed.

`409 EMAIL_RESULT_UNAVAILABLE` means a design was saved but its result could
not be retrieved for the response. `error.details` includes `committed: true`,
`emailId`, `emailVersionId`, and `runId`. Recover with
`customer.emails.get(emailId, { emailVersionId, include: 'html' })` using those
IDs. First persist the committed IDs in the authenticated customer's ownership
records so the saved email can be authorized for recovery and editing. Do not
repeat the prompt. For POST generation, the original idempotency
key replays the same 409; read the saved version instead.

## Authorize the Canvas in Your App

Add a same-origin authenticated `POST /api/brew/canvas-session` endpoint.
The SDK sends `{ target, sessionId? }`. This endpoint must:

1. Use your app's existing login and CSRF validation.
2. Resolve the caller's customer, brand, target email, and permissions from
   server-owned records. Reject unauthorized targets.
3. Create the first grant and store its owner, brand, target, and permissions.
4. On renewal, verify the supplied session belongs to that caller and target,
   and recheck current access before calling `canvas.sessions.refresh()`.
5. Return the session directly as JSON with `Cache-Control: no-store`.

After those checks, create a grant for that email:

```ts theme={null}
const session = await customer.canvas.sessions.create({
  target: { type: 'email', emailId: generated.emailId },
  user: { externalId: 'customer-42:user-7', name: 'Alex' },
  parentOrigin: 'https://admin.example.com',
  permissions: ['read', 'edit', 'presence'],
})
```

Use a stable, tenant-qualified `externalId`. Choose permissions on your
server. `['read']` gives read-only access; `read` is required in every grant.
Set `parentOrigin` from trusted deployment configuration. It must be the exact
HTTPS origin, including any port, without a path or trailing slash. Loopback
HTTP is accepted for development.

For renewal, call `customer.canvas.sessions.refresh(sessionId)` only after
checking the stored ownership record. A session ID alone proves nothing.
Refresh and revoke use the same issuing API principal. When access ends or
the customer signs out, call `customer.canvas.sessions.revoke(sessionId)`.
Custom providers do not automatically revoke on unmount. Lost grants expire.

The response contains a single-use `bootstrapToken`. Do not cache, log, or put
it in a URL. The browser provider passes the uncached session to the iframe.
[Understand the Access Boundary](/sdks/typescript/embedded-email-canvas#understand-the-access-boundary)
explains why a public client ID is insufficient for this private workflow.

## Mount the Canvas and Save Before Leaving

Pass your app's current CSRF token through the provider's `headers` option.
The factory runs for every session create and renewal request. The SDK
preserves JSON content type, same-origin credentials, and cancellation.

```tsx theme={null}
'use client'
import { useRef, useState } from 'react'
import { EmailCanvas, type EmailCanvasHandle } from '@brew.new/sdk/react'
import { createCanvasSessionProvider } from '@brew.new/sdk/embed'

type EditorProps = {
  emailId: string
  csrfToken: string
  onSaveComplete: () => void | Promise<void>
}

export function CustomerEmailEditor({ emailId, csrfToken, onSaveComplete }: EditorProps) {
  const canvas = useRef<EmailCanvasHandle>(null)
  const [errorMessage, setErrorMessage] = useState<string | null>(null)
  const getSession = createCanvasSessionProvider('/api/brew/canvas-session', {
    headers: () => ({ 'X-CSRF-Token': csrfToken }),
  })

  async function save() {
    if (!canvas.current) return
    setErrorMessage(null)
    try {
      await canvas.current.flush()
    } catch {
      setErrorMessage('Your edits could not be saved. Keep the editor open and try again.')
      return
    }
    try {
      await onSaveComplete() // Request HTML from your own authorized backend.
    } catch {
      setErrorMessage('Your edits are saved, but the HTML could not be retrieved. Try again.')
    }
  }

  return (
    <>
      <button type="button" onClick={save}>Save email</button>
      {errorMessage && <p role="alert">{errorMessage}</p>}
      <EmailCanvas
        ref={canvas}
        emailId={emailId}
        getSession={getSession}
        style={{ height: '80vh' }}
        onError={(error) => setErrorMessage(error.message)}
      />
    </>
  )
}
```

The React adapter uses the latest `getSession` callback without reloading the
iframe when the CSRF token changes. Use your app's CSRF header name and current
token. The alert keeps failures visible. If the
HTML request fails after `flush()` succeeds, the message confirms the design
is already saved; no generation or edit needs to run again.

Manual edits autosave. Await `flush()` before navigation, target changes,
unmounting, an API edit, or the HTML read. Use `onDirtyChange` for navigation
warnings. `onSaved` reports IDs and update time, not HTML. The SDK supports
React 18 and 19; `createEmailCanvas()` supports other frameworks.

## Read the Saved HTML

After browser `flush()` succeeds, authenticate and authorize the HTML request
on your backend, resolve the customer's saved brand and email IDs, and read:

```ts theme={null}
const saved = await customer.emails.get(generated.emailId, { include: 'html' })
if (saved.status !== 'ready' || !saved.html) {
  throw new Error('The email HTML is not ready.')
}

console.log(saved.emailId, saved.emailVersionId, saved.html)
```

The GET above reads the latest version. Manual edits can patch a displayed
historical version, and `flush()` does not promote it to latest. For the exact
saved output, retain the canvas `onSaved` event's `emailId` and non-null
`emailVersionId`, then pass that version to the authorized backend read.
Recheck email and version ownership. If no version ID was returned, resolve
that before promising an exact result instead of silently reading latest.

Wait for any active generation to finish before reading its final result.
Use the HTML in your sending system, or use `customer.emails.export()` for a
connected email platform. Neither action belongs in a canvas-save callback
without your backend's authorization checks.

## Optional Capabilities

<AccordionGroup>
  <Accordion title="Organize emails into groups">
    Use `customer.emailGroups.create()` and pass its `groupId` to generation.
    Mount `<EmailCanvas groupId={groupId} getSession={getSession} />` to show a
    group. Authorize a group target in your session endpoint. Keep this optional
    until your app needs several emails on one canvas.
  </Accordion>

  <Accordion title="Inspect or customize the extracted design guidance">
    Use `customer.brand.get({ include: ['identity', 'emailDesign', 'imageStyle',
        'logos'] })` to read the extracted context. Use `customer.brand.patch()` to
    change saved guidance. Generation already uses that context automatically.
  </Accordion>

  <Accordion title="Import designs or use the lower-level controller">
    `customer.emails.import()` accepts HTML, MJML, React Email JSX, or EML.
    [SDK Resources](/sdks/typescript/resources) lists the available methods.
    [Embed the Email Canvas](/sdks/typescript/embedded-email-canvas) covers browser
    controllers, events, collaboration, images, and draft recovery.
  </Accordion>
</AccordionGroup>

## Copy the Complete Agent Prompt

Paste this into the coding agent working in your admin app. Do not paste a key
into the prompt. Give the agent access to your existing app's source and test
commands.

```text theme={null}
Implement a private white-label email editor in this existing admin app.
Canonical guide: https://docs.brew.new/sdks/typescript/white-label-email-editor
Read the guide and inspect this app before editing. Use its existing auth,
tenant model, database, framework, CSRF protection, and test conventions.
Use @brew.new/sdk 13.0.0 or newer; upgrade an older installed dependency.
Do not add an auth provider, database, job system, or new framework. Use
existing background work only if this host cannot wait for email generation.

Build four small pieces:
1. A server-only @brew.new/sdk client using one organization API key from
BREW_API_KEY (brands and emails scopes), plus persisted tenant-to-brandId and
email ownership records. Create the brand once, persist its ID immediately,
and poll only while status is extracting. On any other status, stop polling;
continue only if ready is true, otherwise show the status/error, including
completed with ready false. Use brands.waitForExtraction with a bounded,
cancelable wait when the host allows it; otherwise expose brands.get through
the existing status path and poll with a deadline.
2. Authenticated generation/edit endpoints. Select the brand from trusted
tenant records through withBrand, generate one email without mandatory groups
or context reads, handle the response union, and persist email ownership.
PATCH edits have no replay protection: after an ambiguous timeout reconcile
saved versions and running state before any intentional paid retry.
EMAIL_RESULT_UNAVAILABLE means read the committed version's HTML, not rerun.
Persist its committed IDs in this tenant's ownership records before recovery.
3. A same-origin canvas session endpoint using the app's auth and CSRF checks.
Authorize the email against the caller's tenant on every create and renewal.
Store session ownership, target, brand, and permissions; verify all of them
before refresh. Never trust a browser tenant, brand, target, or session ID as
authority. Set parentOrigin from trusted deployment configuration, use stable
tenant-qualified user IDs, return direct JSON with Cache-Control: no-store,
and revoke grants on sign-out/access removal with the issuing API principal.
No public client IDs or Brew API credentials belong in this private browser UI.
4. One EmailCanvas emailId/getSession component with real CSRF header support.
Use createCanvasSessionProvider headers to read the current CSRF token on
each create and renewal. Your save action must await flush before
calling an authenticated server endpoint to read saved HTML. A save failure
keeps the editor mounted and reports visible, accessible feedback. Distinguish
failed flush from failed HTML retrieval after a successful save. A default
HTML read returns latest; for exact saved output, retain the canvas onSaved
emailId and non-null emailVersionId, authorize their ownership, and pin that
version in the backend GET. Flush does not promote a historical version.

Test with mocked Brew calls and local fixtures. Cover tenant and session-ID
substitution, missing/invalid CSRF, extraction failure/timeout, read-only and
revoked access, renewal, response variants, interrupted PATCH, flush failure,
successful persisted HTML, a historical saved-version read, and an HTML-read
failure after successful save.
No live API calls, paid generation, or sends.
Run the app's relevant checks and report changed files, tests, required server
configuration, installed SDK version, and remaining blockers.
```

## Individual Task Prompts

Each prompt includes its own scope and completion checks. Run them in order.

<AccordionGroup>
  <Accordion title="Task 1: Provision and map a customer brand">
    ```text theme={null}
    In this existing admin app, implement brand provisioning for a private Brew
    email editor. Read https://docs.brew.new/sdks/typescript/white-label-email-editor
    and reuse the existing auth, tenant store, CSRF, framework, and test patterns.
    Use @brew.new/sdk 13.0.0 or newer.
    Add no auth provider, database, framework, or job system. Keep one organization
    API key (brands/emails scopes) server-side in BREW_API_KEY. Never expose it or
    use public client IDs for private access. Authenticate and authorize the
    customer before setup; persist tenant-to-brandId immediately after creation.
    Reuse the same idempotency key for one provisioning intent. Poll only while
    status is extracting, with a bounded, cancelable wait. Stop on any other
    status; continue only if ready is true, otherwise show status/error, including
    completed with ready false. Use brands.waitForExtraction when the host allows
    the wait; otherwise poll brands.get through the existing status path with a
    deadline. Surface failure/timeout and retain the saved ID.
    Do not adopt another tenant's brand because its domain matches. Preserve existing
    canvas-session ownership checks. Deliver the server client, mapping, and status path. Mock all Brew calls; prove cross-tenant
    denial, no duplicate provisioning, and failure/timeout recovery. No paid calls
    or sends. Report files, checks, configuration, and installed SDK version.
    ```
  </Accordion>

  <Accordion title="Task 2: Generate and edit one email">
    ```text theme={null}
    Add private Brew email generation and edits to this admin app, following
    https://docs.brew.new/sdks/typescript/white-label-email-editor. Reuse existing
    auth, tenant records, CSRF validation, framework, and storage. Add no new auth,
    database, framework, or job system; use existing background work only if the
    host's request budget requires it. Keep the Brew organization key server-side;
    no public client IDs. Use @brew.new/sdk 13.0.0 or newer.
    Resolve brandId with the authenticated tenant mapping
    and withBrand. Require a ready brand, generate one email without mandatory
    groups/context reads, handle result variants, and persist email ownership.
    Authorize ownership on edit and preserve canvas-session ownership checks.
    Use a retry key for POST generation. PATCH edits
    are not replay-protected: reconcile saved versions/running work after ambiguous
    timeouts before an intentional paid retry. Recover EMAIL_RESULT_UNAVAILABLE by
    reading its committed version. Deliver endpoints and user-visible error states.
    Persist the committed IDs in the authorized tenant's ownership records first.
    Use mocked/local tests for tenant substitution, result variants, readiness,
    interrupted PATCH, and committed-result recovery. No live calls or sends.
    Report changed files, test results, and host timeout requirements.
    ```
  </Accordion>

  <Accordion title="Task 3: Authorize private canvas sessions">
    ```text theme={null}
    Implement the private canvas session endpoint described at
    https://docs.brew.new/sdks/typescript/white-label-email-editor in this existing
    admin app. Reuse its login, tenant store, CSRF protection, and framework.
    Add no auth provider, database, framework, or job system. Keep the Brew key
    server-side and do not use public client IDs. Use @brew.new/sdk 13.0.0 or newer.
    Accept {target, sessionId?},
    then independently resolve and authorize the caller's tenant, brand, email,
    and permissions. Persist session owner, brand, target, and permissions. On
    renewal, verify the caller owns that session and still has identical authorized
    access before refresh. Use a server-configured exact parentOrigin and a stable
    tenant-qualified external user ID. Return the session as direct uncached JSON;
    never log bootstrap tokens. Implement revocation on sign-out/access removal
    using the issuing principal. Deliver create, refresh, and revoke integration.
    Mock Brew; test wrong-tenant targets, session-ID substitution, invalid CSRF,
    read-only grants, expired/revoked access, and refresh ownership. No paid calls
    or sends. Report changed files, checks, and required origin configuration.
    ```
  </Accordion>

  <Accordion title="Task 4: Embed, flush, and retrieve HTML">
    ```text theme={null}
    Implement the private editing screen from
    https://docs.brew.new/sdks/typescript/white-label-email-editor using this app's
    existing auth, tenant store, CSRF, UI, and test conventions. Add no auth,
    database, framework, or job system. No Brew API key or public client ID goes
    in the browser. Mount EmailCanvas for one authorized emailId with getSession
    calling the app's authenticated session endpoint. Carry the current CSRF token
    on create and renewal through createCanvasSessionProvider headers from
    @brew.new/sdk 13.0.0 or newer. Require
    the backend to verify tenant/target/session ownership on every request.
    Await flush before navigation, target change, an API edit, or HTML retrieval.
    Keep the editor open and show accessible feedback on save failure. Report a
    failed HTML read after successful flush as already saved. Read HTML through an
    authenticated backend endpoint that resolves saved brand/email ownership.
    The default GET reads latest; exact saved output must retain the canvas onSaved
    emailId and non-null emailVersionId, recheck their ownership, and pin that
    version. Flush does not promote historical versions.
    Do not retry ambiguous PATCH edits; reconcile first. Deliver the component,
    save action, and HTML endpoint. Mock Brew and iframe events; prove failed
    flush blocks completion, successful save reads persisted HTML, an HTML-read
    failure preserves the successful save state, historical saved-version reads
    select the intended version, and wrong-tenant reads fail.
    No live calls or sends. Report checks and installed SDK version.
    ```
  </Accordion>
</AccordionGroup>

## Check Your Deployed Integration

If your admin app sets a Content Security Policy, allow `https://brew.new` in
`frame-src`. Test your deployed HTTPS origin and a denied origin. Confirm the
CDN preserves the iframe framing policy. Check wrong-customer access,
read-only permissions, session renewal and revocation, manual save/reopen,
and image upload/crop against the real storage configuration.

Production checks are separate from mocked tests. Agree on any paid generation
before running it. [Run the Open Source Demo](/sdks/typescript/embedded-email-canvas#run-the-open-source-demo)
covers deployment checks that local fixtures cannot prove.

## Related

* [Embed the Email Canvas](/sdks/typescript/embedded-email-canvas)
* [SDK Error Handling](/sdks/typescript/error-handling)
* [Check Brand Readiness](/api-reference/guides/check-brand-readiness)
* [Organization and Brand Credentials](/api-reference/api/organization-and-brand-scope)

## Need Help?

Our team is ready to support you at every step of your journey with Brew. Choose the option that works best for you:

<Tabs>
  <Tab title="Self-Service Tools">
    <CardGroup cols="2">
      <Card title="Search Documentation" icon="magnifying-glass" color="#c44925">
        Type in the "Ask any question" search bar at the top left to instantly find relevant documentation pages.
      </Card>

      <Card title="Ask ChatGPT or Claude" icon="robot" color="#c44925" href="#top" className="ask-ai-card">
        Click here to open the **Copy page** menu at the top of this page, then send the page to ChatGPT, Claude, or your editor.
      </Card>
    </CardGroup>
  </Tab>

  <Tab title="Talk to Our Team">
    <CardGroup cols="2">
      <Card title="Schedule a Call" icon="calendar" color="#c44925" href="https://calendar.google.com/calendar/u/0/appointments/schedules/AcZssZ1iYoRUG1J792XQpbuQLjSRRDupr7MwraFK-HQRCtTYdBmrQi8nZu2qXfzKQigb8gbKJK3KN3-R">
        Book time with our founders for personalized guidance on strategy, best practices, or complex implementation questions.
      </Card>

      <Card title="Call Us Directly" icon="phone" color="#c44925">
        Need immediate assistance? Reach us at **+1-(332)-203-2145** for urgent issues or time-sensitive questions.
      </Card>

      <Card title="Slack Channel" icon="slack" color="#c44925">
        Our preferred support channel. You'll receive an invite after signup for direct founder support and fast responses.
      </Card>

      <Card title="Email Support" icon="envelope" color="#c44925" href="mailto:support@brew.new">
        Contact us at **[support@brew.new](mailto:support@brew.new)** for detailed inquiries or if you prefer not to use Slack.
      </Card>
    </CardGroup>
  </Tab>
</Tabs>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.