> ## 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.

# Migrate from Postmark to Brew

> Move contacts, suppressions, templates, and API sends from Postmark into Brew, map message streams to sending domains, and cut over without double-sends.

A cutover, not a sync. Contacts, suppressions, templates, and API calls move once, then nothing stays connected to Postmark. The rule behind every step below: **only one platform sends live email to a given contact at a time.**

This guide assumes your Brew brand and sending domain are already set up. If not, [verify your domain](/get-started/verify-your-sending-domain) first. DNS can take up to 72 hours.

<Note>
  Looking to go the other way, designing in Brew and sending from Postmark? See the [Postmark integration](/integrations/export/postmark) instead.
</Note>

<Tip>
  Not ready to commit? Leave your Postmark calls in place and route one new one-off email through Brew instead. Steps 1 to 5 cover contacts and a template, which is all you need for a single test send.
</Tip>

## Concepts

| Postmark | Brew |
| - | - |
| Recipient address your app passes in | [Contact](/audience/manage-contacts), see Step 1 |
| Transactional message stream | Transactional-purpose sending domain, see [Transactional Email](/create-emails/transactional-email) |
| Broadcast message stream | Marketing-purpose sending domain |
| Suppression (hard bounce, spam complaint, manual) | Contact marked `Subscribed: false`, see Step 1 |
| Broadcast unsubscribe | The marketing domain's [unsubscribe list](/brand/unsubscribe), or the brand-wide opt-out |
| Template | [Email](/create-emails/emails) |
| Layout | No separate object. Fold it into each email, see Step 5 |
| `POST /email/withTemplate` | A trigger fire on a published [automation](/create-emails/automations), see Step 7 |
| `TemplateModel` | The fire's `payload` |
| `{{name}}` | `{{ trigger.name }}` |
| `{{#each items}}` | `{% for item in trigger.items %}` |
| Server API token | Brew API key, under **Settings → API** |

## Check These Limits First

Sending mostly transactional mail through Postmark? Three Brew limits matter most for that kind of sending:

