> ## 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 SendGrid to Brew

> Move contacts, suppressions, unsubscribe groups, templates, automations, and Mail Send calls from SendGrid into Brew without double-sends.

A cutover, not a sync. Contacts, opt-outs, templates, segments, automations, and API calls move once, then nothing stays connected to SendGrid. 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 SendGrid? See the [SendGrid integration](/integrations/export/sendgrid) instead.
</Note>

<Tip>
  Not ready to commit? Leave SendGrid sending your transactional mail and route one new marketing 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

| SendGrid | Brew |
| - | - |
| Contact (Marketing Campaigns) | Contact |
| Custom field | Custom property |
| List | Custom property plus an Audience filter, see Step 4 |
| Segment | [Audience](/audience/create-audiences) |
| Global unsubscribe | Contact marked `Subscribed: false`, the brand-wide opt-out |
| Unsubscribe group | A marketing domain's unsubscribe list, see Step 1 |
| Bounce, invalid email, spam report | Contact marked `Subscribed: false`, see Step 1 |
| Single Send | [Email](/create-emails/emails) |
| Automation | [Automation](/create-emails/automations) |
| Dynamic template sent with Mail Send | Trigger plus a one-step automation on a transactional-purpose domain, see Step 6 |
| `dynamic_template_data` | The fire's `payload`, see Step 7 |
| `{{insert first_name "default=there"}}` | `{{ firstName \| there }}` |
| `{{#each items}}` | `{% for item in trigger.items %}` |

## Check These Limits First

Brew doesn't yet match three things SendGrid's Email API does. If your integration depends on any of them, keep that mail on SendGrid for now and move marketing first.

