
If you're considering migrating from Mailchimp to Resend, you're in the right place.
Most migrations between email providers are about rewriting API calls. This one isn't. Mailchimp is a marketing platform built around an audience, so the part that decides whether your migration succeeds is which contacts you bring with you.
Mailchimp's export makes it easy to bring the wrong ones. It gives you a ZIP containing separate files for contacts who unsubscribed and for contacts whose addresses have gone bad, and importing those is the fastest way to damage a brand-new sending reputation.
This guide covers the export, exactly what to keep, how to shape the CSV for Resend, and the API differences you'll run into afterwards.
Jump ahead
Mailchimp and Resend both send email, but they were built for different people.
Key differences
Most of what you know from Mailchimp maps onto something in Resend. The vocabulary is the main thing that changes.
| Mailchimp | Resend |
|---|---|
| Audience | Audience |
| Tag / Segment | Segment |
| Campaign | Broadcast |
| Merge field | Contact property |
| Customer Journey | Automation |
| Mailchimp Transactional | POST /emails |
Domains
Mailchimp asks you to verify and authenticate a sending domain before you can send from it. In Resend, domains are verified on the Domains page.
Both services authenticate your mail, with one difference worth noting: Resend enforces SPF as part of domain verification.
| Name | Mailchimp | Resend |
|---|---|---|
| DKIM | DKIM enforced | DKIM enforced |
| SPF | SPF optional | SPF enforced |
| DMARC | DMARC recommended | DMARC recommended |
Activity
Mailchimp shows delivery activity per campaign, inside that campaign's report. Resend gives you every message, transactional and broadcast, on the Emails page.
Dashboard
Mailchimp reports on campaign performance under Reports. In Resend, statistics across all your sending are shown on the Metrics page.
In Mailchimp, click Audience, then All contacts. Pick your audience from the drop-down if you have more than one, then click Export audience and Export as CSV. Mailchimp emails you when the export is ready.
What arrives is not a single CSV. Mailchimp's documentation puts it plainly:
Your export may include a ZIP file that contains separate CSV files for each type of email contact (subscribed, unsubscribed, non-subscribed, and cleaned).
Mailchimp exports are only available for 30 days. If you're planning the migration in advance, export close to when you actually intend to import.
Those four files are the whole reason this guide exists. They are not four slices of one list. They are four different consent states. In Mailchimp's own words:
subscribed: "Someone who's opted in to receive your marketing content."unsubscribed: "Someone who previously received your marketing content but has opted out."non-subscribed: "Someone who's interacted with you but hasn't opted in to receive marketing content."cleaned: "Hard bounces and repeated soft bounces become cleaned contacts. A cleaned contact has a non-deliverable email address."Only subscribed is a list you're allowed to email, and even then not all of it.
Here's the whole decision in one table. If you read nothing else, read this.
| File | What to do | unsubscribed column |
|---|---|---|
| subscribed | Filter, then import | false |
| unsubscribed | Import to preserve the opt-out, or archive offline | true |
| non-subscribed | Import to preserve the opt-out, or archive offline | true |
| cleaned | Never import | - |
Never import cleaned contacts
A cleaned address is one Mailchimp already stopped sending to: "Cleaned addresses can't receive future emails you send to your audience." They hard bounced, or soft bounced repeatedly, and they are considered invalid. Importing them into Resend doesn't give them a second chance. It just moves a known-bad list onto a domain with no sending history, which is the worst possible moment to take a bounce spike.
Unsubscribed and non-subscribed: import as opt-outs, or leave them out
Neither group has permission to receive marketing from you. The only question is whether Resend should know they exist.
unsubscribed set to true. This is what we recommend. Their opt-out becomes a fact in your Resend audience, so if that address ever arrives again from a different source (a signup form, a CRM sync, a second CSV), it stays suppressed. Unsubscribed contacts are excluded from every broadcast.The trade-off is billing: unsubscribed contacts still count toward your contact quota in Resend. That's the same deal you already had in Mailchimp, where subscribed, unsubscribed, and non-subscribed contacts all count toward your contact limit. If you were paying for them before, importing them costs you nothing new.
Filtering the subscribed file
"Subscribed" in Mailchimp is a wide net. It includes people who opted in six years ago and haven't opened anything since, and people whose address has bounced before. Two columns in the export tell you which is which.
MEMBER_RATING is Mailchimp's 1–5 engagement score. The bottom of that scale is a warning:
OPTIN_TIME is the timestamp of the signup. It's populated for contacts who came through a Mailchimp signup form, and blank for everyone else.
So the rule is:
Keep rows where MEMBER_RATING >= 2 AND OPTIN_TIME is not blank
That drops 1-star contacts, the ones with a soft bounce or an unsubscribe-then-resubscribe already on record, and drops anyone whose opt-in Mailchimp can't evidence.
It deliberately keeps 2-star contacts. Two stars is Mailchimp's default for every new contact, not a bad grade, so filtering to 3+ would throw away every subscriber you acquired recently who simply hasn't clicked yet.
A blank OPTIN_TIME only means the contact didn't arrive through a Mailchimp signup form, most commonly because you imported them into Mailchimp from somewhere else. It isn't proof that consent is missing. If you hold consent records elsewhere for those addresses, they're safe to add back.
Resend's contact importer reads four columns, and it matches them exactly. Email Address is not email, and First Name is not first_name.
| Mailchimp column | Resend column | Required |
|---|---|---|
| Email Address | Yes | |
| First Name | first_name | No |
| Last Name | last_name | No |
| (you add this) | unsubscribed | No |
Any other column you leave in the file becomes a contact property, created automatically on import. Mailchimp's TAGS column is often worth keeping for that reason; LEID, EUID, GMTOFF and friends usually aren't.
Three things to get right before you upload:
Lowercase the email column. Resend deduplicates on the exact address, so Sam@Example.com and sam@example.com import as two separate contacts.
Use true or false in the unsubscribed column. The importer accepts true, false, 1 and 0, and nothing else. yes, no, and Mailchimp's own status words like cleaned are ignored, and an ignored value means a new contact defaults to subscribed, which is exactly the outcome you're trying to avoid.
Don't map a "subscribed" column onto unsubscribed. There's no inversion. A TRUE in a column named Subscribed mapped to unsubscribed marks that person as opted out. Build the column fresh instead: false for the subscribed file, true for the others.
The filter script
This reads a Mailchimp export and writes a Resend-ready CSV. Run it once per file you're keeping.
npm install csv-parse csv-stringify
// filter-mailchimp.mjsimport { createReadStream, createWriteStream } from 'node:fs';import { parse } from 'csv-parse';import { stringify } from 'csv-stringify';const [input, output, status = 'subscribed'] = process.argv.slice(2);// The subscribed export becomes subscribed contacts; every other export// is an opt-out and must be flagged as one.const unsubscribed = status === 'subscribed' ? 'false' : 'true';const rows = createReadStream(input).pipe(parse({ columns: true, bom: true, trim: true, skip_empty_lines: true }),);const out = stringify({header: true,columns: ['email', 'first_name', 'last_name', 'unsubscribed'],});out.pipe(createWriteStream(output));let kept = 0;let dropped = 0;for await (const row of rows) {// Resend dedupes on the exact address, so normalise before writing.const email = (row['Email Address'] ?? '').trim().toLowerCase();if (!email) {dropped++;continue;}// Only the subscribed file gets filtered. Opt-outs are imported whole,// because the point of importing them is that the list is complete.if (status === 'subscribed') {const rating = Number.parseInt(row.MEMBER_RATING, 10);const optinTime = (row.OPTIN_TIME ?? '').trim();if (!optinTime || !(rating >= 2)) {dropped++;continue;}}out.write({email,first_name: (row['First Name'] ?? '').trim(),last_name: (row['Last Name'] ?? '').trim(),unsubscribed,});kept++;}out.end();console.log(`${input}: kept ${kept}, dropped ${dropped}`);
Then run it against the files from your ZIP:
node filter-mailchimp.mjs \subscribed_members_export_a1b2c3.csv \resend-subscribed.csv \subscribednode filter-mailchimp.mjs \unsubscribed_members_export_a1b2c3.csv \resend-unsubscribed.csv \unsubscribed
Note that there's no third command. The cleaned export doesn't get one.
Or do it in a spreadsheet
If you'd rather not run anything, open the subscribed CSV and add a helper column:
=IF(AND(VALUE(D2)>=2, E2<>""), "keep", "drop")
Point D2 at MEMBER_RATING and E2 at OPTIN_TIME, fill it down, then filter to keep. Delete every column except the three you need, rename the headers to email, first_name and last_name, add an unsubscribed column set to false, and lowercase the addresses with =LOWER(TRIM(A2)).
From the dashboard
Go to Audiences, choose your audience, and click Add contacts → Import CSV. The Map properties step reads your headers and suggests matches, so a raw Mailchimp export works here even if you skipped the renaming. Just confirm that Email Address lands on email, and that nothing has been mapped onto unsubscribed by accident.
Import the subscribed file first, then the opt-outs. Existing contacts are updated rather than duplicated, and a re-import that leaves the unsubscribed cell empty won't resubscribe anyone who's already opted out.
From the API
POST /contacts/imports takes the file as multipart form data. Unlike the dashboard, it does no guessing. Pass a column_map if your headers aren't already email, first_name, last_name and unsubscribed.
curl -X POST 'https://api.resend.com/contacts/imports' \-H 'Authorization: Bearer re_xxxxxxxxx' \-F 'file=@subscribed_members_export_a1b2c3.csv' \-F 'column_map={"email":"Email Address","first_name":"First Name","last_name":"Last Name"}' \-F 'segments=[{"id":"78261eea-8f8b-4381-83c6-79fa7120f1cf"}]'
A bad mapping fails immediately with a 422 describing the offending column, so you find out before the file is queued. Otherwise you get an import id back and can poll it:
curl 'https://api.resend.com/contacts/imports/8f9e4b21-4a3c-4b7e-9c2d-1f6a5e8b3c40' \-H 'Authorization: Bearer re_xxxxxxxxx'
{"object": "contact_import","id": "8f9e4b21-4a3c-4b7e-9c2d-1f6a5e8b3c40","status": "completed","counts": {"total": 18420,"created": 18317,"updated": 0,"skipped": 61,"failed": 42}}
Those counts are worth reading rather than glancing at. skipped is duplicate addresses within the file. failed is rows dropped because the address was missing or malformed. A handful is normal in a list of any age, and the rest of the import still lands.
Your audience is clean, but your domain is new. Mailbox providers have no history for it, and the fastest way to create a bad one is to send your entire list on day one.
Start with your most engaged segment, then grow volume over the following weeks. The 4- and 5-star contacts you just imported are a ready-made warm-up cohort.