* **60 fires a minute.** Trigger fires share the `automation.runs.write` rate limit: 60 requests per credential over a rolling 60 seconds. A busy receipt or reset stream can pass that. See [Rate Limits](/api-reference/api/rate-limits).
* **No attachments.** Brew sends and trigger fires have no attachment field. A PDF invoice or a calendar invite can't ride along.
* **No delivery webhooks yet.** Webhooks for delivery, open, and bounce events are [on the roadmap](/api-reference/api/events-and-webhooks#outbound-webhooks-roadmap). Today you poll `GET /v1/sends` and `GET /v1/analytics/events` instead.

If a Postmark stream depends on any of these, keep that stream on Postmark for now. Move broadcast mail first.

## 1. Export Contacts, Consent, and Suppression

<Warning>
  Every marketing recipient must have opted in to hear from you. Importing or syncing contact data into Brew is not permission to email it. See [What You Can Send](/get-started/what-you-can-send).
</Warning>

Postmark has no contact database. It sends to whatever address your code passes in each call, so there's no contact list to export. Your contacts come from your own app: the users table, your CRM, or wherever you keep signups.

Export them from there. Keep these columns:

* Email, first name, last name, and any field your templates use
* Your marketing opt-in flag, with the date the person opted in, if your app stores one

Only people who opted in to marketing belong in a marketing audience. An account in your app, or a receipt sent through Postmark, isn't consent.

| Person | What to do |
| - | - |
| Opted in to marketing | Import normally |
| No marketing opt-in, such as someone who only gets receipts | Import, marked `Subscribed: false` |

Import the second group anyway. A [trigger fire](/create-emails/transactional-email#what-to-know-before-you-ship-one) for an address Brew doesn't know creates a subscribed contact. Anyone you leave out would land in your marketing audience the first time Brew sends them a receipt.

### Export Suppressions per Stream

Postmark keeps a separate suppression list for each message stream. An address suppressed on your transactional stream can still be active on your broadcast stream, and the other way around. Export every stream's list.

In Postmark, open **Servers**, pick the server, open a message stream, then its **Suppressions** tab, then **Export**. The file is JSON, not CSV, so convert it before Step 2. From code, `GET /message-streams/{stream_id}/suppressions/dump` returns the same list with each address's `SuppressionReason`.

| Stream | Reason | What to do |
| - | - | - |
| Any | `HardBounce` | Import, marked `Subscribed: false` |
| Any | `SpamComplaint` | Import, marked `Subscribed: false` |
| Transactional | `ManualSuppression` | Import, marked `Subscribed: false` |
| Broadcast | `ManualSuppression` | Import into the marketing domain's unsubscribe list, see Step 3 |

On a broadcast stream, an unsubscribe is stored as `ManualSuppression`. The **Suppressions** tab labels it **Unsubscribed**.

`Subscribed: false` is the brand-wide opt-out. A broadcast unsubscribe is narrower: the person left that one stream.

In Brew, each marketing domain keeps its own unsubscribe list, which is the closer match. See [Two Ways to Opt Out](/brand/unsubscribe#two-ways-to-opt-out).

Ran several broadcast streams and can't tell which one a person meant to leave? Mark them `Subscribed: false`. Stopping too much mail is the safe mistake.

<Warning>
  `Subscribed: false` stops marketing only. [Transactional](/create-emails/transactional-email) mail still reaches unsubscribed contacts, and an import can't mark an address as bounced.

  Brew suppresses an address after its own first hard bounce. Until then, keep your app from firing receipts at addresses Postmark suppressed for a hard bounce.
</Warning>

Postmark's suppressions say nothing about consent, and its activity history stays behind. Brew's engagement-based audiences start counting from your import date.

[Bring Your Consent Records](#bring-your-consent-records) covers carrying the opt-in date over.

## 2. Map Your CSV

| Column | Required |
| - | - |
| `Email` | Yes |
| `First Name`, `Last Name` | Recommended |
| Your marketing opt-in flag | Convert it to a `Subscribed` column. Opted in becomes `true`. Everything else becomes `false` |
| Anything else | Becomes a custom property automatically, except columns Brew manages such as `Created At`, which are skipped |

Notes:

* Postmark's suppressions name the address field `EmailAddress`. In your CSV, call that column `Email` and add a `Subscribed` column set to `false`.
* Don't import your opt-in flag as it is. Brew reads opt-outs only from a `Subscribed` column, so any other column becomes a plain custom property and those people import as subscribed.
* New columns become camelCase properties. A `Plan Type` column becomes `planType`. Use that name in merge tags and filters.
* Empty cells don't overwrite existing data on update imports.
* Duplicate rows: the last one wins, so dedupe first.
* These names belong to built-in contact fields and can't be used for custom properties: `email`, `firstName`, `lastName`, `subscribed`, `createdAt`, `updatedAt`, `validationStatus`, `lastValidatedAt`, `importId`, `csvFileName`.
* Save as UTF-8. Garbled characters at the mapping step usually mean it wasn't.
* Dates: day-first dates such as `03/04/2026` (3 April) import correctly when you pick day first at the mapping step, and `YYYY-MM-DD` needs no choice. See [date formats](/audience/manage-contacts#custom-properties).

Full import behavior lives in [Add Contacts](/audience/add-contacts).

## 3. Import into Brew

**Audience → Add contacts → CSV Upload.** Map columns, review, then import. Tick the [validation checkbox](/audience/validate-contacts) first if the list hasn't been checked recently. It costs 2 credits per address, which is worth it before warming up a new domain.

Upload the opted-in contacts, the people without an opt-in, and the suppressions as separate files. That keeps `Subscribed` set correctly per batch.

Broadcast unsubscribes go to a domain instead. Open **Domains** in the sidebar and pick the marketing domain that replaces your broadcast stream. Then click **View list → Import CSV**.

Each file holds up to 10,000 rows. See [Import a List from Another Provider](/brand/unsubscribe#import-a-list-from-another-provider).

### Bring Your Consent Records

A contact's `Subscribed` status says Brew may email them. A consent record says how and when they agreed. Carry both over.

* **Export the evidence.** Postmark stores no consent, so the evidence lives in your app: the signup checkbox, its label, and the opt-in date. Keep those columns as custom properties.
* **In the app,** tick the consent checkbox at the review step. Brew records consent once for the whole file, dated the day you import.
* **To keep each person's original opt-in,** import over the API instead. `POST /v1/contacts` takes a `consent` record per contact, with the original date as `capturedAt`. It never changes `Subscribed`.

```json theme={null}
{
  "contacts": [
    {
      "email": "jane@example.com",
      "consent": { "source": "import", "capturedAt": "2024-03-02T10:15:00Z", "evidence": "Newsletter checkbox on /signup" }
    }
  ]
}
```

## 4. Rebuild Segmentation

Postmark has no segments, so there's little to rebuild. Your code picked who got each broadcast, usually with a database query.

Rebuild each query as an [Audience](/audience/create-audiences), a dynamic filter over contact properties, email engagement, and subscription status. Describe one in plain English or build the filters by hand. Import every property the query used, such as `plan` or `lastActiveAt`, in Step 3.

Before cutover, check that each Audience's count is close to what the query returns. A wrong translation can undercount or overcount who gets an email.

## 5. Rebuild Templates and Merge Tags

Postmark templates live on each server's **Templates** tab. To pull them all at once, Postmark's CLI runs `postmark templates pull <path>`. It writes each template's HTML to `content.html`, with layouts in `_layouts`.

From code, `GET /templates/{templateIdOrAlias}` returns one template's `HtmlBody`, `TextBody`, and `Subject`.

A template that uses a layout holds only the middle of the email. Copy the layout too, and paste the template's body where the layout says `{{{ @content }}}`.

Brew has no layouts. Each email carries its own header and footer, styled from your [brand identity](/brand/brand-identity).

| You have | Use | Result |
| - | - | - |
| A template's HTML | Import email (the `</>` button) | Editable email, imports the original markup as-is |
| A Figma frame | Connect with Figma | Responsive email |
| A screenshot | Upload image to recreate | Rebuilt structure and styling |
| A sent email | Forward to `hello@email.brew.new` | Lands in **Templates → Forwarded** |

In Brew, open **Emails** in the sidebar. The import tools sit in the toolbar on the right side of the canvas. See [Importing existing designs](/create-emails/emails#importing-existing-designs).

For most templates, use the HTML or screenshot as reference. Prompt Brew to rebuild it against your brand rather than treating the import as finished. It's faster, and the result inherits your brand identity automatically.

Keep each GIF under 2 MB so it loads fast on a phone.

### Translate Merge Tags

Postmark templates use Mustachio. Brew runs [Liquid](/create-emails/merge-tags#liquid-templating).

Most values in a template came from the `TemplateModel` in your API call. They become fields of the trigger's `payload`, which Liquid reads under `trigger.`.

* `{{name}}` becomes `{{ trigger.name }}`. Declare each field on the trigger, because an undeclared key never reaches the email. Names are case-sensitive.
* `{{company.name}}` becomes `{{ trigger.company.name }}`. Declare the nesting on the trigger. See [Nested Payloads](/create-emails/merge-tags#nested-payloads).
* `{{{name}}}` becomes `{{ trigger.name }}`. Triple braces aren't merge tags in Brew, and publish refuses most of them.
* `{{#each items}}` … `{{/each}}` becomes `{% for item in trigger.items %}` … `{% endfor %}`. Inside the loop, Mustachio's bare `{{price}}` becomes `{{ item.price }}`, and `{{.}}` becomes `{{ item }}`.
* A parent-scope tag such as `{{../company_name}}` becomes the full path, `{{ trigger.company_name }}`.
* `{{#show_receipt}}` … `{{/show_receipt}}` becomes `{% if trigger.show_receipt %}` … `{% endif %}`. An inverted `{{^show_receipt}}` becomes `{% unless trigger.show_receipt %}`.
* A value about the person, not the event, can come from the contact instead. Write `{{ firstName | there }}`, and the fallback after the pipe shows when the contact has no name on file.
* Delete `{{{ @content }}}` once the layout is folded in, and delete `{{{ pm:unsubscribe }}}`. Brew adds the unsubscribe link and your mailing address to every marketing email.

## 6. Rebuild Automations

Postmark has no automations. Every email it sent started with a call from your code. So this step is mostly setup for Step 7.

| What your code sent through Postmark | Brew |
| - | - |
| One template per event, on the transactional stream, such as a password reset | Trigger plus a one-step automation on a transactional-purpose domain |
| One template per event, on the broadcast stream, such as a welcome email | Trigger plus a one-step automation on a marketing domain |
| A batch to many people, such as a newsletter | An [Email](/create-emails/emails) sent to an Audience. No automation needed |
| A sequence your own jobs scheduled, such as day 1, day 3, day 7 | One automation with Wait nodes between the Send Email steps |

Test-fire before publishing. Leave every rebuilt automation unpublished until cutover day, which is what prevents a double-send. [Build an Automation](/create-emails/build-an-automation) covers the trigger setup and the test step.

## 7. Swap the API Calls in Your Code

Postmark's API lives at `https://api.postmarkapp.com`. It takes a server token in an `X-Postmark-Server-Token` header. Brew uses one host, a bearer key, and plain JSON.

**Template sends.** Each `withTemplate` call becomes a fire of the trigger you built in Step 6. Every published automation listening for it starts.

Before, with Postmark:

```js theme={null}
await fetch('https://api.postmarkapp.com/email/withTemplate', {
  method: 'POST',
  headers: {
    Accept: 'application/json',
    'Content-Type': 'application/json',
    'X-Postmark-Server-Token': process.env.POSTMARK_SERVER_TOKEN,
  },
  body: JSON.stringify({
    From: "receipts@example.com",
    To: "user@example.com",
    TemplateAlias: "receipt",
    TemplateModel: {
      name: "Ada",
      order: { total: "$49.00", items: [{ name: "Pro seat", price: "$49.00" }] },
    },
    MessageStream: "outbound",
  }),
});
```

After, with Brew:

```js theme={null}
await fetch(
  `https://brew.new/api/v1/automations/triggers/${process.env.BREW_RECEIPT_TRIGGER_ID}/fire`,
  {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.BREW_API_KEY}`,
      'Content-Type': 'application/json',
      'Idempotency-Key': `receipt-${order.id}`,
    },
    body: JSON.stringify({
      payload: {
        email: "user@example.com",
        name: "Ada",
        order: { total: "$49.00", items: [{ name: "Pro seat", price: "$49.00" }] },
      },
    }),
  },
);
```

What moves where:

* `To` becomes the payload's `email`. That's the contact the automation runs for.
* `TemplateModel` becomes the rest of the `payload`, read in the email as `{{ trigger.order.total }}`.
* `TemplateAlias`, `From`, and `MessageStream` leave the call. The automation's Send Email step holds the email, the subject, and the sending domain. The domain's purpose does the job the stream did.
* Open and click tracking follow the sending domain's settings, not a flag on each call.

Postmark has no idempotency key, so a retried call can send twice. Brew's `Idempotency-Key` header fixes that.

Derive it from something stable about the action, like the order id above, so a repeat fire starts nothing new. See [Idempotency](/api-reference/api/idempotency).

Postmark answers `200` once it accepts a message. Brew answers `202`, which means queued, not delivered.

The response lists `automationRunIds`. Follow one with `GET /v1/automations/runs/{automationRunId}`.

**Batch sends.** A newsletter sent with `/email/batchWithTemplates` or the Bulk Email API (`/email/bulk`) becomes one Email sent to an Audience. Send it from the app, or from code with `POST /v1/sends` and an `audienceId`.

**Marketing opt-ins.** Postmark never stored contacts, so this call is new. When someone opts in to marketing in your app, add them to Brew:

```js theme={null}
await fetch('https://brew.new/api/v1/contacts', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.BREW_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    email: "user@example.com",
    firstName: "Ada",
    subscribed: true,
    consent: { source: "form", capturedAt: new Date().toISOString() },
  }),
});
```

`subscribed: true` applies only to a new contact. A single upsert that asks for it on an opted-out contact gets `422 RESUBSCRIBE_NOT_ALLOWED`, and nothing is written. Handle that `422` by sending the same call without `subscribed`.

Full request shapes are in the [API reference](/api-reference/api/api-introduction). This section shows the shape of the migration, not a copy-paste replacement for your code.

## 8. Cut Over

Move in this order:

1. **Rebuild while Postmark keeps sending.** Import contacts and rebuild audiences, templates, and automations. None of it touches live sending. Keep every Brew automation unpublished.
2. **Check your segments.** Confirm each Audience's count against the query it replaces. See Step 4.
3. **Refresh opt-outs.** Right before your first Brew send, export each stream's new suppressions from Postmark and import them as in Step 3. Repeat before each type you move, until Postmark stops sending. Anyone who opted out during the rebuild stays opted out.
4. **Warm up with the first type you move.** Stop sending that type through Postmark first, so nobody gets it from both. Send to your most engaged contacts first and ramp over several days. [Gradual send](/create-emails/send-options#gradual-send) does the ramping for you. No history means no reputation, even on a verified domain.
5. **Move one type at a time.** Broadcasts first, then event-driven marketing email. For each template, in order: publish the Brew automation, then switch that call in your code from Postmark to the Brew fire. Watch each in [analytics](/analytics/reading-analytics) before you move the next.
6. **Move transactional email last.** Skip this step for any stream a [limit above](#check-these-limits-first) rules out. Before you move any, add a separate transactional-purpose subdomain (see [Transactional Email](/create-emails/transactional-email)). A fire for a new address creates a subscribed contact, so only send marketing to people who opted in. Receipts and resets are the highest-stakes sends. Cut them over one at a time, and test-run each automation with a real payload before you point production code at its trigger.
7. **Keep Postmark as a fallback.** Leave the account and its server token in place for a couple of weeks, so you can switch a call back. Then downgrade or cancel. If you receive mail through an inbound stream, keep it on Postmark or move it to an inbound provider.

Never leave the same email type live in both places at once. That's how a contact gets the same receipt twice.

## Quick Reference

| Task | Where in Brew |
| - | - |
| Import contacts | Audience → Add contacts → CSV Upload |
| Import broadcast unsubscribes | Domains → your marketing domain → View list → Import CSV |
| Manage unsubscribes and bounces | Audience tab, contact detail view |
| Build a segment | Audience → Create Audience |
| Import a template | Emails → canvas → Import |
| Build a flow | Automations in the sidebar |
| Create a trigger event | Trigger events, or `POST /v1/automations/triggers` |
| API keys | Settings → API |

## 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="ChatGPT/Claude Integration" icon="robot" color="#c44925">
        Click "Open in ChatGPT" at the top right of any page to explore it further with ChatGPT or Claude.
      </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.