| On SendGrid | In Brew today |
| - | - |
| High-volume Mail Send calls | Trigger fires share the `automation.runs.write` limit, 60 requests a minute per credential. See [Rate Limits](/api-reference/api/rate-limits#per-route-policies) |
| Attachments, such as a PDF invoice | No attachments. Sends and fires carry no files, so link to the file from the email |
| Event Webhook for delivery, open, and bounce events | Outbound webhooks aren't available yet. Poll `GET /v1/sends` and `GET /v1/analytics/events` instead. See [Events and Webhooks](/api-reference/api/events-and-webhooks) |

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

**Contacts.** Go to **Marketing → Contacts**. Click the action menu (⋮) next to **All Contacts**, or next to one list, then click **Export**.

SendGrid emails you when the file is ready. Download it from the **Active Exports** page within 12 hours of starting the export.

Keep these columns:

* Email, first name, last name, and any custom fields you use
* Any custom field that records how or when someone opted in

Mail you send only through the Email API has no SendGrid contact. Those recipients live in your own user records. Brew creates a contact for each the first time you fire a trigger for them, see Step 7.

**Suppression.** A SendGrid contact has no subscription field. Opt-outs and bad addresses live on the **Suppressions** lists in the left menu. Export each one:

| List | How to export | What to do |
| - | - | - |
| Global Unsubscribes | Action menu at the top → **Download as CSV** | Import, marked `Subscribed: false` |
| Bounces | Gear icon → **Export CSV** | Import, marked `Subscribed: false` |
| Invalid Emails | Gear icon → **Download CSV** | Import, marked `Subscribed: false` |
| Spam Reports | Gear icon → **Download CSV** | Import, marked `Subscribed: false` |
| Group Unsubscribes | **Unsubscribe Groups** page, action menu next to a group → **Export** | See [Decide Where Group Opt-Outs Go](#decide-where-group-opt-outs-go) |
| Blocks | Gear icon → **Export CSV** | Don't import. See below |

A block is about your sending IP or your content, not the address. SendGrid doesn't suppress blocked addresses, so don't import them as opt-outs. Read the block reasons instead.

On SendGrid, a global unsubscribe, bounce, or spam report stops every send to that address, transactional mail included. Brew splits the two:

* **`Subscribed: false` stops marketing only.** Transactional mail from a transactional-purpose domain still reaches the contact. That's what you want for a password reset.
* **Brew can't import a bounce as a suppression.** It suppresses an address after one of its own sends hard-bounces. Fix or remove bounced and invalid addresses in your own user records before you move transactional mail.

Import every opt-out and bad address, even ones that aren't contacts yet. A contact you leave out can come back later through a form or an integration as a new, subscribed contact.

SendGrid keeps engagement history, such as last opened and last clicked, on the contact. Brew's engagement-based audiences start counting from your import date.

### Decide Where Group Opt-Outs Go

Each SendGrid unsubscribe group is one kind of mail, such as a newsletter or product updates. Brew has no groups.

Opt-outs work at two levels. Each marketing domain keeps its own unsubscribe list, and **Unsubscribe from all** covers them all. See [Unsubscribes](/brand/unsubscribe#two-ways-to-opt-out).

Map each group before you import:

| The group's mail moves to | Put its opt-outs |
| - | - |
| Its own Brew marketing domain | That domain's unsubscribe list |
| A Brew marketing domain it shares with other groups | That domain's list. Someone who left one group now leaves them all on that domain |
| No separate domain | The brand-wide opt-out, as `Subscribed: false` |
| A transactional-purpose domain | Ask first. Transactional mail ignores opt-outs, so mail people could opt out of probably isn't transactional |

A global unsubscribe always maps to the brand-wide opt-out.

## 2. Map Your CSV

| Column | Required |
| - | - |
| `Email` | Yes |
| `First Name`, `Last Name` | Recommended. Map SendGrid's first and last name columns to them at the mapping step |
| `Subscribed` (`true`/`false`, defaults `true`) | Add it yourself. Set `false` on every row of the suppression files |
| Anything else | Becomes a custom property automatically, except columns Brew manages such as `Created At`, which are skipped |

Notes:

* SendGrid's export has no consent column. Brew reads opt-outs only from a `Subscribed` column, so add one to each suppression file before you import it
* SendGrid's activity columns, such as last opened and last clicked, import as plain properties and stop updating on the day you export. Drop the ones you won't filter on
* 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 contact file and each suppression file separately. That keeps `Subscribed` set correctly per batch.

Then import each group's opt-outs into the domain you chose in Step 1. Open **Domains** in the sidebar, pick the domain, click **View list** on its **Unsubscribes** card, then **Import CSV**.

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

Import contacts before the group files. An address that isn't a contact yet can end up opted out of all mail, not just one domain.

### 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.** SendGrid stores no consent record. If you saved an opt-in date or source in a custom field, keep that column as a custom property.
* **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": "SendGrid signup form, optInDate field" }
    }
  ]
}
```

## 4. Rebuild Segmentation

[Audiences](/audience/create-audiences) are dynamic filters over contact properties, email engagement, and subscription status. Describe one in plain English or build the filters by hand.

**SendGrid segments** filter on contact fields, email activity, and Single Send or Automation activity. Contact fields carry over as properties. Activity starts fresh in Brew, so a segment like "opened in the last 30 days" refills as you send.

**Lists don't have a direct equivalent.** Rebuild each list as a custom property plus an Audience filtered on it. Add a column to that list's export, such as `newsletterList` set to `true`, and import it.

**Keep marketing to people who opted in.** A fire for a new address creates a subscribed contact. Once receipts and resets run through Brew, your audience fills with customers who never chose marketing. Build every marketing Audience on a property only opted-in contacts carry, such as `marketingOptIn: true` from your import.

Before cutover, spot-check that a recreated Audience's membership count is in the right ballpark against the original segment. A wrong translation can undercount or overcount who gets an email, and that isn't always visible in the UI.

## 5. Rebuild Templates and Merge Tags

| 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 live email | Forward to `hello@email.brew.new` | Lands in **Templates → Forwarded** |

To get a dynamic template's HTML, open **Email API → Dynamic Templates** in SendGrid, then the template's active version. In the Design Editor, the **Build** tab's **Advanced** section exports the HTML. In the Code Editor, copy it.

From code, `GET /v3/templates/{template_id}` returns each version's `html_content`.

In Brew, open **Emails** in the sidebar, then use the import tools in the toolbar right 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](/brand/brand-identity) automatically.

Running a long parallel test instead of a fast cutover? You can design in Brew and still send from SendGrid.

Click **Send** in the top right, choose **Export template**, and pick SendGrid. That pushes the finished email to SendGrid as a dynamic template through the [SendGrid integration](/integrations/export/sendgrid).

### Translate Merge Tags

SendGrid dynamic templates use Handlebars. Brew runs [Liquid](/create-emails/merge-tags#liquid-templating). Both use `{{ }}` for values, but logic blocks look different.

Brew property names are camelCase and case-sensitive, and a snake\_case name renders empty.

If you keep an imported template's HTML as it is, replace SendGrid's tags before you send:

* `{{first_name}}` becomes `{{ firstName }}`, and `{{last_name}}` becomes `{{ lastName }}`. A custom field becomes the camelCase name from your import, such as `{{ planType }}`.
* `{{insert first_name "default=there"}}` becomes `{{ firstName | there }}`. The fallback after the pipe shows for contacts with nothing on file.
* Values from `dynamic_template_data` become `{{ trigger.* }}`, named after the key you send in the fire's `payload`. `{{order.number}}` becomes `{{ trigger.order.number }}`. See Step 7.
* `{{#if}}`, `{{else if}}`, `{{else}}`, and `{{/if}}` become `{% if %}`, `{% elsif %}`, `{% else %}`, and `{% endif %}`. `{{#unless}}` becomes `{% unless %}`.
* Comparison helpers become operators inside `{% if %}`. `{{#equals a b}}` is `{% if a == b %}`, and `notEquals`, `greaterThan`, and `lessThan` are `!=`, `>`, and `<`. `{{#and}}` and `{{#or}}` become `and` and `or`.
* `{{#each order.items}}{{this.name}}{{/each}}` becomes `{% for item in trigger.order.items %}{{ item.name }}{% endfor %}`. Inside the loop, write the full path where Handlebars used `@root`.
* `{{formatDate}}` and `{{length}}` become Liquid filters, such as `date` and `size`. Check each one against the [Liquid templating](/create-emails/merge-tags#liquid-templating) reference.
* Triple braces, such as `{{{name}}}`, are a legacy spelling in Brew. Rewrite them with two braces.
* Legacy substitution tags, such as `-name-` sent in `substitutions`, become `{{ trigger.name }}` from the fire's `payload`.
* Delete `{{{unsubscribe}}}`, `{{{unsubscribe_preferences}}}`, the `<%asm_...%>` tags, and the `{{Sender_Address}}` family. Brew adds the unsubscribe link and your mailing address to every marketing email. Transactional mail carries no unsubscribe link.
* Remove `{{Weblink}}`, the view-in-browser link.

## 6. Rebuild Automations

**Marketing Campaigns automations** become Brew automations on a marketing-purpose domain.

| SendGrid Automation | Brew |
| - | - |
| Entry criteria: a list, such as a welcome series | Custom HTTP trigger fired from your signup form, or an integration event |
| Entry criteria: a segment | Manual-audience run, or a trigger you fire when the contact qualifies |
| Time between emails | Wait |
| Exit criteria | Filter before each Send Email step |
| Unsubscribe group | The sending domain on the Send Email step |
| Email | Send Email, pointed at your rebuilt template |

**Transactional email.** Most SendGrid mail is a Mail Send call with a dynamic template, like a receipt. In Brew, the sending domain decides what's transactional.

Rebuild each template as a trigger plus a one-step automation on a transactional-purpose domain. One trigger per kind of email. See [Transactional Email](/create-emails/transactional-email).

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

SendGrid's v3 API lives at `https://api.sendgrid.com/v3` and takes a bearer key. Brew does too, at `https://brew.new/api/v1`.

**Contact upsert.**

Before, with SendGrid:

```js theme={null}
await fetch('https://api.sendgrid.com/v3/marketing/contacts', {
  method: 'PUT',
  headers: {
    Authorization: `Bearer ${process.env.SENDGRID_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    list_ids: [process.env.SENDGRID_NEWSLETTER_LIST_ID],
    contacts: [
      {
        email: "user@example.com",
        first_name: "Ada",
        last_name: "Lovelace",
        custom_fields: { [process.env.SENDGRID_PLAN_FIELD_ID]: "pro" },
      },
    ],
  }),
});
```

After, with 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",
    lastName: "Lovelace",
    subscribed: true,
    customFields: { plan: "pro", newsletterList: true },
    consent: { source: "form", capturedAt: new Date().toISOString() },
  }),
});
```