Domain and/or IP Warm-up Guide
Learn how to warm up a domain or IP to avoid deliverability issues.
resend.com/docs/knowledge-base/warming-up
From there, keeping the audience clean is ongoing work rather than a one-off. It's the same discipline that made this import worth doing.

Audience Hygiene
Strategies for maintaining good audience hygiene and maximizing email deliverability.
resend.com/docs/knowledge-base/audience-hygiene
Mailchimp maintains client libraries for four languages, split across two separate APIs: one for Marketing, one for Transactional. Resend has a single API and a wider set of official SDKs.
| Platform | Mailchimp | Resend |
|---|---|---|
| Node.js | mailchimp-marketing-node | resend-node |
| PHP | mailchimp-marketing-php | resend-php |
| Python | mailchimp-marketing-python | resend-python |
| Ruby | mailchimp-marketing-ruby | resend-ruby |
| Go | - | resend-go |
| Rust | - | resend-rust |
| Java | - | resend-java |
| .NET | - | resend-dotnet |
Mailchimp splits marketing and transactional email into two products. Campaigns go through the Marketing API; one-off application email goes through Mailchimp Transactional, the product formerly known as Mandrill, which is a paid add-on rather than part of your plan.
In Resend there's no such split. The same POST /emails endpoint sends a password reset and a receipt, and no contact record is required.
Mailchimp Transactional
const mailchimp = require('@mailchimp/mailchimp_transactional')('xxxxxxxxx');await mailchimp.messages.send({message: {from_email: 'onboarding@resend.dev',subject: 'hello world',html: '<p>it works!</p>',to: [{ email: 'delivered@resend.dev', type: 'to' }],},});
Resend
import { Resend } from 'resend';const resend = new Resend('re_xxxxxxxxx');await resend.emails.send({from: 'Acme <onboarding@resend.dev>',to: ['delivered@resend.dev'],subject: 'hello world',html: '<p>it works!</p>',});
To send to your whole audience the way a Mailchimp campaign would, use Broadcasts instead, from the dashboard or via POST /broadcasts.
Both platforms offer SMTP, but on Mailchimp's side it belongs to the Transactional add-on rather than the marketing product.
| Name | Mailchimp Transactional | Resend |
|---|---|---|
| Host | smtp.mandrillapp.com | smtp.resend.com |
| Ports | 25, 465, 587, 2525 | 25, 465, 587, 2465, 2587 |
| Username | Your Mailchimp account email | resend |
| Password | Any active API key | Your API key |

