ZeptoMail Migration in 7 Steps: Port Zoho Sending, No Downtime
A step-by-step ZeptoMail migration: DKIM selector cutover, dual-send verification window, suppression list export, and webhook swap — without dropping a single password reset.
A step-by-step ZeptoMail migration: DKIM selector cutover, dual-send verification window, suppression list export, and webhook swap — without dropping a single password reset.
The thing that finally pushed me toward a ZeptoMail migration wasn't price. It was standing in a hotel bathroom at 6 a.m., squinting at a phone, clicking through three separate ZeptoMail Mail Agents trying to work out which one had stopped sending password resets — because each agent has its own token, its own stats page, and its own opinion about what "delivered" means.
I got there eventually. Took eleven minutes — eleven minutes of being the on-call support engineer for my own email setup, in a bathroom, because I built it that way.
Look, ZeptoMail is a good product. Zoho built it transactional-only so marketing sends couldn't poison transactional reputation — right call, and the deliverability is fine. Honestly? If price is your only reason to move, don't. Prepaid credits are cheap and you'll talk yourself out of it.
The reason to move is consolidation. If you're already running JustEmails for domain mailboxes at $49/year and paying Zoho separately for transactional, you're maintaining two DNS configurations and two auth models for what is functionally one thing.
This is the cutover runbook. Nothing here is clever — it's boring on purpose, because the interesting part of an email migration is the part where you lose a receipt.
Transactional sending fully on the JustEmails API, your domain authenticated with a JustEmails DKIM selector, your suppression list ported into your own database, webhook events flowing to your existing handler, and ZeptoMail's Mail Agents idle but not yet deleted.
Hands-on time is maybe three hours. Wall-clock is a week, because most of it is waiting.
That last one matters for tier sizing. The base plan includes 1,000 transactional emails per month; each additional 10,000/month tier is $25/year and they stack. I skipped this once, sized off a "feels like about 3K a month" guess, and got a very educational 429 mid-Tuesday.
Size against the daily cap as well, because it's the one that bites during a cutover. Sends are capped per account per day — 20/day for the first week after the domain verifies, 200/day in the second, 500/day from day 15 — and API sends share that number with anything your mailboxes send. The warm-up clock runs from domain verification rather than signup, so an established account adding a fresh domain starts that domain at the bottom of the curve. Buying transactional tiers raises the monthly quota only; it does not move the daily ceiling. Distinct recipients are separately capped at 100 an hour, dropping to 15 an hour until the domain is verified and a payment has landed. Plan the dual-send window around those numbers or you'll spend it debugging a limit instead of a migration.
ZeptoMail's unit of organization is the Mail Agent. Each one has its own send token, its own bounce settings, and — this is the part that bites — its own suppression list.
For each agent, write down:
.com, .eu, .in, .com.au, and .jp, and the API host differsGrep your codebase for the auth header rather than trusting the dashboard:
grep -rn "Zoho-enczapikey\|zeptomail" --include="*.js" --include="*.ts" --include="*.py" --include="*.env*" .
I've found forgotten senders this way three times out of three. There's always one — a cron job on a box nobody logs into, mailing a nightly digest to twelve people who'd complain within a day if it stopped.
Last time it was mine. Wrote it, deployed it, forgot it, then spent a genuinely embarrassing stretch of the cutover arguing with the dashboard about a Mail Agent I'd created myself and could not remember creating.
This is the part everyone gets wrong, so slow down here.
DKIM uses selectors precisely so a domain can carry several signing keys at once. ZeptoMail signs with its own (zmail._domainkey by default, though you may have set a custom one — check the Domains page in the console). JustEmails uses its own. Both live in DNS simultaneously and neither cares about the other.
Add the JustEmails records without removing anything:
je1._domainkey.yourdomain.com CNAME je1.dkim.justemails.app
je2._domainkey.yourdomain.com CNAME je2.dkim.justemails.app
Then widen SPF to cover both senders during the overlap:
v=spf1 include:zeptomail.zoho.com a:mail1.justemails.app ~all
Zoho gives you an include:; ours is an a: mechanism pointing straight at the host we send from. There is no JustEmails include to add — the names people reach for carry no TXT record, so a record built that way authorises nothing while looking entirely correct in a DNS panel.
Watch your lookup count either way. Ten DNS lookups is the hard RFC 7208 limit, and a: counts against it exactly as include: does — if you're already carrying a marketing tool plus a CRM plus Zoho, one more mechanism can tip you into permerror and break authentication for everything at once.
And nothing warns you. No red banner, no log line — you find out because a customer mentions in passing that your receipts have been going to spam for a week. That one still annoys me.
DMARC doesn't change. This is not the week to move from p=none to p=quarantine.
If your transactional mail sends from a subdomain like mail.yourdomain.com (it should — see setting up a dedicated sending subdomain), all of this applies to the subdomain's records, not the apex.
Skip this and your first JustEmails send goes to a few hundred addresses that hard-bounced months ago. That's how you burn a fresh reputation in one afternoon. Ask me how I know.
Per Mail Agent: Settings → Suppression List → Export. Merge the CSVs, lowercase, dedupe, then load the result into your own table:
CREATE TABLE email_suppressions (
email TEXT PRIMARY KEY,
reason TEXT NOT NULL, -- hard_bounce | complaint | manual
source TEXT NOT NULL, -- 'zeptomail_import' | 'justemails_webhook'
suppressed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
Then check it in your send path — before the API call, not after.
Keeping suppression in your own database is the highest-value step in this runbook. Do it once and every future provider change is a config swap. (I'm aware this sounds like busywork when the provider already has the feature. It isn't. The feature is the lock-in.)
The two APIs are close enough that this is mostly mechanical. ZeptoMail:
await fetch('https://api.zeptomail.com/v1.1/email', {
method: 'POST',
headers: {
'Authorization': `Zoho-enczapikey ${process.env.ZEPTO_TOKEN}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
from: { address: 'noreply@yourdomain.com', name: 'YourApp' },
to: [{ email_address: { address: user.email, name: user.name } }],
subject: 'Reset your password',
htmlbody: html
})
});
JustEmails:
await fetch('https://justemails.app/api/v1/send', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.JUSTEMAILS_API_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': `pwreset:${user.id}:${resetTokenId}`
},
body: JSON.stringify({
from: { email: 'noreply@yourdomain.com', name: 'YourApp' },
to: [{ email: user.email }],
subject: 'Reset your password',
html,
text: plainTextFallback
})
});
Three payload differences worth flagging. Auth is Bearer, not Zoho's Zoho-enczapikey. Recipients use email rather than the nested email_address.address. And the HTML field is html, not htmlbody — exactly the kind of one-word difference that produces a 400 you'll stare at for ten minutes.
The host catches people out before any of that. ZeptoMail lives on api.zeptomail.com, so the instinct is to reach for a matching api. subdomain on our side. There isn't one — the JustEmails API is served from the main hostname under /api/v1, and anything addressed to api.justemails.app dies at DNS resolution, well before it produces an HTTP status you can debug.
Use the idempotency key. It's the difference between a timeout meaning "maybe sent" and "definitely sent once." Build it from something stable — a reset token ID, an invoice number — not a timestamp. And here's a take I'll defend: a transactional provider that doesn't offer idempotency keys is shipping an unfinished product, and that alone is enough to cross it off the shortlist.
Send a text part too. A missing plain-text alternative is a small spam-score penalty at basically every major mailbox provider, and it costs you nothing.
Deploy a wrapper that tries JustEmails and falls back to ZeptoMail. Then leave it alone and watch.
export async function sendTransactional({ to, subject, html, text, idemKey }) {
if (await isSuppressed(to)) return { skipped: 'suppressed' };
try {
const res = await sendViaJustEmails({ to, subject, html, text, idemKey });
if (res.ok) return { provider: 'justemails' };
throw new Error(`justemails ${res.status}`);
} catch (err) {
metrics.increment('email.fallback', { reason: err.message });
logger.warn({ err, to }, 'JustEmails failed, falling back to ZeptoMail');
}
const res = await sendViaZeptoMail({ to, subject, html });
if (!res.ok) throw new Error(`both providers failed: ${res.status}`);
return { provider: 'zeptomail', fallback: true };
}
The email.fallback counter is the whole point. Zero for 72 hours and you're done. If it isn't zero, the counter tells you why.
Seventy-two hours is a floor, not a target. Run it across a weekday cycle plus a weekend — your monthly invoice job and your Sunday-night digest are exactly the senders you forgot in step 1.
While the window is open, check DKIM on real received mail: send yourself a message, open the raw headers, confirm dkim=pass with the je1 selector. If it's failing, our body-hash mismatch guide covers the usual cause — something mutating the message after signing.
ZeptoMail posts bounce, open, and click events to your configured URL. JustEmails posts delivery notifications, different payload shape. Don't stand up a second endpoint for the overlap — take both at the one you already have and branch on structure, because the version of you debugging a missing bounce at 3 a.m. will not remember there were ever two URLs:
export async function POST(request) {
const payload = await request.json();
if (payload.event_type) return handleJustEmails(payload); // JustEmails
if (payload.event_name) return handleZepto(payload); // ZeptoMail
return new Response('unknown payload', { status: 400 });
}
Both paths write to the same email_suppressions table on a hard bounce or complaint. That's the point of owning the table. For what to do with these events — and what a complaint rate over 0.3% means for your reputation — see handling bounce and complaint webhooks.
One caveat, since I'd rather you hear it here: JustEmails doesn't do open and click tracking the way ZeptoMail does. My take is that transactional open tracking is mostly theater — Apple's Mail Privacy Protection pre-fetches images and inflates the number into meaninglessness anyway. But if a stakeholder reads that dashboard weekly, find out before cutover, not after.
Once you've had zero fallbacks across a full weekday cycle:
v=spf1 a:mail1.justemails.app ~all, plus the includes for any other legitimate senders.401 Unauthorized. You kept ZeptoMail's header format. It's Authorization: Bearer je_xxx, not Zoho-enczapikey. Then check the env var for a trailing newline — it's always a trailing newline.
400 Bad Request with no obvious cause. It's htmlbody. Or the nested email_address object. Diff the payload field by field and it falls out in about thirty seconds.
429 Too Many Requests. You've hit a ceiling — either the monthly quota your tier buys, or, more often mid-migration, the daily cap or the hourly distinct-recipient limit, neither of which a bigger tier raises. Respect the retry_after header instead of hammering it — retrying straight into a limit is how a two-minute blip turns into an hour.
DKIM passes in testing, fails in production. Usually a tracking-link rewriter modifying the body after signing. Sometimes a mailing list re-wrapping the message, which no signer survives.
Bounces spike right after cutover. You skipped step 3. Import the suppression list, purge the bounced addresses, and slow down for a few days.
Delivering but landing in spam. Check whether you're sending from a bare no-reply@ address — there are better patterns, and reply-ability is a real reputation signal.
Watch Google Postmaster Tools for two weeks. Reputation shifts are gradual and you want to see the downward trend before your users do.
Two weeks. Not two days.
Still weighing HTTP API against plain SMTP relay? We broke down the tradeoffs in SMTP vs API for transactional email. Short version: API for per-message IDs and webhooks, SMTP for legacy things that only speak port 587.
And if the mailbox side of your Zoho footprint is next, that's a separate project with its own IMAP-sync gotchas — the Zoho Mail migration guide covers it.
Not if you run both providers in parallel. Add the JustEmails DNS records alongside ZeptoMail's, then deploy a wrapper that tries JustEmails first and falls back to your existing Mail Agent on any non-2xx. Watch for at least 72 hours. Only remove the fallback after a full weekday cycle — including the nightly batch job you forgot about — goes through with zero fallbacks.
No, and you shouldn't. DKIM is verified by the receiving server at delivery time, not at send time. Anything ZeptoMail queued before cutover can land hours later, and if the selector is already gone the signature fails and your DMARC alignment takes the hit. Leave it in DNS for 48 hours after your last ZeptoMail send. Multiple selectors coexisting is normal — that's the whole point of selectors.
Yes, and it's the step people skip. Export the suppression list from each ZeptoMail Mail Agent, merge the CSVs, dedupe on lowercased address, and load the result into your own application-side table. Check that table before you call any send API. Owning suppression in your database instead of a provider dashboard means the next migration costs you nothing.
Different shapes. ZeptoMail sells prepaid credit packs; JustEmails bundles 1,000 transactional emails per month into the $49/year plan that also covers unlimited mailboxes and domains, with stackable 10,000/month tiers at $25/year each. Sending millions a month? Price out both honestly. Sending tens of thousands and already wanting the mailbox hosting? The consolidation math usually wins.
Unlimited custom domain email hosting for $49/year flat — unlimited domains, unlimited mailboxes, 10 GB storage, full IMAP/SMTP. Built for agencies, freelancers, and anyone managing email across more than one domain.