SendGrid keys custom fields by field ID and queues the upsert, answering `202` with a `job_id`. Brew takes custom fields by name. A list becomes a custom property, as in Step 4.

Adding someone to a SendGrid list opts no one in. In Brew, `subscribed: true` opts the person in, and the `consent` record says how. The `consent` record never changes `subscribed`.

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

**Transactional sends.** A Mail Send call becomes a fire. Fire the trigger, and its published automation sends the email.

Before, with SendGrid:

```js theme={null}
await fetch('https://api.sendgrid.com/v3/mail/send', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.SENDGRID_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    from: { email: "orders@example.com" },
    template_id: process.env.SENDGRID_RECEIPT_TEMPLATE_ID,
    personalizations: [
      {
        to: [{ email: "user@example.com" }],
        dynamic_template_data: {
          first_name: "Ada",
          order: { number: "1042", items: [{ name: "Mug", qty: 2 }] },
        },
      },
    ],
  }),
});
```

After, with Brew:

```js theme={null}
await fetch(
  `https://brew.new/api/v1/automations/triggers/${process.env.BREW_ORDER_PLACED_TRIGGER_ID}/fire`,
  {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.BREW_API_KEY}`,
      'Content-Type': 'application/json',
      'Idempotency-Key': `order-placed-${order.id}`,
    },
    body: JSON.stringify({
      payload: {
        email: "user@example.com",
        firstName: "Ada",
        order: { number: "1042", items: [{ name: "Mug", qty: 2 }] },
      },
    }),
  },
);
```

