---
title: "Migrate from Resend to Unitpost"
description: "Leave Resend in order: copy its suppression list, import contacts, topics and templates, re-verify DNS, swap the SDK and re-key svix webhooks."
url: https://www.unitpost.com/migration/resend
section: Migration
updated: 2026-11-02
---
# Migrate from Resend to Unitpost

To leave Resend, paste a full-access Resend key into Unitpost's Migration Assistant. It imports contacts with their unsubscribe flag, audiences as static segments, topics, templates as drafts, domains with new DKIM keys and webhooks switched off. It does not copy Resend's suppression list or per-contact topic choices, so you bring those over through Resend's API first. Send history, analytics, automations and API keys stay behind.

**Complex migration.** Resend's suppression list (bounces, complaints and manual blocks) is the one core list the importer does not read, so you copy it through Resend's API before the first send.

## What comes with you

| Data | Result | What happens |
| --- | --- | --- |
| Unsubscribes and consent | Imports | A contact Resend marks `unsubscribed` imports unsubscribed. Here that stops campaigns and other marketing sends; transactional sends still reach the address. |
| Segments | Imports | Each audience becomes a static segment with its members. The importer reads Resend's Audiences API, which Resend deprecated when audiences became segments, so compare the preview count with your Segments page. |
| Contacts | Partly imports | Every contact from Resend's `GET /contacts` imports with first and last name. Custom contact properties are not read, and contacts already in your workspace are skipped unless you choose Update in the preview. |
| Topics | Partly imports | Topics are recreated with their default. Per-contact choices are not read, so a contact who opted out of an `opt_in` topic in Resend counts as subscribed to it here until step 3. |
| Templates | Partly imports | Subject, HTML and text import as draft code-mode templates in the marketing category. Resend's `{{{VARIABLE}}}` placeholders are not converted, and the preview does not flag them for Resend. |
| Sending domains | Partly imports | Each domain is recreated as verifying with a new DKIM key and new DNS records. Resend's open and click tracking settings carry over to the new domain. |
| Webhooks | Partly imports | Endpoints are recreated switched off with a new signing secret. Events both products share keep their names; `contact.topics.updated`, `suppression.*`, `topic.*` and `inbox.*` are dropped and listed in the preview. |
| Suppressions (bounces and complaints) | Rebuild | Not imported. Resend now lists its suppressions at `GET /suppressions`, but the importer does not call it, so step 1 copies them. |
| API keys | Rebuild | Keys are stored hashed on both sides and cannot be read. Create new keys in Unitpost. |
| Scheduled broadcasts | Rebuild | Broadcasts are not read. Recreate upcoming ones as campaigns on a segment. |
| Receiving | Rebuild | Inbound settings are not read. Turn receiving on for the verified domain here and point its MX record at Unitpost. |
| Send history and analytics | Stays behind | Sent emails, delivery events, logs and broadcast stats stay in Resend. |
| Automations | Stays behind | Resend automations do not import, and Unitpost automations are not launched yet. |

## Before you start

- A Resend API key with `full_access`. A `sending_access` key can only send, so the importer cannot read your account with it.
- 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 `suppressions:write`, `templates:write`, `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, to add the new DKIM, MX and SPF records.
- Node.js 18 or later if you run the scripts below; they use the built-in `fetch`.

## The cut-over plan

### 1. Copy Resend's suppression list

Do this before anything sends from Unitpost. Page through Resend's `GET /suppressions` and add the addresses with `POST /api/v1/suppressions`, up to 1,000 per call. Bounce and complaint rows then block every send here, and running this before the import makes those contacts arrive unsubscribed.

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

const unitpost = new Unitpost(process.env.UNITPOST_MIGRATION_KEY);
const resendHeaders = { Authorization: "Bearer " + process.env.RESEND_API_KEY };

// 1. Read Resend's suppression list (100 per page, cursor in "after").
const emails = [];
let after = null;
do {
  const url = "https://api.resend.com/suppressions?limit=100" + (after ? "&after=" + after : "");
  const page = await fetch(url, { headers: resendHeaders }).then((r) => r.json());
  for (const s of page.data) emails.push(s.email);
  after = page.has_more ? page.data[page.data.length - 1].id : null;
} while (after);

// 2. Add them to Unitpost, up to 1,000 per call. reason "import" also makes
//    the Migration Assistant import those contacts unsubscribed.
for (let i = 0; i < emails.length; i += 1000) {
  const { error } = await unitpost.suppressions.create({
    emails: emails.slice(i, i + 1000),
    reason: "import",
  });
  if (error) throw new Error(error.message);
}
```

