MillionSend Docs

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 resend

It 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-instance

What it does:

  • Resend is only read. Every request against Resend is a GET to 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 the ratelimit-* headers and on every 429. --rps changes the rate.
  • Your keys never leave your machine. The tool talks to api.resend.com and 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 unsubscribed flags. It finishes in minutes, and the CLI then prints Cutover ready with the DNS records and the RESEND_BASE_URL line: 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 enrichment leaves 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 enrichment re-runs just the two passes against contacts already on the target, and --only properties,enrichment runs 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 its unsubscribed flag 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:

ContactsFacetsAt 8 req/sAt 10 req/sAt 50 req/s
36,685topics + propertiesabout 2 h 30 minabout 2 habout 25 min
160,000topics + propertiesabout 11 habout 9 habout 1 h 50 min
160,000topics onlyabout 5 h 30 minabout 4 h 30 minabout 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 --rps was not given, it uses the limit minus 2 so your app keeps sending.
ResourceWhat moves
ContactsEmail, names, unsubscribed, properties, topic subscriptions, segment memberships. Opt-outs are preserved; nobody is re-subscribed.
Segments, topics, propertiesMatched by name / name / key: created when missing, updated when different.
TemplatesName, alias, subject, html, text. from, reply_to and variables cannot be stored — listed as manual steps.
WebhooksEndpoint 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).
SuppressionsBounces, complaints and manual entries, with their origin.
DomainsCreated 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.
BroadcastsDrafts 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.com

Self-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 with 422. Resend accepts the unquoted form.

  • PATCH /contacts/{id}/topics takes a bare JSON array of { "id": "<topic-id>", "subscription": "opt_in" | "opt_out" } entries, not an object wrapping it; GET /contacts/{id}/topics reads them back with the effective choice per topic.

  • There is no POST /contacts/imports (CSV). Bulk contacts go through POST /contacts/batch?on_conflict=upsert as JSON, up to 1,000 per call, with x-batch-validation: permissive to keep the valid rows when some fail.

  • A recipient whose domain can never receive mail, such as x@example.com, x@foo.test or x@gmail.con, is refused with 422 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 to recipient is suppressed is refused with 422 all_recipients_suppressed; Resend accepts it, records the email as suppressed and fires email.suppressed. Here, POST /emails keeps a metadata-only suppressed email and fires email.suppressed too, 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. In POST /emails/batch one such item rejects the whole batch; with x-batch-validation: permissive the other items are accepted and that one is listed in errors. 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.

On this page