Your email API returned 200. Here's why the email still never arrived
A user signs up. Your code calls the email API. You get 200 OK and an id back. The user says the verification email never came. You check the logs: success. You check your code: it's fine. You send yourself a test: it a
A user signs up. Your code calls the email API. You get 200 OK and an id back. The user says the verification email never came.
You check the logs: success. You check your code: it's fine. You send yourself a test: it arrives. Your user still has nothing.
This is one of the most common email bugs, and it hides in the gap between three different things that all get called "sent":
-
Accepted. The email API took your request. That's what the
200means. - Delivered. The recipient's mail server accepted the message. Your provider learns this later, asynchronously.
- Inboxed. The message landed in the inbox, not spam, and not a tab nobody opens. No sender can see this. Not you, not your provider.
A 200 tells you about step 1 only. This guide walks the path from there to the inbox, in the order I'd debug it, with the commands to run at each step.
Step 1: Find out what actually happened to that email
Before touching DNS or rewriting copy, get the facts on that one message. Every serious email API records what happens after it accepts a request, and reading that is step one.
With Cresca's API (the one I'm building, so it's the one I'll use for examples), the email object carries a last_event field:
curl -s https://api.cresca.xyz/api/v1/emails/4ef9a417-02e9-4d39-ad75-9611e0fcc33c \
-H "Authorization: Bearer $CRESCA_API_KEY" | jq '{to, subject, last_event, created_at}'
{
"to": ["[email protected]"],
"subject": "Verify your email",
"last_event": "bounced",
"created_at": "2026-11-04T09:14:03.120Z"
}
Each status points to a different problem, so this is where the debugging forks:
| Status | What it means | Where to look next |
|---|---|---|
queued, sending
|
Still in the pipeline. Usually seconds. | Wait. If it's stuck for minutes, it's a provider issue. |
sent |
Handed to the mail infrastructure; no answer from the recipient's server yet. | Wait a few minutes, then step 4 (greylisting, delays). |
delivered |
The recipient's server accepted it. | It's in their account somewhere: spam folder, a tab or a filter. Steps 2โ3 and 6. |
delivery_delayed |
The receiving server said "try later" (full mailbox, rate limiting, greylisting). | Step 4: read the deferral reason. |
bounced |
The receiving server rejected it. | Step 4: the bounce code tells you exactly why. |
complained |
The recipient marked it as spam. | Step 6: why your email looked unwanted. |
suppressed |
Never sent, because the address is on the suppression list (it bounced or complained before). | Step 5. |
failed |
Couldn't be sent at all. | The dashboard shows the reason. Often an unverified domain or sender. |
Two of these surprise people.
delivered is not "in the inbox". It means the receiving server said 250 OK. Gmail accepts a lot of mail and then files it under Spam, and it's still delivered. If your status says delivered and the user can't find it, skip to steps 2, 3 and 6.
suppressed emails never left. If an address hard-bounced last month, or the person reported a previous email as spam, a good provider stops sending to it. That protects your reputation, but it means your 200 was followed by nothing at all. That's step 5.
If you poll instead of using webhooks, wait a few seconds after sending and check again. A tiny helper is enough:
// wait-for-status.js (Node 18+)
const API = "https://api.cresca.xyz/api/v1";
const FINAL = new Set(["delivered", "bounced", "complained", "suppressed", "failed", "canceled"]);
export async function waitForStatus(id, { timeoutMs = 120_000, everyMs = 5_000 } = {}) {
const deadline = Date.now() + timeoutMs;
let last;
while (Date.now() < deadline) {
const res = await fetch(`${API}/emails/${id}`, {
headers: { Authorization: `Bearer ${process.env.CRESCA_API_KEY}` },
});
if (!res.ok) throw new Error(`Status check failed: ${res.status}`);
last = (await res.json()).last_event;
if (FINAL.has(last)) return last;
await new Promise((r) => setTimeout(r, everyMs));
}
return last; // still queued/sent/delayed: worth a look
}
Use it in a smoke test after every deploy that touches email: send a verification email to a test inbox and fail the pipeline if the status isn't delivered within two minutes.
Step 2: Check that your domain is actually authenticated
If emails are bouncing with authentication errors, or landing in spam even for you, the cause is usually DNS. Three records decide whether a mailbox provider believes the email really came from you.
- SPF lists the servers allowed to send for a domain. It's checked against the envelope sender (the Return-Path / MAIL FROM), not the From address your user sees.
-
DKIM signs each message with a key whose public half you publish in DNS. The signature carries a domain (
d=), which is the domain vouching for the message. - DMARC ties them to the From address. It passes if SPF or DKIM passes and is aligned, meaning the domain they authenticated matches the domain in your From header.
That last word, aligned, is where most setups quietly fail. You can have a passing SPF check and a passing DKIM signature, and still fail DMARC, because both authenticated your provider's domain instead of yours.
Check all three with dig. Replace acme.com with your domain, and mail.acme.com with whatever subdomain you send from:
# DMARC: must exist on the From domain (or its parent)
dig +short TXT _dmarc.acme.com
# "v=DMARC1; p=none; rua=mailto:[email protected]"
# SPF: on the domain used as the envelope sender (often a MAIL FROM subdomain)
dig +short TXT bounce.mail.acme.com | grep spf
# "v=spf1 include:amazonses.com ~all"
# DKIM: the selector comes from your provider; it's in the DNS records they gave you
dig +short CNAME abc123._domainkey.mail.acme.com
dig +short TXT abc123._domainkey.mail.acme.com
The mistakes that come up most:
-
Two SPF records on one name. SPF allows exactly one
v=spf1record per name. Two of them is apermerror, which counts as a fail. Merge them into one record with multipleinclude:. -
Too many DNS lookups in SPF. SPF stops at 10 lookups. Every
include:can pull in more. Once you've added a CRM, a helpdesk and two email tools, you can be over the limit without knowing. Remove services you no longer send from. -
No DMARC record at all. Without one, Gmail and Yahoo treat bulk mail from you as unauthenticated (more on that in step 3). Start with
p=noneand arua=address so you get reports, then tighten later. -
DKIM published on the wrong name. If the provider asked for
abc123._domainkey.mail.acme.comand your DNS host appended your domain again, you've createdabc123._domainkey.mail.acme.com.acme.com. Run thedigand check. -
From address on a domain you never set up. Your app sends as
[email protected]while you authenticatedacme.com. Different domain, nothing aligns.
Read the verdict the receiver actually gave
DNS tells you what you published. The receiving server tells you what it concluded. In Gmail, open the email, click โฎ โ Show original, and you'll see a summary at the top:
SPF: PASS with IP 54.240.x.x
DKIM: 'PASS' with domain mail.acme.com
DMARC: 'PASS'
If you're scripting it, the same verdict is in the Authentication-Results header of the raw message:
Authentication-Results: mx.google.com;
dkim=pass [email protected] header.s=abc123;
spf=pass smtp.mailfrom=bounce.mail.acme.com;
dmarc=pass (p=NONE) header.from=mail.acme.com
What you want to see is dmarc=pass, and a DKIM header.i or SPF smtp.mailfrom whose domain matches header.from. If DMARC fails while SPF and DKIM both pass, it's alignment, every time.
Step 3: Know the rules Gmail, Yahoo and Outlook now enforce
Since February 2024, Gmail and Yahoo have enforced sender requirements, and Microsoft added similar rules for Outlook.com consumer mailboxes in 2025. The short version:
For everyone sending to these mailboxes:
- Authenticate with SPF or DKIM (do both).
- Have valid forward and reverse DNS on the sending IPs. Your provider handles this if you use one.
- Send over TLS.
- Keep the spam complaint rate low. Google's line is below 0.3%, and it recommends staying under 0.1%.
For bulk senders (Google's threshold is around 5,000 messages a day to Gmail addresses):
- SPF and DKIM and a DMARC record (at least
p=none). - DMARC alignment: the From domain must match the SPF or DKIM domain.
-
One-click unsubscribe on marketing and subscription email, using the
List-UnsubscribeandList-Unsubscribe-Postheaders (RFC 8058), and honoring unsubscribes within two days.
Those unsubscribe headers look like this on a marketing email:
List-Unsubscribe: <https://acme.com/unsubscribe?u=8f3c...>, <mailto:[email protected]?subject=unsubscribe>
List-Unsubscribe-Post: List-Unsubscribe=One-Click
Transactional email (receipts, password resets, verification codes) doesn't need an unsubscribe link, and you shouldn't add one. But it's checked against the same authentication rules, and it shares your domain's reputation, which is why the next point matters.
Send app email and marketing from different subdomains. If acme.com sends a newsletter that gets complaints, you don't want password resets from the same domain paying for it. A common split:
mail.acme.com โ transactional: resets, receipts, verification
news.acme.com โ marketing: newsletters, announcements
Mailbox providers track reputation per domain, so this keeps one stream's bad week from hurting the other.
Step 4: Read the bounce or deferral message
If the status is bounced or delivery_delayed, the receiving server told your provider why, as an SMTP reply. Look at the message in your provider's dashboard or event log. The codes are more useful than they look.
The first digit tells you the category:
-
5xx: permanent. Don't retry. Your provider should suppress the address. -
4xx: temporary. The provider retries on its own; you'll seedelivery_delayedmeanwhile.
The real detail is in the enhanced status code (5.1.1, 5.7.26 and so on) and the text after it. The ones that cover most cases:
| Reply | What it means | Fix |
|---|---|---|
550 5.1.1 The email account that you tried to reach does not exist |
Typo or deleted address. | Nothing to fix on your side. Validate addresses at signup (a confirm-your-email step catches typos like gmial.com). |
552 5.2.2 Mailbox full (often sent as a 4xx first) |
The recipient's quota is full. | Nothing. It'll retry, then bounce. |
550 5.7.26 ... unauthenticated (Gmail) |
Failed DMARC, or no SPF/DKIM at all. | Step 2. This is an alignment or DNS problem. |
421 4.7.28 ... unusual rate (Gmail) |
You're sending faster than your reputation supports. | Slow down: warm up a new domain gradually. |
554 5.7.1 ... blocked / mentions a blocklist |
Your IP or domain is on a blocklist. | Check which list the message names; with a shared provider, contact them. |
Step 5: Check whether the address is suppressed
If an address bounced hard once, sending to it again is a signal to mailbox providers that you don't maintain your list. So providers keep a suppression list and skip those addresses on later sends. Many also suppress anyone who marked a previous email as spam.
In practice this produces the most confusing bug report of all: "I signed up again with the same address and still got nothing." The first signup attempt had a typo, or their mailbox was full for long enough to hard-bounce, and the address has been suppressed ever since.
What to do:
- Look up the address in your provider's suppression list. In Cresca, the email's status itself says
suppressed, and the delivery logs show which recipient was skipped. - If the original reason is gone (the person fixed their mailbox, or the bounce was a misconfiguration on your side), get the address removed from the list and resend. Some providers let you do that yourself; others, Cresca included, have support remove bounce and complaint suppressions after checking, so one mistaken unsuppress can't hurt your domain.
- Don't build a "retry everything" job that ignores suppression. It's the fastest way to turn a few bad addresses into a reputation problem for every email you send.
One rule worth copying: a marketing unsubscribe must never block transactional email. Someone who unsubscribed from your newsletter still needs their password reset. Hard bounces and spam complaints are different: those addresses shouldn't get anything from you.
Step 6: If it's delivered but nobody sees it, look at the email itself
If authentication passes and the status is delivered, the message reached the account. When it lands in spam or a tab instead of the inbox, the content and the sending pattern are what's left. For transactional email, the usual suspects are:
-
No plain-text part. Send
multipart/alternativewith both HTML and text. Most email APIs generate the text version from your HTML if you don't provide one. -
A From address nobody recognises.
[email protected]reads like phishing. Use your product's name and domain:Acme <[email protected]>. -
Link mismatch. The visible link text says
acme.combut the URL goes through a shortener or a tracking domain. Link to your own domain, and if you use click tracking, use a tracking subdomain you own. - Marketing inside transactional. A receipt with three promotional banners gets treated like a promotion. Keep transactional email about the transaction.
- A brand-new domain sending a lot, fast. New domains have no reputation. Start with your own team and real users, and ramp up over a couple of weeks.
The checklist I run, in order
When a user says "I never got the email":
- Look up the status for that email id: queued, sent, delivered, bounced, suppressed or failed.
- If bounced or delayed: read the SMTP reply and use the table in step 4.
- If suppressed: check why, and only get it removed if the reason is gone.
- If delivered: ask them to check spam and other tabs, and look at your own copy's Show original for SPF/DKIM/DMARC.
-
Check DNS with
dig: one SPF record, DKIM on the right name, a DMARC record, and alignment with your From domain. - Check the bulk-sender rules if you send marketing: one-click unsubscribe headers and a low complaint rate.
- Separate streams: app email and marketing on different subdomains.
Almost every "it said sent but never arrived" bug sits somewhere in that list. The 200 was true. It just wasn't the whole story.
I'm building Cresca, an email platform with a transactional email API and AI-designed marketing campaigns in one account. Every API email has a status you can read through the API or in the dashboard timeline, and the email lifecycle docs list every status used above. What's the strangest delivery bug you've chased? Tell me in the comments.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes โ full credit and traffic to the original publisher.