> Run it again on cut-over day to catch addresses Resend suppressed in the meantime.

### 2. Run the Migration Assistant

Paste the key in [Settings → Migration](/dashboard/settings/migration). The preview shows counts, samples and conflicts with data you already have; nothing is written until you accept, and you can exclude single items first.

### 3. Copy topic opt-outs

Resend returns a contact's topic choices only one contact at a time (`GET /contacts/{id}/topics`), and the importer does not call it. For each `opt_in` topic, collect who opted out and opt them out here with `POST /api/v1/email/topics/{id}/opt-out` (`emails`, up to 1,000 per call).

### 4. Verify your domains next to Resend

Open [Domains](/dashboard/email/domains) and publish the records shown: MX and SPF on `unitpost.<your domain>` and a DKIM TXT under `_domainkey`. Resend's return path lives on the `send` subdomain, so nothing collides and both stay verified. Keep the one `_dmarc` record you have, then run `POST /api/v1/email/domains/{id}/verify`.

### 5. Fix variables and publish templates

Rewrite `{{{PRICE}}}` as `{{PRICE}}` (names match exactly), and the reserved `{{{FIRST_NAME}}}`, `{{{LAST_NAME}}}`, `{{{EMAIL}}}` and `{{{UNSUBSCRIBE_URL}}}` as `{{first_name}}`, `{{last_name}}`, `{{email}}` and `{{unsubscribe_url}}`. Imports land in the marketing category, which adds an unsubscribe footer; set receipts and resets to `transactional` and publish with `PATCH /api/v1/email/templates/{id}`.

> `POST /api/v1/email/templates/{id}/render` lists `missing_variables` without sending anything.

### 6. Switch the SDK

The body keeps Resend's field names. What changes is the package, the key, and the path: `POST /api/v1/email` (singular) instead of `/emails`. `Idempotency-Key` works the same way, 24 hours and up to 256 characters.

```ts
// Before: Resend
import { Resend } from "resend";
const resend = new Resend(process.env.RESEND_API_KEY);
const { data, error } = await resend.emails.send({
  from: "Acme <hi@acme.com>",
  to: ["user@example.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({
  from: "Acme <hi@acme.com>",
  to: ["user@example.com"],
  subject: "Your receipt",
  html: receiptHtml,
});
```

### 7. Re-key and enable webhooks

Both products sign with the svix headers (`svix-id`, `svix-timestamp`, `svix-signature`), so your verifier stays. The importer's secret is never shown: rotate it in [Webhooks](/dashboard/webhooks) or with `POST /api/v1/webhooks/{id}/rotate-secret`, deploy it, then set `status` to `enabled`. A switched-off endpoint does not queue events.

### 8. Send tests

Send one of each template with `POST /api/v1/email/templates/{id}/test-send`, and fire `POST /api/v1/webhooks/{id}/test` at each endpoint to prove the new secret verifies.

### 9. Move traffic in stages

Route one message type through Unitpost first and watch `email.bounced` and `email.complained` for it, then move the rest. Both providers stay verified on the domain, so going back is a config change.

### 10. Cancel Resend last

Close the account only after its scheduled emails have gone out, its webhooks have gone quiet, and the checks below are done.

## Same ideas, new names

| Resend | Unitpost | Note |
| --- | --- | --- |
| Audience (now Segment) | Segment | Imported as static; build filter-based ones as dynamic segments. |
| Topic | Topic | `opt_in` becomes `default_opt_in: true`, `opt_out` becomes `false`. |
| Broadcast | Campaign |  |
| Contact property | Contact field (`custom_fields`) |  |
| Team | Workspace |  |
| `full_access` / `sending_access` key | API key with scopes | A send-only key needs `emails:send`. |
| Suppression origin `bounce`, `complaint`, `manual` | Suppression reason `bounce`, `complaint`, `manual` | Copied rows use `import`. |

## API calls

