---
title: "Migrate from SendGrid to Unitpost"
description: "Leave SendGrid in order: import contacts and suppressions, copy unsubscribe-group opt-outs, re-verify DNS, port dynamic templates, swap the webhook verifier."
url: https://www.unitpost.com/migration/sendgrid
section: Migration
updated: 2026-11-02
---
# Migrate from SendGrid to Unitpost

To leave SendGrid, paste a SendGrid API key into Unitpost's Migration Assistant. It exports your Marketing contacts with custom fields and list membership, imports bounces, spam reports, blocks and global unsubscribes, and brings dynamic templates, authenticated domains and the event webhook. Unsubscribe-group opt-outs and segments do not import, so copy group opt-outs before any campaign. Send history, Single Sends, automations, subusers and IP pools stay at SendGrid.

**Medium migration.** Contacts, bounces, spam reports, global unsubscribes and dynamic templates import, but unsubscribe-group opt-outs and segments do not, so you copy those by hand before the first campaign.

## What comes with you

| Data | Result | What happens |
| --- | --- | --- |
| Contacts | Imports | The importer runs SendGrid's contact export (once for the account, once per list) and imports every Marketing contact with name and custom fields under their field names. Addresses you only emailed through Mail Send are not Marketing contacts unless you added them. |
| Suppressions (bounces and complaints) | Imports | Bounces and spam reports import with their reason. Blocks import too, as suppressions that stop every send, although SendGrid itself stops suppressing a blocked address. Invalid emails are not read. |
| Unsubscribes and consent | Partly imports | Global unsubscribes import as unsubscribe suppressions, and those contacts land unsubscribed, which stops marketing here. Unsubscribe-group opt-outs are not read; step 2 copies them. |
| Lists and segments | Partly imports | Marketing lists become static segments with their members. SendGrid segments are not read, even though the preview shows a notice about them; rebuild them as dynamic segments. |
| Topics | Partly imports | Each unsubscribe group becomes a topic that contacts are enrolled in by default, as groups work. Nobody's group opt-out comes with it. |
| Templates | Partly imports | Dynamic templates import the active version's subject, HTML and text as drafts in code mode. Plain `{{name}}` placeholders work; Handlebars helpers such as `{{#if}}` and `{{#each}}` do not, and legacy templates are not read. |
| Sending domains | Partly imports | Authenticated domains are recreated as verifying with a new DKIM key. Link branding is not read. |
| Webhooks | Partly imports | Only the webhook behind SendGrid's single-webhook settings endpoint is read, so accounts with several Event Webhooks lose the rest. It is recreated switched off with a new signing secret. |
| API keys | Rebuild | Keys cannot be read. Create Unitpost keys with the scopes each service needs. |
| Single Sends | Rebuild | Not read. Recreate scheduled ones as campaigns on a segment. |
| Subusers | Rebuild | The importer reads only the account its key belongs to. Unitpost has no subusers, so give each subuser its own workspace and run the importer with a key from that subuser. |
| Send history and analytics | Stays behind | Email Activity, stats and Single Send results stay in SendGrid. |
| Automations | Stays behind | Marketing automations stay in SendGrid; Unitpost automations are not launched yet. |
| IP pools and dedicated IPs | Stays behind | IP pools, dedicated IPs and their warmup history stay at SendGrid; nothing imports them. |

## Before you start

- A SendGrid API key with Full Access, or a Restricted Access key with the read scopes listed under the key field in [Settings → Migration](/dashboard/settings/migration). A narrower key skips whatever it cannot read.
- A Unitpost API key with `emails:send` for your app, created in [API keys](/dashboard/api-keys).
- A second Unitpost key for the migration steps, with `templates:write`, `topics:read`, `topics:write` and `webhooks:manage`, saved as `UNITPOST_MIGRATION_KEY`. Keep it out of your app and revoke it once the move is done.
- Access to your DNS host.
- If you use subusers, a decision on which workspace each one becomes.

## The cut-over plan

### 1. Run the Migration Assistant

Paste the key in [Settings → Migration](/dashboard/settings/migration). The snapshot waits on SendGrid's contact export jobs, so a large account takes longer. In the preview's suppression list, SendGrid blocks show as `other`: exclude the ones you still want to mail before you accept. Suppressions are written before contacts, so globally unsubscribed contacts land unsubscribed.

### 2. Copy unsubscribe-group opt-outs

Do this before any campaign scoped to a topic. Read every group unsubscribe from `GET /v3/asm/suppressions` and opt those addresses out of the matching topic with `POST /api/v1/email/topics/{id}/opt-out`. Each address must already be a contact here: the call refuses the whole list at the first one that is not.

