Migrate from Resend to Unitpost
Complex migrationResend'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
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.
| 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. Asending_accesskey can only send, so the importer cannot read your account with it. - A Unitpost API key with
emails:sendfor your app, created in API keys. - A second Unitpost key for the migration steps, with
suppressions:write,templates:write,topics:writeandwebhooks:manage, saved asUNITPOST_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
Resend keeps sending until the last step, so there is no gap in delivery.
Copy Resend's suppression list
Do this before anything sends from Unitpost. Page through Resend's
GET /suppressionsand add the addresses withPOST /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.copy-suppressions.mjsimport { 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.
Run the Migration Assistant
Paste the key in 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.
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 eachopt_intopic, collect who opted out and opt them out here withPOST /api/v1/email/topics/{id}/opt-out(emails, up to 1,000 per call).Verify your domains next to Resend
Open 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 thesendsubdomain, so nothing collides and both stay verified. Keep the one_dmarcrecord you have, then runPOST /api/v1/email/domains/{id}/verify.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 totransactionaland publish withPATCH /api/v1/email/templates/{id}.POST /api/v1/email/templates/{id}/renderlistsmissing_variableswithout sending anything.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-Keyworks the same way, 24 hours and up to 256 characters.send.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, });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 or withPOST /api/v1/webhooks/{id}/rotate-secret, deploy it, then setstatustoenabled. A switched-off endpoint does not queue events.Send tests
Send one of each template with
POST /api/v1/email/templates/{id}/test-send, and firePOST /api/v1/webhooks/{id}/testat each endpoint to prove the new secret verifies.Move traffic in stages
Route one message type through Unitpost first and watch
email.bouncedandemail.complainedfor it, then move the rest. Both providers stay verified on the domain, so going back is a config change.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.
Name changes
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
sendsubdomain once no mail goes through Resend. Keep_dmarcand 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. Providers change their APIs and plans; check their docs before you rely on a detail.
Questions about leaving Resend
Does the importer copy my Resend suppression list?
Will my Resend webhook verification code keep working?
Do my Resend topic subscriptions carry over?
Resend's free plan is 3,000 emails a month. What is Unitpost's?
I render React Email in my app. What changes?
Leaving Resend?
Preview the migration on the free tier
Verify a domain, paste a read-only key, and see what would come across before anything is written.