MepMail Docs

Migrate from Resend

One command moves your Resend account; two environment lines move your code — the wire format is identical.

MepMail'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://github.com/JE4NVRG/mepmail/blob/main/apps/docs/content/prompts/migrate-from-resend.md.

1. Move your account

Create an ms_ API key with full access under API keys (MepMail 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 MepMail 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 MepMail 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 MepMail 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.
SuppressionsBounces, complaints and manual entries, with their origin.
DomainsCreated with return path and tracking settings, in the one SES region your MepMail instance serves (MepMail Cloud: sa-east-1) — the Resend region does not carry over. On MepMail 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), 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 MepMail

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-mepmail.je4ndev.com

Self-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 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.

There is no client of ours to install: the official Resend SDKs are the client, pointed at your instance. The two environment lines above cover the Node SDK unchanged; to set the base URL in code:

import { Resend } from "resend";

const resend = new Resend("ms_...", { baseUrl: "https://api-mepmail.je4ndev.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. MepMail 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 MepMail 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. MepMail is not affiliated with or endorsed by Resend.

On this page