```ts
import { Unitpost } from "unitpost";

const unitpost = new Unitpost(process.env.UNITPOST_MIGRATION_KEY);

// 1. Every group unsubscribe: one row per address per group.
const rows = await fetch("https://api.sendgrid.com/v3/asm/suppressions", {
  headers: { Authorization: "Bearer " + process.env.SENDGRID_API_KEY },
}).then((r) => r.json());

// 2. The importer named each topic after its unsubscribe group.
for await (const topic of unitpost.email.topics.listAll()) {
  const emails = rows
    .filter((r) => r.group_name === topic.name)
    .map((r) => r.email.toLowerCase());
  for (let i = 0; i < emails.length; i += 1000) {
    const { error } = await unitpost.email.topics.optOut(topic.id, {
      emails: emails.slice(i, i + 1000),
    });
    if (error) throw new Error(topic.name + ": " + error.message);
  }
}
```

### 3. Verify your domains next to SendGrid

Open [Domains](/dashboard/email/domains) and publish the records shown: MX and SPF on `unitpost.<your domain>` and a DKIM TXT under `_domainkey`. SendGrid's `em` and `s1`/`s2._domainkey` CNAMEs stay where they are, so both providers verify at once. Keep your single `_dmarc` record.

### 4. Port dynamic templates

Unitpost fills `{{name}}` placeholders only, with no loops or conditionals, so render `{{#each}}` tables in your code or split the template. `first_name`, `last_name`, `email` and `unsubscribe_url` are reserved and fill per recipient, so rename variables that use them and replace `{{{unsubscribe}}}` with `{{unsubscribe_url}}`. Imports land as marketing drafts, which adds a footer: set receipts and resets to `transactional` and publish with `PATCH /api/v1/email/templates/{id}`.

### 5. Switch the SDK

The display name moves into `from` (`Acme <hi@acme.com>`). A template send passes `template: { id, variables }` with Unitpost's `tmpl_` id instead of `template_id` and `dynamic_template_data`, and `scheduled_at` (ISO 8601, up to 90 days ahead) replaces the Unix `send_at`, which SendGrid caps at 72 hours. Sends also accept an `Idempotency-Key`, which SendGrid does not offer.

```ts
// Before: SendGrid
const sgMail = require("@sendgrid/mail");
sgMail.setApiKey(process.env.SENDGRID_API_KEY);
await sgMail.send({
  to: "user@example.com",
  from: "hi@acme.com",
  subject: "Your receipt",
  html: receiptHtml,
});

// After: Unitpost
import { Unitpost } from "unitpost";
const unitpost = new Unitpost(process.env.UNITPOST_API_KEY);
const { data, error } = await unitpost.email.send({
  to: "user@example.com",
  from: "Acme <hi@acme.com>",
  subject: "Your receipt",
  html: receiptHtml,
});
```

### 6. Replace the webhook verifier

SendGrid's Signed Event Webhook is checked against a public key; Unitpost signs each delivery with a per-endpoint `whsec_` secret in the svix format, one event per request with a `type` field. Rotate the secret in [Webhooks](/dashboard/webhooks) to see it, deploy the new verifier, then set `status` to `enabled`. A switched-off endpoint does not queue events.

### 7. Send tests

Send each template once with `POST /api/v1/email/templates/{id}/test-send`, and fire `POST /api/v1/webhooks/{id}/test` at the endpoint.

### 8. Move traffic in stages

Move one message type first and watch `email.bounced` and `email.complained` for it, then the rest. SendGrid stays verified, so rolling back is a config change.

### 9. Cancel SendGrid last

Re-run step 2 on cut-over day, finish the checks below, then downgrade or close the account.

## Same ideas, new names

| SendGrid | Unitpost | Note |
| --- | --- | --- |
| Marketing list | Segment (static) |  |
| Segment | Segment (dynamic) | Rebuilt by hand. |
| Unsubscribe group | Topic | Imported with `default_opt_in: true`. |
| Global unsubscribe | Unsubscribed contact | Stops marketing; transactional still sends. |
| Dynamic template | Template |  |
| Single Send | Campaign |  |
| Custom field | Contact field |  |
| Domain authentication | Domain |  |
| Subuser | Separate workspace |  |
| Category | Tag (`tags: [{ name, value }]`) |  |

## API calls

