01The part that really is one line
The request and response shapes of /v1/emails are Resend's, exactly. Not inspired by, not mostly compatible — the same fields in the same places, so the resend SDK you already have installed works against your instance with a base URL and a key.
Sending is the compatible surface: change the base URL and the key and stop. Contacts, audiences and broadcasts have their own endpoints here and are not part of the one line.
import { Resend } from 'resend' // before const resend = new Resend(process.env.RESEND_API_KEY) // after — same import, same calls, same response handling const resend = new Resend(process.env.MAILYSEND_API_KEY, { baseUrl: 'https://your-instance.example.com/v1', }) await resend.emails.send({ from: '[email protected]', to: '[email protected]', subject: 'Your receipt', html: body, }) // → { id: "…" }, exactly as before
Everything added on top is additive and ignored by their SDK, which is what makes the compatibility survive contact with reality rather than only with a hello-world. None of it is required to migrate.
| Beyond the shared surface | What it gives you |
|---|---|
| suppressed | An array on the send response naming the recipients that were dropped for being on your suppression list — the difference between “we sent it” and “we sent it to the three of five recipients who were not suppressed”. |
| POST /v1/emails/batch | Up to a hundred distinct messages per call, with per-item results: one malformed item does not reject the other ninety-nine, and the response says which index failed. |
| Idempotency-Key | A header accepted on send, so a retried request is not a second message. |
| scheduled_at | A send scheduled for later, with PATCH to reschedule it and DELETE to cancel it while it is still queued. |
Get the identical calls working first and adopt the extras later, so that if something goes wrong during the cutover you know it is not your new code.
02Moving a sending domain
The rule for the whole DNS step is one sentence: publish the new records alongside the old ones and verify them before you move any traffic. Do it in that order and there is never a moment where neither system can authenticate your mail.
DKIM selectors and bounce subdomains coexist; SPF and DMARC do not, and those two are the ones people get wrong.
- Two DKIM selectors can live side by side indefinitely. That is the entire reason selectors exist, and a message signed by either one verifies against the key it names.
- Two bounce subdomains are simply two different names — Resend's documented setup authenticates through a send. subdomain and signs under its own selector; your new transport uses a different one.
- SPF. A domain may publish exactly one record; two is a permanent error and receivers treat it as no SPF at all.
- DMARC. Also exactly one, and the policy in it should not move during a migration.
“Adding” a record for the new sender silently disables the old one. Put both includes in the single record for the duration of the overlap, and remove the old include only after the old system has stopped sending.
; during the overlap — one record, both includes
@ TXT "v=spf1 include:_spf.mx.cloudflare.net include:amazonses.com ~all"Whatever policy you are on today, keep it through the migration: a cutover is the worst possible moment to also tighten p=none to p=reject, because you would then have two changes and one symptom. Do the move first, confirm alignment in the aggregate reports, tighten afterwards.
$ dig +short TXT yourdomain.com$ dig +short TXT ms1._domainkey.yourdomain.com$ dig +short TXT _dmarc.yourdomain.com
From a resolver, not from your DNS provider's own interface, which will happily show you a record it has not published yet. If dig disagrees with the panel, believe dig. The authentication guide covers what each record is proving and why alignment is the part that bites.
03Contacts, suppressions and history
Three kinds of data, and they are not equally important. One of them must be moved before your first send; one is worth moving carefully; one is usually not worth moving at all.
| Data | Move it? | Why |
|---|---|---|
| Suppression list | Before the first send | The only step in this guide with no undo. |
| Contacts | Yes, fussily | Address, name fields, unsubscribe flag, and the custom fields your templates actually reference. |
| History — opens and clicks | No | It will not line up with the new event stream, and engagement columns rebuild themselves within a send cycle or two. |
Export the list from your current provider and import it in bulk. Up to a thousand addresses per call, and re-running an import is safe: an address that is already suppressed is upserted rather than rejected, so a partial import can simply be run again rather than diffed. Carry the reason across rather than flattening everything to a generic entry — it costs nothing at import time and it is the difference between later being able to say “these two thousand are unsubscribes, those four hundred are dead mailboxes” and having one undifferentiated blocklist you can never safely prune.
POST /v1/suppressions/bulk { "emails": [ { "email": "[email protected]", "reason": "hard_bounce" }, { "email": "[email protected]", "reason": "unsubscribe" } ] }
Contacts, second. Import into an audience, check the count against your source before you trust it, and — this is the one worth being fussy about — confirm that the unsubscribe flag survived. A contact import that silently drops unsubscribed produces exactly the same catastrophe as skipping the suppression list, with the added indignity of having done the work.
History, mostly not. A segment defined over imported counts is a segment computed from numbers whose provenance you have already forgotten. Keep your old dashboard read-only for as long as your provider allows and let the new numbers accumulate honestly. The exception is genuine legal retention — export the raw records to your own storage and treat that as an archive rather than trying to make it queryable in the new system.
04Rewiring webhooks
This is the one place where “compatible” stops. The event names are close and the signature scheme is not the same, so your receiver needs a real change — and the way to make that change safely is to run both verifiers at once.
Branch on which headers arrived, verify with the matching verifier, and reject anything that has neither. After the cutover you delete a branch.
- Three headers: svix-id, svix-timestamp, svix-signature
- The signature is base64-encoded
- One header: MailySend-Signature: t=…,v1=…
- HMAC-SHA256 over t + "." + body, hex-encoded
The two differ in every dimension — header names, signed string, encoding of the result. There is no shim that makes one verify as the other, and you should be glad: a compatibility layer over a signature check is a place for a bypass to hide.
if (req.get('mailysend-signature')) { if (!verifyMailySend(req)) return res.sendStatus(400) } else if (req.get('svix-signature')) { if (!verifySvix(req)) return res.sendStatus(400) } else { return res.sendStatus(400) // unsigned is not a third case }
| Carries over from the old integration | Why re-check it now |
|---|---|
| Verify against the raw request body | True of both schemes, so a handler that got it right for Svix is already capturing the bytes before any JSON parsing. Confirm it rather than assume it. |
| Keep the handler idempotent | Delivery here is at-least-once and every event carries a stable id in MailySend-Event-Id. During a percentage cutover two systems briefly report on overlapping traffic, which is exactly when a non-idempotent handler starts double-counting. |
The retry behaviour is different too, and generally in your favour: six queue attempts, then a long tail at three, six, twelve and twenty-four hours, then the endpoint is disabled after twenty consecutive failures with replay available once you have fixed it. The webhooks guide has verifier code in six languages and the full ladder, which is the page to have open while you write this branch.
05A cutover you can undo
Move a percentage, not a system. The point of a percentage cutover is not caution for its own sake — it is that a problem shows up in a slice of your traffic instead of all of it, and that going back is a configuration change rather than a project.
- Of the recipient address, or the user id — something durable.
- Each recipient’s experience stays consistent, and the comparison between the two paths means something.
- A given customer receives one message through each system.
- A broken template becomes one broken email each to a lot of people, rather than a clear signal from a contained group.
A day. You are not measuring rates at this volume, you are confirming that mail arrives, renders, authenticates, and produces the webhook events your application expects. Send one to a personal address at each of the big receivers and read the headers.
Two or three days. Now you can compare bounce and complaint rates between the two paths on the same kind of traffic in the same period, which is the only comparison that means anything.
A few days at each. Leave the old path configured and credentialled for a fortnight after you reach a hundred per cent. Deleting the old integration is the last step of the migration, not part of it.
Watch the two paths side by side, not the new one in isolation. Your bounce rate on the new system is meaningless without knowing what it is on the old one this week — plenty of “migration problems” turn out to be a bad list segment that was misbehaving equally on both, and you only discover that if you were looking at both.
Rolling back is genuinely cheap here, and that is by construction: the DNS for both systems is still published and verified, the old credential still works, and the send call is identical, so reverting is one configuration value. That property is the reason the DNS section insisted on alongside rather than instead of — it is what turns a roll back from an incident into a shrug.
06Where the two genuinely differ
Plainly, then, including the parts that do not favour this project. A comparison a reader cannot check is not worth reading, and a comparison that never loses is a comparison nobody should believe.
The difference is ownership, not a feature gap. Both systems send email; what changes is who operates it, whose reputation the domain builds, and who can read the mail.
Where Resend is the better answer. It is a capable product with real people operating it, and if you do not want to run infrastructure, that is a legitimate choice rather than a failure of nerve. Concretely:
| If this is you | Then buy it rather than run it |
|---|---|
| You send a few thousand messages a month | Their free tier is cheaper than any deployment of anything. |
| Nobody on the team will own an email problem at an inconvenient hour | Buying that ownership is the correct engineering decision. |
| You are pre-product-market-fit | Every hour spent on DNS is an hour not spent on the thing customers pay for. The trade is obvious and it is not in this direction. |
| You need someone contractually accountable for delivery | That is a thing a vendor sells and a thing you cannot sell yourself. |
That framing matters because it tells you when to reconsider. If you migrate expecting a capability you did not have before, you will be disappointed — sending an email is a well-understood problem and both systems do it. If who holds the account, whose reputation the domain is building, who can change the pricing and who is able to read the contents of your mail is not currently a problem for you, this migration is work with no payoff, and you should not do it.
What just happened
The send call did not change, the DNS was published alongside the old records rather than instead of them, and the traffic moved a percentage at a time with a condition written down in advance for rolling it back. The one step with no undo is the suppression list: if it was not imported before the first send, you have already mailed every address that bounced or unsubscribed on the old system, and no amount of care afterwards takes that back.