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.

ResendUnitpost
Unsubscribes and consentImports
SegmentsImports
ContactsPartly imports
Suppressions (bounces and complaints)Rebuild

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.

DataResultWhat happens
Unsubscribes and consentImportsA contact Resend marks unsubscribed imports unsubscribed. Here that stops campaigns and other marketing sends; transactional sends still reach the address.
SegmentsImportsEach 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.
ContactsPartly importsEvery 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.
TopicsPartly importsTopics 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.
TemplatesPartly importsSubject, 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 domainsPartly importsEach 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.
WebhooksPartly importsEndpoints 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)RebuildNot imported. Resend now lists its suppressions at GET /suppressions, but the importer does not call it, so step 1 copies them.
API keysRebuildKeys are stored hashed on both sides and cannot be read. Create new keys in Unitpost.
Scheduled broadcastsRebuildBroadcasts are not read. Recreate upcoming ones as campaigns on a segment.
ReceivingRebuildInbound settings are not read. Turn receiving on for the verified domain here and point its MX record at Unitpost.
Send history and analyticsStays behindSent emails, delivery events, logs and broadcast stats stay in Resend.
AutomationsStays behindResend 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.
  • 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

Resend keeps sending until the last step, so there is no gap in delivery.

  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.

    copy-suppressions.mjs
    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. 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 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.

    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,
    });
  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 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.

Name changes

Same ideas, new names

ResendUnitpostNote
Audience (now Segment)SegmentImported as static; build filter-based ones as dynamic segments.
TopicTopicopt_in becomes default_opt_in: true, opt_out becomes false.
BroadcastCampaign
Contact propertyContact field (custom_fields)
TeamWorkspace
full_access / sending_access keyAPI key with scopesA send-only key needs emails:send.
Suppression origin bounce, complaint, manualSuppression reason bounce, complaint, manualCopied rows use import.

API calls

Resend APIUnitpost APINote
POST /emailsPOST /api/v1/emailSame body fields, singular path.
POST /emails/batchPOST /api/v1/email/batchUp to 100 emails on both.
PATCH /emails/{id}, POST /emails/{id}/cancelPATCH /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}/topicsPATCH /api/v1/contacts/{id}/topics
GET /segmentsGET /api/v1/segments
GET /suppressionsGET /api/v1/suppressions
POST /suppressions/batch/addPOST /api/v1/suppressions with emails100 per call there, 1,000 here.
GET /templates/{id}GET /api/v1/email/templates/{id}
POST /webhooksPOST /api/v1/webhooks

SMTP settings

Resend SMTPUnitpost SMTPNote
smtp.resend.comsmtp.unitpost.com
Username resendUsername unitpost
Password: Resend API keyPassword: Unitpost API keyThe 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 eventUnitpost eventNote
email.sent, email.delivered, email.delivery_delayedSame names
email.bounced, email.complained, email.failedSame names
email.opened, email.clickedSame names
email.scheduled, email.suppressedSame names
email.receivedemail.receivedFires once receiving is on for the domain.
domain.created, domain.updated, domain.deletedSame names
contact.created, contact.updated, contact.deletedSame names
contact.topics.updatedtopic.subscribed, topic.unsubscribedNot carried over; subscribe by hand.
suppression.added, suppression.removedNo equivalent
topic.created, topic.updated, topic.deletedNo 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. 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?

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.

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.