Hard bounce vs soft bounce: the difference, and what to do with each one
Published Updated 15 min readBy the Rasket team
Written for e-commerce teams, email marketers and deliverability specialists

A hard bounce is permanent: the address does not exist or its domain takes no mail, so stop sending to it. A soft bounce is temporary: a full mailbox, a busy server, a sender being slowed, and the mail may arrive later. Decide which from the reply’s enhanced status code, not the first digit alone.
Say a customer types jane.smtih@gmail.com at checkout. Her order confirmation hard-bounces within seconds with 550 5.1.1, and every shipping update would too. The same afternoon a regular customer’s receipt soft-bounces because her inbox is full. Delete the regular and you lose a customer; keep mailing the typo and providers see a careless sender. In short:
- Remove addresses that do not exist:
5.1.1, and the ones that hide under other codes, such as Microsoft 365’s5.4.1“Recipient address rejected” and a5.2.1“inactive” account. - Keep full mailboxes and busy servers, and remove one only after two bounces in a row at least two weeks apart.
- Investigate every
5.7.x: it is almost always a refusal of your mail, not a problem with the address. Fix the cause and keep the recipient.
Hard bounce vs soft bounce, side by side
| Hard bounce | Soft bounce | |
|---|---|---|
| Cause | The address or its domain does not exist | Mailbox full, server busy, greylisting, a rate limit |
| Retry the same message? | Never | Our mail servers already did, before calling it a bounce |
| Remove the address? | At once | Only if it repeats (see below) |
| Rasket event | bounce.type: Permanent | bounce.type: Transient |
| Suppressed by Rasket? | Yes, automatically | No, the rule is yours |
A delay is not a bounce yet. While our mail servers are still retrying a 4xx reply you get email.delivery_delayed; it becomes a Transient bounce only when the retries run out. A third type, Undetermined, means our mail servers could not tell from the reply whether the problem is lasting. It is rare; count it as a soft bounce.
Read the enhanced status code, not just the first digit
Every refusal comes with a short error number: a three-digit reply code (RFC 5321) and usually an enhanced code after it (RFC 3463), such as 5.1.1. The first digit says permanent (5) or temporary (4). The middle digit says whose problem it is:
| Middle digit | About | Whose problem |
|---|---|---|
| 1, 2 | The address, the mailbox | The recipient's |
| 5, 6, 7 | The protocol, the content, security or policy | Usually yours |
| 3, 4 | The receiving mail system, the routing between you | Usually theirs, and temporary |
These are the replies you will see most, as the receiving servers write them. In the dashboard, open a bounced email in Emails and its timeline shows the same line; in a bounce event it arrives in diagnosticCode after smtp;.
550 5.1.1 The email account that you tried to reach does not exist.
550 5.4.1 Recipient address rejected: Access denied
550 5.2.1 The email account that you tried to reach is inactive.
452 4.2.2 The recipient's inbox is out of storage space.
552 5.2.2 The recipient's inbox is out of storage space and inactive.
421 4.7.0 Try again later, closing connection.
550 5.7.26 Unauthenticated email from acme.example is not accepted due to domain's DMARC policy.| Code | What it means | What to do |
|---|---|---|
5.1.1 | No such mailbox | Hard bounce. Stop sending to the address. |
5.1.10 | No such recipient. It is Microsoft 365’s usual reply for an address that does not exist; it can also mean the domain publishes a null MX and takes no mail (RFC 7505) | Hard bounce. Stop sending to the address. A null-MX domain rarely gets this far on Rasket, because the send is refused before it leaves. |
5.4.1 | At Microsoft 365, with "Recipient address rejected", the address is not in that company’s directory | Hard bounce. But if every address at one company gets it at once, their directory is misconfigured and the addresses are fine. |
5.2.1 | With "inactive" or "disabled", the account has been switched off | Hard bounce. Gmail also uses 550 5.2.1 for a mailbox “receiving email at a rate that prevents additional messages”, which is not, so read the words. |
4.2.2 | Mailbox full (Gmail: 452 4.2.2) | Soft. Keep the address. |
5.2.2 | Mailbox full; at Gmail, "out of storage space and inactive" | RFC 3463 defines mailbox full as a temporary failure, so a receiver sending it under a 5 is using the wrong class: treat a bare 5.2.2 as soft. With Gmail's “inactive”, the mailbox is abandoned: remove it. |
4.x.x | Temporary: busy, greylisting, rate limiting | A deferral, not yet a bounce. Many replies like Gmail's 421 4.7.28 “unusual rate of email” mean slow down: you are over a rate or your reputation is slipping. |
5.7.26, 5.7.27, 5.7.515 | Gmail refusing mail that fails DMARC or SPF; Outlook.com refusing a high-volume sender without SPF, DKIM and DMARC | About your mail: the same refusal is waiting for everyone at that provider. Fix authentication. |
5.7.1 | Policy refusal. The text says which: a block list or reputation, or a rule for this one recipient, such as Gmail's “The user or domain that you are sending to (or from) has a policy that prohibits the email” | Block list or reputation: fix your sending. A recipient's own rule: count it as a soft bounce, so the rule below removes it if it repeats. |
Deleting addresses over a 5.7.x only hides the incident. The authentication article covers the usual fixes.
Some replies carry no enhanced code at all, only a reply code and a sentence, and some receivers put a missing address under the wrong code. For both, M3AAWG’s sender best practices (section 2.2) say the same thing: read the descriptive text. Treat a reply as a bad address only when the words are unambiguous, such as “user unknown” or “does not exist”. RFC 5321’s stock 550 text, “mailbox unavailable”, is used for policy refusals too, so it is not enough.
What Rasket already does for you
- Refuses addresses that cannot receive mail, before sending. A send to a known typo of a big provider’s domain (
gmial.com), a reserved domain, or a domain with no MX and no address record, or a null MX, is refused with a “Did you mean” suggestion where there is one. It never becomes a bounce: a single send gets a422, and a campaign records that recipient asemail.suppressedwith reasoninvalid_recipient. If the DNS lookup fails, the send goes through. - Remembers addresses that cannot exist. An address that hard-bounced as non-existent (a
5.1.1or5.1.10, or a reply our mail servers classify as “no such address”) is refused for every team for 180 days after its last such bounce, so nobody’s reputation pays for it twice. If a customer’s address is refused this way and they say it works, ask us to lift it. - Retries soft failures. An
email.bouncedevent with typeTransientmeans the retries have already stopped. Do not add a second retry layer that resends the same message. - Suppresses hard bounces and complaints. Every bounce reported as
Permanentadds the address to your team’s suppression list, its do-not-send list, as does every spam complaint. Later sends to it, marketing and transactional alike, are stopped and reported asemail.suppressed. - Leaves soft bounces alone.
TransientandUndeterminedbounces never suppress. When a repeating soft bounce means “gone” is your call; the next section shows the rule.
One gap to know about: a refusal that arrives as Permanent is suppressed whatever its code, policy refusals included. During an authentication outage that can be hundreds of good addresses in an hour. Once the cause is fixed, lift them:
- In the dashboard, open Emails and click Suppressions at the top right.
- Set the origin filter to bounce and the date to the day of the outage. Each row shows the address and when it was added; open one to see the email that bounced.
- Remove the affected addresses with Remove. The dashboard removes one at a time; there is no bulk lift there yet.
For more than a handful, use the API: GET /suppressions?origin=bounce with start_date and end_date lists them, and POST /suppressions/batch/remove lifts them in one call. Tracing a missing email walks through finding which case you are in.
When to give up on a soft bounce
M3AAWG’s Sender Best Common Practices (version 4.0, August 2026, section 2.2) say addresses should be removed when they “consistently bounce with any failure code or descriptive text”, and that “generally it is considered a best practice to remove an address from the list if it bounces consecutively at least two times over two weeks or more.” Platforms pick their own counts; this is the published baseline. The two-week spread is what keeps one bad day at the receiver from costing you a customer. Reset the count whenever a message to the address is delivered.
Until an address reaches that line, keep it in your next campaign. One full-mailbox bounce says nothing about next week, and a customer who empties their inbox expects your mail to resume. A campaign’s report shows how many recipients bounced, and its list of bounced recipients has a bounce type column, so the soft ones are easy to pick out.
Rasket has no built-in view of repeat soft bounces yet, so without code the check is manual. For a store sending a few thousand emails a month it takes a few minutes, once a month:
- Open Emails, set Status to Bounced and the date to the last 30 days, and click Export this page for each page of results.
- In the spreadsheet, sort by recipient. An address listed twice or more, with its first and last bounce at least two weeks apart, has met the rule.
- On Suppressions, click Add email address and paste them in, one per line. Addresses whose bounce was
Permanentare already there.
Past that volume, the handler at the end of this article applies the rule on every event instead.
What bounce rate is too high
Measure bounce rate as bounced recipients over recipients with an outcome, delivered plus bounced. That is how GET /emails/metrics reports bounce_rate, per day and per sending domain.
No mailbox provider publishes an email bounce rate limit. M3AAWG says large volumes of permanent failures count against a sender’s reputation and usually point to a poor sign-up process or an old list, without giving a number. The published numbers are sending platforms’ enforcement lines. Our terms warn a team at 2%, cut its sending back at 5% and stop it at 10%. Those apply to a team’s sending as a whole, and a new account is watched more closely from its first emails, so they are a ceiling for the team, not a target for one campaign.
No one publishes a healthy number either, so compare each send with your own previous ones. A slow climb means the list is decaying faster than you clean it. A spike almost always traces to one import, one form without validation, or one integration writing the wrong field into the address: compare the day’s bounces by sending domain and by campaign, and the culprit is usually alone. Before mailing an old or imported list, send to its most recently active part first and check the bounce rate before sending the rest.
A low rate does not prove a clean list. Catch-all domains accept mail for every address and never bounce (list-validation tools flag them as “catch-all” or “accept-all”), and an abandoned mailbox at such a domain only shows up as a reader who never opens or clicks. Retiring inactive readers, below, is what catches those.
Business addresses bounce differently from consumer ones. A company’s mail gateway is more likely to accept a message and send the bounce to your return path (the bounce address your mail carries) later, hours or even days after it looked delivered (M3AAWG notes this for B2B mail). Weigh a late bounce exactly like an immediate one: the code decides, not the channel. In Rasket a late bounce replaces the delivered status on that email. Corporate refusals are also more often policy (5.7.x) or a directory answer (5.4.1, above), so group them by domain before removing anyone.
Stop bad addresses at the form, and tell the customer
Rasket already refuses typo and mail-less domains when you send. A check at the form is still worth having, because it catches the customer while they are on the page and can fix it.
- Check the domain can receive mail. A form you control can look up the domain’s MX record, the DNS entry that names its mail server, at sign-up (
dig +short MX example.comfrom a terminal shows what it will find). No MX and no address record, or a null MX, means every message will bounce. On a hosted store where you cannot add code, skip this one: Rasket’s send-time check covers it, and the next one is what you control. - Catch typos. Suggest
gmail.comforgmial.combefore the form submits. A typo before the @, likejane.smtih, gets past every check, so show the address back on the confirmation page. - Confirm before marketing. A confirmation email the person must click proves the mailbox works. The double opt-in article shows the flow.
- Retire the inactive. Abandoned addresses start hard-bouncing, and some are later turned back on as spam traps. The list cleaning guide has a sunset policy.
When a receipt or password reset bounces, the suppression also stops every later transactional email to that address, so a resend fixes nothing. Show a banner in the customer’s account, and give support a reply to send by another channel, such as the phone number on the order:
Thanks for your order. Our confirmation to jane.smtih@gmail.com was returned:
the receiving server says that address doesn't exist, so we haven't been able to
email you. Could you confirm the right address? We'll update your account and
resend the confirmation straight away.Once they confirm a new address, update the account and resend; the old one stays suppressed. On a hosted store with no developer to add a banner, open the order in your store’s admin, correct the customer’s email there and resend the confirmation from the order: the support reply and that edit are the whole fix. Only mail you send through Rasket appears in its dashboard; if your store platform sends its own order emails, their bounces are in the platform’s admin.
For developers: handling the bounce event in code
A bounce arrives as an email.bounced webhook. Its bounce object carries type (Permanent, Transient or Undetermined), a subType, the receiving server’s message, and diagnosticCode lines. One bounce notice can hold the lines for several recipients of the same email, so the handler below reads only the line that names its address (or names nobody), pulls the enhanced code out of it, catches the receivers that report a missing address under another code, and sorts each bounce into one of three outcomes.
Show the handler (TypeScript)
// Your own functions: the handler only decides which one to call.
declare function alertTeam(message: string): Promise<void>;
declare function markAddressBad(address: string): Promise<void>;
declare function recordSoftBounce(
address: string,
code: string | null,
permanent: boolean,
): Promise<void>;
const API = "https://api.rasket.com";
type Bounce = { type: string; message?: string; diagnosticCode?: string[] };
type Verdict = "bad-address" | "our-problem" | "soft";
// The enhanced code follows the three-digit reply code: "550 5.1.1" or "552-5.2.2".
const ENHANCED = /\b[245]\d\d[ -]([245]\.\d{1,3}\.\d{1,3})\b/;
// A missing or abandoned mailbox reported under another code. Match the words too:
// Gmail also uses 5.2.1 for a mailbox that is receiving mail too fast.
const GONE_UNDER_OTHER_CODES = [
{ code: "5.4.1", words: /recipient address rejected/i }, // Microsoft 365
{ code: "5.2.1", words: /inactive|disabled/i }, // account switched off
{ code: "5.2.2", words: /inactive/i }, // Gmail: full and abandoned
];
// Unambiguous wording, whatever code it came under: a starting list to extend from
// your own bounce logs. RFC 5321's stock "mailbox unavailable" is used for policy
// refusals too, so it is not here.
const GONE_WORDS = new RegExp(
"user unknown|unknown user|no such (user|mailbox|recipient)|does not exist" +
"|doesn't have an? [\\w.-]+ account|mailbox is disabled",
"i",
);
// 5.1.7 and 5.1.8 are about your own sender address, not the recipient's.
const SENDER_SIDE = new Set(["5.1.7", "5.1.8"]);
// One recipient's own policy rule (Gmail's wording): soft, so the two-bounce rule
// drops it if it repeats. Never when the text is about authentication or reputation.
const RECIPIENT_RULE = /policy that prohibits the email/i;
const ABOUT_SENDER = /spf|dkim|dmarc|authenticat|reputation|block ?list/i;
/** The server's reply. One notice can hold several recipients' lines: use ours. */
function replyText(bounce: Bounce, address: string): string {
// `message` usually repeats the first diagnostic line, hence the Set.
const lines = [...new Set([...(bounce.diagnosticCode ?? []), bounce.message ?? ""])];
const own = lines.filter((line) => line.toLowerCase().includes(address.toLowerCase()));
// No line names us: use only lines that name nobody, never another recipient's.
const ours = own.length > 0 ? own : lines.filter((line) => !line.includes("@"));
return ours.join("\n");
}
/** The RFC 3463 code inside the reply, such as "5.1.1". */
export function enhancedCode(text: string): string | null {
return ENHANCED.exec(text)?.[1] ?? null;
}
export function classify(bounce: Bounce, address: string): Verdict {
const text = replyText(bounce, address);
const code = enhancedCode(text);
const subject = code?.split(".")[1]; // see the middle-digit table
if (GONE_UNDER_OTHER_CODES.some((x) => x.code === code && x.words.test(text))) {
return "bad-address";
}
const recipientRule = RECIPIENT_RULE.test(text) && !ABOUT_SENDER.test(text);
if (subject === "7" && recipientRule) return "soft";
const aboutAddress =
bounce.type === "Permanent" && subject !== "7" && !SENDER_SIDE.has(code ?? "");
if (aboutAddress && (subject === "1" || GONE_WORDS.test(text))) return "bad-address";
if (SENDER_SIDE.has(code ?? "") || ["5", "6", "7"].includes(subject ?? "")) {
return "our-problem"; // your sending or your message
}
return "soft"; // includes 3 (their mail system) and 4 (routing)
}
/** Call this for each verified email.bounced event. */
export async function onBounce(data: { to: string[]; bounce: Bounce }): Promise<void> {
// Rasket has already suppressed every Permanent bounce, whatever this decides.
const permanent = data.bounce.type === "Permanent";
for (const address of data.to) {
const reply = replyText(data.bounce, address);
const code = enhancedCode(reply);
const verdict = classify(data.bounce, address);
if (verdict === "our-problem") {
const lift = permanent ? " (suppressed)" : "";
await alertTeam(`${address} refused us${lift}: ${reply}`);
} else if (verdict === "bad-address") {
await markAddressBad(address); // ask the customer for a new address
} else {
await recordSoftBounce(address, code, permanent);
}
}
}
const TWO_WEEKS_MS = 14 * 24 * 60 * 60 * 1000;
/** M3AAWG section 2.2: two or more bounces in a row, spread over at least two weeks. */
export async function suppressIfPersistent(
address: string,
streak: { count: number; firstAt: Date },
): Promise<void> {
if (streak.count < 2 || Date.now() - streak.firstAt.getTime() < TWO_WEEKS_MS) return;
const response = await fetch(`${API}/suppressions`, {
method: "POST",
headers: {
authorization: `Bearer ${process.env.RASKET_API_KEY}`,
"user-agent": "acme-mailer/1.0",
"content-type": "application/json",
},
body: JSON.stringify({ email: address }),
});
if (!response.ok) throw new Error(`suppress failed: ${response.status}`);
}It does the three jobs the platform cannot. It alerts a person when the refusal is about your sending, including a Transient 4.7.x whose retries ran out: that is a rate or reputation problem, not a soft bounce to count against the address. It marks the address as bad in your own database, so your product can ask the customer for a new one at their next login. And suppressIfPersistent applies the two-weeks rule to soft bounces: call it from recordSoftBounce with the address’s streak, and reset the streak on email.delivered. The permanent flag keeps your records in step with Rasket’s list. Deliveries can arrive twice, so deduplicate on svix-id as the webhooks article shows.
Frequently asked questions
What does a bounce look like when I send from an ordinary mailbox?
A message from 'Mail Delivery Subsystem' or 'Postmaster' saying your email could not be delivered, with the receiving server's reply quoted inside, such as 550 5.1.1. That is a delivery status notification. A sending platform reads the same notice for you and turns it into an event, so bounce handling becomes code rather than reading mail.
How do I tell a reputation block from an authentication failure?
Both arrive as 5.7.x, so read the words after the code. Mentions of DMARC, SPF, DKIM or 'unauthenticated' mean a record is missing or not aligned. Mentions of a block list, 'reputation' or 'too much spam' mean the receiver distrusts your sending. Check your records first, because that takes minutes; reputation takes weeks.
Does a bounce on a marketing email stop order confirmations too?
Yes, if it was a hard bounce or a complaint. A team has one suppression list for every kind of mail, so an address suppressed after a campaign is also skipped for receipts and password resets. An unsubscribe is different: it stops only marketing mail. Ask the customer for a new address, or lift the suppression once you know the mailbox works again.
Do spam complaints work like bounces?
In one way: Rasket suppresses the address as soon as a complaint arrives, and it reaches you as an email.complained event. But a complaint means the message was delivered and the reader pressed the spam button, so it says nothing about the address and a lot about your list. Gmail does not report individual complaints to senders at all; its rate is in Postmaster Tools.
Should I run an imported list through a validation service first?
For a list nobody has mailed in a year, yes. A validation service checks the syntax and the domain and usually asks the receiving server whether the mailbox exists. It cannot confirm addresses at catch-all domains, which accept everything. Rasket already refuses dead domains and known dead addresses at send time, but not a dead mailbox at a live domain.
Can I send to an address that was suppressed by mistake?
Yes. Remove it with DELETE /suppressions/{email}, or from the dashboard, and the next send goes through. Do that only once you know the cause is fixed, such as a mailbox the customer has emptied or a policy block you have resolved, or the next send bounces again.
Sources
- RFC 3463: Enhanced Mail System Status Codes — IETF, read 2026-10-17
- RFC 5321: Simple Mail Transfer Protocol, section 4.2 (reply codes) — IETF, read 2026-10-17
- RFC 7505: A "Null MX" No Service Resource Record for Domains That Accept No Mail — IETF, read 2026-10-17
- M3AAWG Sender Best Common Practices, Version 4.0 (August 2026), section 2.2 — M3AAWG, read 2026-10-17
- Gmail SMTP errors and codes — Google, read 2026-10-17
- Fix NDR error 550 5.1.10 in Exchange Online — Microsoft, read 2026-10-17
- Fix NDR error 550 5.4.1 in Exchange Online — Microsoft, read 2026-10-17
- Rasket docs: Event payloads — Rasket, read 2026-10-17
- Rasket docs: Suppressions API reference — Rasket, read 2026-10-17
Related
- Events — Every event a webhook can carry, with one real payload each.
- Suppressions — Addresses we will not send to, and why.
- Email list cleaning: sunset inactive subscribers — Define inactive subscribers when Apple inflates opens, spot scanner clicks, run a re-engagement flow, and sunset the rest without blocking their receipts.
- Email deliverability guide: inbox, not spam — Delivered is not inboxed: the records, list habits and sending pace that reach the inbox, a checklist for non-developers, and what to do when mail hits spam.
- Email webhooks explained: events and signatures — Email webhooks explained: the delivery events, the payload, HMAC signature verification, retries, and complete tested receivers in Node and Python.
- Greylisting — Greylisting is a filtering technique where a receiving server temporarily refuses a message from an unfamiliar sender and waits to see whether it is tried…