| Resend API | Unitpost API | Note |
| --- | --- | --- |
| POST /emails | POST /api/v1/email | Same body fields, singular path. |
| POST /emails/batch | POST /api/v1/email/batch | Up to 100 emails on both. |
| PATCH /emails/{id}, POST /emails/{id}/cancel | PATCH /api/v1/email/{id} | One call reschedules or cancels. |
| POST /contacts, PATCH /contacts/{id or email} | POST /api/v1/contacts, PATCH /api/v1/contacts/{id or email} |  |
| PATCH /contacts/{id}/topics | PATCH /api/v1/contacts/{id}/topics |  |
| GET /segments | GET /api/v1/segments |  |
| GET /suppressions | GET /api/v1/suppressions |  |
| POST /suppressions/batch/add | POST /api/v1/suppressions with `emails` | 100 per call there, 1,000 here. |
| GET /templates/{id} | GET /api/v1/email/templates/{id} |  |
| POST /webhooks | POST /api/v1/webhooks |  |

## SMTP settings

| Resend SMTP | Unitpost SMTP | Note |
| --- | --- | --- |
| smtp.resend.com | smtp.unitpost.com |  |
| Username `resend` | Username `unitpost` |  |
| Password: Resend API key | Password: Unitpost API key | The key needs `emails:send`. |
| 25, 587, 2587 (STARTTLS) | 587, 2587, 25 (STARTTLS) | TLS 1.2 or later before login. |
| 465, 2465 (implicit TLS) | 465, 2465 (implicit TLS) |  |

## Webhook events

| Resend event | Unitpost event | Note |
| --- | --- | --- |
| email.sent, email.delivered, email.delivery_delayed | Same names |  |
| email.bounced, email.complained, email.failed | Same names |  |
| email.opened, email.clicked | Same names |  |
| email.scheduled, email.suppressed | Same names |  |
| email.received | email.received | Fires once receiving is on for the domain. |
| domain.created, domain.updated, domain.deleted | Same names |  |
| contact.created, contact.updated, contact.deleted | Same names |  |
| contact.topics.updated | topic.subscribed, topic.unsubscribed | Not carried over; subscribe by hand. |
| suppression.added, suppression.removed | No equivalent |  |
| topic.created, topic.updated, topic.deleted | No equivalent |  |
| inbox.* | No equivalent |  |

## Before you cancel Resend

- Re-run the suppression copy from step 1 so addresses Resend suppressed during the overlap are blocked here too.
- Let scheduled emails and broadcasts at Resend finish or cancel them; scheduled sends do not move.
- Export the logs and broadcast stats you want to keep; Resend's pricing page lists 30-day data retention, and none of it moves.
- Delete Resend's DKIM record and the MX and SPF records on its `send` subdomain once no mail goes through Resend. Keep `_dmarc` and Unitpost's records.
- Revoke Resend API keys and delete its webhook endpoints so nothing keeps sending or posting.

Facts about Resend checked 2026-11-02.

## FAQ

### Does the importer copy my Resend suppression list?

No. Resend lists suppressions at `GET /suppressions`, but the importer does not call it yet. Unsubscribed contacts carry over; bounces, complaints and manual blocks need the script in step 1.

### Will my Resend webhook verification code keep working?

Yes, once it has the new secret. Both sign with the svix headers and a `whsec_` secret, and both payloads carry `type`, `created_at` and `data`; Unitpost adds `source` and `event_id`.

### Do my Resend topic subscriptions carry over?

The topics do, the choices do not. Contacts who opted out of an `opt_in` topic count as subscribed here until you opt them out (step 3), so do it before the first campaign scoped to that topic.

### Resend's free plan is 3,000 emails a month. What is Unitpost's?

5,000 emails a month, up to 200 a day, with no card. Resend's free plan is 3,000 a month and 100 a day.

### I render React Email in my app. What changes?

Not the rendering. Pass the HTML you already render to `unitpost.email.send` as `html`; only the client, the key and the verified domain change.

## Related

- [Migrate from SendGrid to Unitpost](https://www.unitpost.com/migration/sendgrid): Leave SendGrid in order: import contacts and suppressions, copy unsubscribe-group opt-outs, re-verify DNS, port dynamic templates, swap the webhook verifier.
- [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.
