JustEmails
PricingSign inStart free trialStart free
Tutorials··14 min read

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.

By JustEmails Platform Team
Contents
  1. What You'll Have When You're Done
  2. Prerequisites
  3. Step 1: Inventory Every Mail Agent
  4. Step 2: Add JustEmails DNS Alongside ZeptoMail's
  5. Step 3: Port the Suppression List Before You Send Anything
  6. Step 4: Rewrite the Send Call
  7. Step 5: The Dual-Send Window
  8. Step 6: Move the Webhooks
  9. Step 7: Cut Over, Then Wait Before Deleting
  10. Common ZeptoMail Migration Errors and How to Fix Them
  11. Next Steps
  12. Frequently Asked Questions
  13. Will my emails stop sending during a ZeptoMail migration?
  14. Do I have to remove ZeptoMail's DKIM record right away?
  15. Can I move my ZeptoMail suppression list to JustEmails?
  16. How much does the JustEmails transactional API cost compared to ZeptoMail credits?
  17. Try JustEmails

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.

What You'll Have When You're Done

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.

Prerequisites

  • A JustEmails account with the sending domain added, on a paid plan. If you're already hosting mailboxes there, the domain is added — but note the 7-day trial does not include API access, so the key this migration needs can only be created on a paid plan.
  • Access to your DNS provider — Cloudflare, Route 53, Namecheap, whatever you use.
  • Admin on the Zoho account that owns the ZeptoMail Mail Agents. Not just an operator seat — you'll need to export suppression data.
  • Somewhere to see errors when the cutover goes sideways at 2 a.m. We run JustAnalytics for error tracking, and having send failures land on the same timeline as deploys turns a two-hour outage into a five-minute one.
  • Your current sending volume. Pull the last 90 days per Mail Agent before you touch anything.

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.

Step 1: Inventory Every Mail Agent

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:

  • What sends through it (password resets? invoices? the thing the intern built in 2024?)
  • The From address and reply-to
  • Its webhook URLs and suppression list size
  • The DC region — Zoho splits across .com, .eu, .in, .com.au, and .jp, and the API host differs

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

Step 2: Add JustEmails DNS Alongside ZeptoMail's

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.

Step 3: Port the Suppression List Before You Send Anything

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

Step 4: Rewrite the Send Call

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.

Step 5: The Dual-Send Window

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.

Step 6: Move the Webhooks

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.

Step 7: Cut Over, Then Wait Before Deleting

Once you've had zero fallbacks across a full weekday cycle:

  1. Remove the ZeptoMail fallback branch. One provider in the send path now.
  2. Trim SPF to v=spf1 a:mail1.justemails.app ~all, plus the includes for any other legitimate senders.
  3. Leave ZeptoMail's DKIM selector in DNS for 48 hours. Anything still in their queue delivers with a valid signature. Deleting early is the classic self-inflicted DMARC failure.
  4. Pause the Mail Agents rather than deleting them. Give it 30 days.
  5. Update the runbook that says "check the ZeptoMail dashboard." Someone will follow it mid-incident at the worst possible moment.

Common ZeptoMail Migration Errors and How to Fix Them

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.

Next Steps

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.

Frequently Asked Questions

Will my emails stop sending during a ZeptoMail migration?

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.

Do I have to remove ZeptoMail's DKIM record right away?

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.

Can I move my ZeptoMail suppression list to JustEmails?

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.

How much does the JustEmails transactional API cost compared to ZeptoMail credits?

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.


Try JustEmails

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.

Start your 7-day free trial → · How it compares

zeptomail-migrationtransactional-email-apidkim-selector-cutoversuppression-listzoho-alternativesbuildinpublicsaasstudioaiworkforcebuildwithclaude

Related posts

Guides
Custom Domain Email: What It Costs and How to Actually Set One Up
13 min read
Guides
Email Domain Price: What a Custom Domain Email Address Really Costs
13 min read
Tutorials
Listmonk SMTP Setup (and Mautic's Mailer DSN) with JustEmails
18 min read