Migrate from Resend
One command moves your Resend account; two environment lines move your code — the wire format is identical.
MillionSend's REST API is wire-compatible with Resend's: same endpoints, same request and response shapes. Migrating is a configuration change, not a rewrite. The account data — contacts, segments, topics, templates, webhooks, domains, suppressions — moves with one command.
Hand it to an agent
The whole migration — inventory, account move, code changes, DNS, cutover — is written up as one prompt an agent can follow end to end, including the guardrails (Resend read-only, keys never in files, ask before applying).
Or point the agent at it: https://docs.millionsend.com/prompts/migrate-from-resend.md.
1. Move your account
Create an ms_ API key with full access under API keys (MillionSend
Cloud, or your own self-hosted instance), then run, on your
machine:
npx @millionsend/cli migrate --from resendIt asks for your Resend key (full access) and your MillionSend key, reads your Resend account, shows a plan, waits for confirmation, applies it and prints a summary. Self-hosted, name your instance's API URL:
npx @millionsend/cli migrate --from resend --to-url https://api.your-instanceWhat it does:
- Resend is only read. Every request against Resend is a
GETto a documented endpoint; nothing there is created, changed or deleted. The CLI prints the rate limit Resend reports on connect and paces itself under it: 8 requests per second by default (Resend's team limit is 10, shared with your production sending), backing off on theratelimit-*headers and on every429.--rpschanges the rate. - Your keys never leave your machine. The tool talks to
api.resend.comand to your MillionSend API only. Keys live in memory for the run, are never written to a file, and are redacted from every log line. No telemetry, no update check. - Cutover first, enrichment after. Pass 1 creates properties, topics,
segments, domains, webhooks, templates, broadcasts and suppressions and
upserts contacts with their segment memberships and
unsubscribedflags. It finishes in minutes, and the CLI then prints Cutover ready with the DNS records and theRESEND_BASE_URLline: transactional sending can move at that point. Enrichment — only when the account uses topics or contact properties — then reads each contact once per facet, topic subscriptions first (so opt-outs land before properties), then properties, with a live rate and time left. Hold topic sends and broadcasts until it finishes. Both passes resume where they stopped after Ctrl-C and a re-run, and--skip enrichmentleaves them out. - Re-run before cutover. Every run is a diff: existing rows are updated
when they differ and left alone when they match, contacts are upserted by
email. Run the same command again right before you switch traffic and the
contacts that arrived in between come across. A re-run reads every contact
again, so it costs the full enrichment time;
--only enrichmentre-runs just the two passes against contacts already on the target, and--only properties,enrichmentruns the properties pass alone. Accounts migrated with CLI 0.1.x received no property values (the wire shape was misread); that one command fills them in. A contact that appeared on Resend since the last run is created by that pass with itsunsubscribedflag and names, and is recorded for rollback like any other. The cutover-ready checklist prints only when contacts, domains and suppressions are all part of the run.
Before a large migration
Enrichment dominates: two GETs per contact against a limit shared with the
sending your app does on the same Resend team. The estimate the plan prints
follows from contacts × facets ÷ rate:
| Contacts | Facets | At 8 req/s | At 10 req/s | At 50 req/s |
|---|---|---|---|---|
| 36,685 | topics + properties | about 2 h 30 min | about 2 h | about 25 min |
| 160,000 | topics + properties | about 11 h | about 9 h | about 1 h 50 min |
| 160,000 | topics only | about 5 h 30 min | about 4 h 30 min | about 55 min |
Three things shorten it:
- Ask Resend for a temporary raise. Resend's documentation says the team
limit "can be increased for trusted senders by request" (Settings → Usage
shows the current one). Pass the granted rate explicitly —
--rps 50— the CLI accepts values above 10 and warns when the rate exceeds the limit it detected. - Run off-peak. The limit is per team, so the enrichment competes with
your production sends;
429s land on both. A second API key does not help. - Leave headroom. When the CLI detects a limit above 10 and
--rpswas not given, it uses the limit minus 2 so your app keeps sending.
| Resource | What moves |
|---|---|
| Contacts | Email, names, unsubscribed, properties, topic subscriptions, segment memberships. Opt-outs are preserved; nobody is re-subscribed. |
| Segments, topics, properties | Matched by name / name / key: created when missing, updated when different. |
| Templates | Name, alias, subject, html, text. from, reply_to and variables cannot be stored — listed as manual steps. |
| Webhooks | Endpoint and events. The signing secret is copied, so the receiver you already run keeps verifying (deliveries carry the svix-* headers). --fresh-webhook-secrets mints new ones. Events MillionSend also emits carry over — all eleven email.* types, contact.created, contact.updated and contact.deleted, suppression.added and suppression.removed, and the twelve inbox.* types MillionSend sends; the rest (contact.topics.updated, domain.*, topic.*, inbox.thread.assigned, inbox.thread.unassigned) are dropped per webhook and listed. email.scheduled, email.failed and email.suppressed carry over only to an instance that emits them (its GET /openapi.json lists them). An older one refuses those webhooks with a 422: migrate to it with npx @millionsend/cli@0.2.3, which drops the three along with suppression.added and suppression.removed and lists all five; add those two back to the webhook afterwards, in the dashboard or with PATCH /webhooks/{id} sending its full events list (PATCH replaces the list). |
| Suppressions | Bounces, complaints and manual entries, with their origin. |
| Domains | Created with return path and tracking settings, in the one SES region your MillionSend instance serves (MillionSend Cloud: sa-east-1) — the Resend region does not carry over. On MillionSend Cloud, tracking toggles only carry over together with a tracking subdomain; without one the report lists them for you to set up in the dashboard. DNS records must be added again — see step 3. |
| Broadcasts | Drafts and scheduled ones import as drafts. Sent ones are skipped unless --include-sent. |
What cannot move: API keys (Resend exposes their names only — the report lists them as a to-do), DKIM/DNS records (keys are per provider), inboxes with their threads and drafts (create them again; see step 3), and email history, sent or received. Audiences, deprecated in Resend, are skipped — segments cover them.
Flags, environment variables, files, exit codes and CI usage are on the CLI reference.
2. Point your existing code at MillionSend
The official Resend SDKs honor RESEND_BASE_URL, so the migration is two
environment lines — no code changes:
RESEND_API_KEY=ms_...
RESEND_BASE_URL=https://api.millionsend.comSelf-hosted, use your instance's API origin instead.
Five details that differ from what a Resend integration may assume:
-
Sender and recipient fields take exactly one mailbox each, in the RFC 5322 shapes
ada@example.com,Ada <ada@example.com>or"Ada, Inc." <ada@example.com>. A display name that contains a comma must be quoted; unquoted, it reads as two addresses and the send is rejected with422. Resend accepts the unquoted form. -
PATCH /contacts/{id}/topicstakes a bare JSON array of{ "id": "<topic-id>", "subscription": "opt_in" | "opt_out" }entries, not an object wrapping it;GET /contacts/{id}/topicsreads them back with the effective choice per topic. -
There is no
POST /contacts/imports(CSV). Bulk contacts go throughPOST /contacts/batch?on_conflict=upsertas JSON, up to 1,000 per call, withx-batch-validation: permissiveto keep the valid rows when some fail. -
A recipient whose domain can never receive mail, such as
x@example.com,x@foo.testorx@gmail.con, is refused with422 validation_error, and so is a new contact at one: the migration lists such contacts as failed instead of moving them. See Errors. -
A send whose every
torecipient is suppressed is refused with422 all_recipients_suppressed; Resend accepts it, records the email assuppressedand firesemail.suppressed. Here,POST /emailskeeps a metadata-onlysuppressedemail and firesemail.suppressedtoo, but answers 422 instead of 200. The migration copies Resend's bounces, complaints and manual entries, so your code may already be mailing addresses on the list: treat this error as "not sent" rather than as a failure worth retrying. InPOST /emails/batchone such item rejects the whole batch; withx-batch-validation: permissivethe other items are accepted and that one is listed inerrors. See Suppressions.
Prefer first-party clients? The MillionSend SDKs cover nine languages with the same surface:
import { MillionSend } from "millionsend";
const ms = new MillionSend("ms_...", { baseUrl: "https://api.millionsend.com" });3. Finish what the CLI lists
The summary ends with a checklist. Three items are always on it:
- Add DNS records for each domain. MillionSend uses its own DKIM keypair, so the records are new even for a domain that already sends through Resend. The report prints a copy-ready table of the records per domain (also under Domains in the dashboard). Both providers can stay verified side by side while you migrate.
- Set
RESEND_BASE_URL(step 2) in every environment that sends. - Create API keys — one per name the report lists (for example
prod,staging) under API keys.
Two more appear when they apply: template from / reply_to values to set
per send, and webhook event types MillionSend does not emit. Broadcast bodies
need no change: {{{RESEND_UNSUBSCRIBE_URL}}} is a supported alias of
{{{UNSUBSCRIBE_URL}}}.
Receiving mail through Resend too? Turn receiving on for the domain here once
it is verified (Enable receiving on its page), then choose which addresses
take mail. Resend receives at every address of the domain; here that is
Catch-all, on the same page (a paid plan on MillionSend Cloud), or else
an inbox per address. Then change the domain's MX
record from the one Resend gave you to mx.millionsend-dns.com
(self-hosted: your instance's RECEIVING_MX_HOST). Mail goes to the MX
record with the lowest priority number, so that change is the cutover; what
Resend already received stays there. Code calling
resend.emails.receiving.* and handlers for email.received need no
change. See Receiving.
Using Resend's Inboxes preview? Point resend@6.32.1-preview-inboxes.6 at
MillionSend like any other Resend SDK, and create each inbox again with the
same address (POST /inboxes, or Inboxes → Create inbox); threads and
drafts stay at Resend. An inbox created through a Resend SDK is an agent's
inbox, with MillionSend's protections on: mail from senders whose domain
doesn't vouch for them, for one, waits in quarantine. Create inboxes read by
people as kind: "human", in the dashboard or over plain HTTP. forwarding: true gives a receiving_address on the
instance's shared inbox domain, so it needs one. See
Inboxes → Resend compatibility.
Send one email through the new base URL and watch it move to Delivered on the Emails page — that is the whole migration.
Resend is a registered trademark of Plus Five Five, Inc. MillionSend is not affiliated with or endorsed by Resend.