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 — the seven email.* types plus contact.created, contact.updated and contact.deleted; the rest (domain.*, email.suppressed) are dropped per webhook and listed. |
| 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), and sent email history. 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.
Three details that differ from what a Resend integration may assume:
-
Sender and recipient fields take exactly one mailbox each, in the RFC 5322 shapes
[email protected],Ada <[email protected]>or"Ada, Inc." <[email protected]>. 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.
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}}}.
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.