| SendGrid API | Unitpost API | Note |
| --- | --- | --- |
| POST /v3/mail/send | POST /api/v1/email |  |
| `personalizations` (up to 1,000 per call) | POST /api/v1/email/batch | Up to 100 messages per call, each personalized. |
| `template_id` + `dynamic_template_data` | `template: { id, variables }` |  |
| `send_at` (Unix time, up to 72 hours ahead) | `scheduled_at` (ISO 8601 with a zone, up to 90 days ahead) |  |
| PUT /v3/marketing/contacts | POST /api/v1/contacts/imports | Up to 30,000 contacts there, 5,000 rows here. |
| GET /v3/suppression/bounces | GET /api/v1/suppressions?reason=bounce |  |
| GET /v3/suppression/spam_reports | GET /api/v1/suppressions?reason=complaint |  |
| POST /v3/asm/groups/{group_id}/suppressions | POST /api/v1/email/topics/{id}/opt-out |  |
| GET /v3/templates/{template_id} | GET /api/v1/email/templates/{id} |  |
| GET /v3/user/webhooks/event/settings/all | GET /api/v1/webhooks |  |

## SMTP settings

| SendGrid SMTP | Unitpost SMTP | Note |
| --- | --- | --- |
| smtp.sendgrid.net | smtp.unitpost.com |  |
| Username `apikey` | Username `unitpost` |  |
| Password: API key with Mail permission | Password: Unitpost API key with `emails:send` |  |
| 25, 587, 2525 (TLS optional) | 587, 2587, 25 (STARTTLS) | TLS 1.2 or later is required before login. |
| 465 (SSL) | 465, 2465 (implicit TLS) |  |

## Webhook events

| SendGrid event | Unitpost event | Note |
| --- | --- | --- |
| processed | email.sent |  |
| delivered | email.delivered |  |
| deferred | email.delivery_delayed |  |
| bounce | email.bounced |  |
| dropped | email.failed | A send our suppression list stops is `email.suppressed`. |
| spamreport | email.complained |  |
| open | email.opened |  |
| click | email.clicked |  |
| unsubscribe | contact.updated |  |
| group_unsubscribe | contact.updated | The importer maps it here; `topic.unsubscribed` is the closer event. |
| group_resubscribe | contact.updated | `topic.subscribed` is the closer event. |

## Before you cancel SendGrid

- Export contacts and suppression lists as files before you downgrade or close: when SendGrid retired its free plans, accounts left on them with more than 100 contacts had their contacts deleted.
- Download the Email Activity you need; SendGrid keeps up to 30 days of it only with its Email Activity history add-on, and none of it moves.
- Re-run the group unsubscribe copy (step 2) so late opt-outs reach Unitpost.
- Delete SendGrid's `em` CNAME and `s1._domainkey` and `s2._domainkey` CNAMEs once no mail goes through SendGrid. Keep `_dmarc` and Unitpost's records.
- Delete SendGrid API keys and switch off its Event Webhooks.

Facts about SendGrid checked 2026-11-02.

## FAQ

### Does SendGrid still have a free plan?

No. SendGrid began retiring its free Email API and Marketing Campaigns plans on May 28, 2025, with a 60-day transition before sending paused on free accounts. Unitpost's free plan is 5,000 emails a month, up to 200 a day, with no card.

### Do my unsubscribe groups and their opt-outs carry over?

The groups do, as topics. The opt-outs do not, so run the script in step 2 before you send a campaign scoped to one of those topics.

### Will my dynamic template Handlebars still render?

Plain `{{name}}` placeholders do. Helpers like `{{#if}}` and `{{#each}}` are left as text, because Unitpost templates have no loops or conditionals.

### Can SendGrid keep sending while my domain verifies on Unitpost?

Yes. Unitpost's records sit on `unitpost.<your domain>` and a new DKIM selector, beside SendGrid's CNAMEs, so the domain is verified on both until you remove SendGrid's records.

### What does SendGrid still do that Unitpost does not?

Subuser management for multi-tenant sending, dedicated IP pools, a long record at very high volume, and a much larger integration catalogue. If one of those is a requirement, stay on SendGrid for it.

## Related

- [Migrate from Postmark to Unitpost](https://www.unitpost.com/migration/postmark): Leave Postmark in order: import every server's suppressions, templates and webhooks, keep broadcast unsubscribes off receipts, re-verify DNS, swap the client.
- [Migrate from Mailgun to Unitpost](https://www.unitpost.com/migration/mailgun): Leave Mailgun in order: import lists and per-domain suppressions, check EU domains, re-verify DNS, rebuild routes as receiving, move to JSON and Bearer auth.
- [Migrate from Brevo to Unitpost](https://www.unitpost.com/migration/brevo): Leave Brevo in order: import contacts, lists, blocked contacts and templates, rewrite params variables, re-verify DNS, re-record SMS consent, swap the call.
