Migrate from Resend
Copy contacts, topics, segments, suppressions and templates into one sesameSend team.
scripts/import.mts copies a Resend account into one sesameSend team: contact properties, topics, segments, contacts (with unsubscribe state), segment membership, per-topic subscriptions, suppressions and templates. It keeps Resend's IDs where it can, so a rerun is safe and Resend IDs stored in your own database still resolve.
Dry run first
A dry run reads and counts. It writes nothing.
RESEND_API_KEY=re_... pnpm import:resend --team <team id>Find the team id in the teams table, or in the output of bootstrap.mjs. Then run the import:
RESEND_API_KEY=re_... pnpm import:resend --team <team id> --write --with-topics --with-propertiesSafety
- Resend API keys cannot be read-only, so the importer's client only sends
GETrequests. Nothing in your Resend account changes. - Nobody is resubscribed. An unsubscribe, topic opt-out or suppression already in sesameSend always wins over the source.
- Writes skip the API layer, so an import fires no webhooks, events or automations.
- The report ends with
verify: unsubscribed contacts and suppressions are counted back from sesameSend and compared with the source.
Other sources and options
The same script reads two more sources. Both are dry runs until you add --write.
# A CSV of contacts
pnpm exec tsx --env-file=.env scripts/import.mts --from csv --file contacts.csv --team <team id>
# An SES account (read-only calls)
pnpm exec tsx --env-file=.env scripts/import.mts --from ses --region us-east-1 --team <team id>| Option | Does |
|---|---|
--only contacts,suppressions,... | Import only some kinds |
--optouts optouts.txt | One address per line, applied on top of any source: imported as unsubscribed, never resubscribed |
--report report.md | Write the reconciliation table as markdown (it is always printed to stderr) |
--allow-invalid-optouts | Allow --write when some --optouts lines have no email address. Without it the import aborts, because those opt-outs would be lost. |
--assume-subscribed | Allow --write when some contacts have no unsubscribed value. Without it the import aborts. |
--rps 2 | Request rate against the source |
--json report.json | Machine-readable report |
Duplicate emails in the source are merged into one contact, and if any copy is unsubscribed, the contact is unsubscribed. An unsubscribe already in sesameSend still wins over the source.
Time
--with-topics and --with-properties each cost one request per contact. At Resend's default 2 requests per second, 10,000 contacts take about 85 minutes per flag.
What is not imported
- Emails, broadcasts, automations, webhooks and API keys. Recreate them.
- Domains. They are listed, not created. Add each one under Domains and publish its new DKIM records. Keep the old records until you switch. See Domains and DKIM.
Cutover checklist
-
Import with a dry run, then with
--write. -
Add and verify your domains in sesameSend.
-
Point each app at sesameSend and swap in an
os_key, one app at a time. The base URL setting differs by SDK:SDK Base URL setting Node RESEND_BASE_URLPython resend.api_url, orRESEND_API_URLset before import.RESEND_BASE_URLhas no effect, so a Python app that only sets it keeps sending to Resend.Go client.BaseURL(trailing slash), orRESEND_BASE_URLPlain HTTP Change the host See Quickstart. After the cutover, check that your Resend dashboard shows no new sends from that app.
-
Recreate webhooks, and point your receivers at the new signing secret. See Webhooks.
-
Remove the old DNS records after traffic has moved.