Send emails with SMTP
Use Resend's SMTP server to send emails from any language or framework.
resend.com/docs/send-with-smtp
Mailchimp has two unrelated webhook systems: audience webhooks on the Marketing API, which fire when someone subscribes or unsubscribes, and message webhooks on Mailchimp Transactional. Resend delivers both kinds of event through a single webhook endpoint.
| Event | Mailchimp Transactional | Resend |
|---|---|---|
| Send | send | email.sent |
| Delivery | delivered | email.delivered |
| Delivery Delayed | deferral | email.delivery_delayed |
| Bounces | hard_bounce, soft_bounce | email.bounced |
| Complaints | spam | email.complained |
| Open Tracking | open | email.opened |
| Click Tracking | click | email.clicked |
| Rejected | reject | email.failed |
| Unsubscribe | unsub | contact.updated |
| Inbound | Inbound routing | Inbound |
Mailchimp and Resend have broadly similar security postures. Mailchimp, as part of Intuit, additionally holds ISO 27001.
| Name | Mailchimp | Resend |
|---|---|---|
| Authentication | Email/Password, Google | Email/Password, Google, GitHub |
| Multi-Factor Auth | 2FA available | MFA available |
| GDPR | GDPR compliant | GDPR compliant |
| SOC 2 | SOC 2 compliant | SOC 2 compliant |
| ISO 27001 | ISO 27001 certified | - |
Resend supports idempotency keys on the POST /emails and POST /emails/batch endpoints.
Mailchimp Transactional does not offer an idempotency header or key on its send endpoints.
An idempotent operation is an action you can perform more than once, with the same input, and it always produces the same outcome and avoids repeating side effects.
By adding an Idempotency-Key header, or using the equivalent field on our SDKs, you can tell Resend that this specific email should only be sent once, even if we get more than one request from you about it.
await resend.emails.send({from: 'Acme <onboarding@resend.dev>',to: ['delivered@resend.dev'],subject: 'hello world',html: '<p>it works!</p>',},{idempotencyKey: 'welcome-user/123456789',},);