The `to` address becomes the payload's `email`, the contact the automation runs for. `dynamic_template_data` becomes the `payload`, read in the email as `{{ trigger.order.number }}`. The template, sender, and subject move into the automation, so `template_id`, `from`, and `asm` leave your code.

Nest order data in an object, as above. Object and array fields stay template data. Declared top-level values, such as `firstName`, are saved on the contact.

Fire once per recipient. A Mail Send call with several `personalizations` becomes several fires, each with its own key. Each fire counts toward the rate limit in [Check These Limits First](#check-these-limits-first).

SendGrid's Mail Send takes no idempotency key, so a retry can send twice. On Brew, send an `Idempotency-Key` derived from the action, like the order id above.

A repeat with the same key starts nothing new. See [Idempotency](/api-reference/api/idempotency).

A fire answers `202` once its runs start, not when the email is delivered. A suppressed address counts under `counts.skipped`. `422 NO_PUBLISHED_AUTOMATION` means nothing published listens for the trigger, so publish before you point code at it.

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 SendGrid keeps sending.** Import contacts and rebuild segments, templates, and automations. None of it touches live sending. Keep every Brew automation unpublished.
2. **Check your segments.** Confirm each recreated Audience's membership count against the original. See Step 4.
3. **Refresh opt-outs.** Right before your first Brew send, export SendGrid's new global unsubscribes, bounces, invalid emails, and spam reports and import them into Brew as `Subscribed: false`. Export the group unsubscribes again too. Repeat before each type you move, until SendGrid stops sending. Anyone who opted out during the rebuild stays opted out.
4. **Warm up with the first type you move.** Pause that type on SendGrid 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.** Single Sends first, then automations. For each automation, in order: disable it on SendGrid, publish the Brew automation, then point its signup forms or app events at Brew. Watch each in [analytics](/analytics/reading-analytics) before you move the next.
6. **Move transactional email last,** and only mail the limits in [Check These Limits First](#check-these-limits-first) don't block. 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. Order confirmations 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 SendGrid as a fallback.** Leave its API key and templates in place for a couple of weeks, so you can switch a code path back. Then downgrade or cancel.

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 a group's opt-outs | Domains → pick the domain → Unsubscribes → 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 |
| Send a transactional email | `POST /v1/automations/triggers/{triggerEventId}/fire` |
| 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="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.