Idempotency Keys
Learn how to send an idempotent email using Resend.
resend.com/docs/dashboard/emails/idempotency-keys
Deliverability Insights
Particularly useful right after a migration, when you're watching a new domain closely. Resend gives you detailed recommendations on each email sent, so a placement problem surfaces before it becomes a reputation problem.

Deliverability Insights
Improve email deliverability by identifying issues and applying best practices.
resend.com/blog/deliverability-insights
React Email
Mailchimp campaigns live in Mailchimp's template editor. Resend templates are components in your codebase, versioned alongside the rest of your application.

React Email
Build and send emails using React components.
resend.com/docs/send-with-nextjs
Multi-Region
Improve your email deliverability speed by using a region nearest to your users.

Faster Email Delivery with Multi-Region
Faster deliverability with reduced latency.
resend.com/blog/multi-region
Mailchimp bills on contacts. Resend gives you the option to pay based on contacts, based on emails, or a combination.
Key differences
| Contacts | Resend |
|---|---|
| 1,000 | $0 |
| 5,000 | $40 |
| 10,000 | $80 |
| 25,000 | $180 |
| 50,000 | $250 |
| 100,000 | $450 |
| 150,000 | $650 |
| 200,000+ | Custom |
Contacts are only required when using Resend's Broadcasts and Automations. If you plan to send only transactional emails, see our email-based pricing. Mailchimp's rates vary by plan and commitment term, so check their pricing page for a current comparison.
Ready to migrate to Resend? Get started. If there's anything else we can help with, contact our team, and we'll answer any questions you have.