The SendGrid bounce spike that taught us how to ship outbound email safely
On April 17, 2026, our outbound pipeline generated 1,939 hard bounces in a single day against 1,046 successful deliveries. Here's what went wrong, how we recovered, and the seven-gate preflight we now run before any campaign send — written for legal-tech ops teams who will eventually make the same mistake.
Verify it yourself — free, no login
See how AI medical-record review links every fact to the exact Bates page that proves it — click any citation and jump straight to the record.
See the 60-second demo →If you run outbound email at a SaaS company small enough to fit in one person's head but big enough to have a domain reputation worth protecting, this post is for you. What happened to us on April 17 is boring infrastructure failure, not a cautionary tale about spammers. The lessons generalize.
What happened
We're a plaintiff-attorney SaaS platform. Outbound email serves two purposes: (1) transactional — welcome emails, password resets, trial-drip sequences; (2) campaign — cold outreach to prospect firms identified through public databases (Justia, AAJ directories).
On the morning of April 17, we fired a campaign against a newly-scraped list of ~3,000 addresses. The list came from a public source, but it had a quality problem we didn't catch: most of the "email" column was synthetically generated using a [email protected] pattern — a defensive guess against firm domains, not verified deliverables.
By end of day: 1,939 hard bounces. 65% bounce rate. Our SendGrid reputation score, which had been climbing through a 10-day warmup, dropped into "fair" territory. Gmail's rolling 30-day bounce window filled with this single bad event.
The core failure wasn't the bounced addresses. It was that we had no gate between "list acquired" and "list sent." The campaign script did its job perfectly. It was just asked to do the wrong job.
What we should have known
In retrospect, three things were already visible that we should have acted on before pressing send:
- The
[email protected]pattern on more than ~20% of the list is diagnostic for a guess-based list. Firm-domain email patterns are wildly inconsistent — some use first initial + last name, some use first name only, some use last name + extension number. A list where 70%+ follow the same pattern is not a list; it's a prediction. - The commercial market for verifying these patterns exists for a reason. NeverBounce and ZeroBounce charge $0.008 per email because bounce-insurance has economic value. When we skipped that step to save $24, we were insuring ourselves worse than the actual insurance.
- Our SendGrid warmup plan was on Day 10 of 42. The whole point of the warmup curve is that early-stage reputation is fragile. A single bad day inside Day 1-14 costs more reputation than three bad days on Day 30+.
What we did on Day 1 (Apr 17 evening)
The immediate triage had to be compressed because every hour of continued sending compounded the damage:
- Kill-switched the campaign — added
plaintiff_attorneyto aBLOCKED_SEGMENTSset in the send script. Manual action, committed. - Stopped all non-transactional sends for the next 48 hours to let the reputation signal age. Transactional mail (welcome / password reset / drip) kept flowing because those addresses were self-submitted and known-valid.
- Hard-bounce suppression — wrote every bouncing address from the Apr 17 batch into a
marketing/bounced-emails-YYYY-MM-DD.txtfile loaded by the send script on every run. Re-sends to these addresses would compound reputation damage. - Purged the master list — 2,582 rows matching the synthetic pattern or on the hard-bounce list were deleted from the source CSV entirely, not just suppressed.
- Audited the next scheduled send — didn't let anything go out on Apr 18 until we understood what happened.
By Apr 18 morning, we had a clean stop. Reputation was damaged but contained. The damage now had a timeline: Gmail's rolling window would clear the Apr 17 event at around Day 30 (May 7-15), so full recovery was ~3 weeks out.
What we built in the next three days
The kill-switch was the immediate fix. The structural fix is a preflight script that every campaign must pass before it's allowed to run.
The seven gates
bin/sendgrid-preflight.sh is mandatory. run-campaign.js won't start unless preflight exits zero. Each gate is cheap to check and failure-closed:
Gate 1: Campaign state still paused? Must be TRUE before a re-prime. We're re-priming, not resuming blind. Gate 2: BLOCKED_SEGMENTS includes the problem segment? grep plaintiff_attorney in run-campaign.js Gate 3: Recent 3-day bounce rate ≤ 8%? Live SendGrid /v3/stats call. Fail-closed if API unreachable. Gate 4: Required env vars set? SENDGRID_API_KEY, UNSUBSCRIBE_SECRET. NEVERBOUNCE_API_KEY recommended. Gate 5: List hygiene? File must be a .verified.csv (NeverBounce output). Warns on any firstname.lastname@ pattern remaining. Gate 6: Google Postmaster reputation checked? (manual) Gate 7: SendGrid sender reputation > 80? (manual)
The script exits non-zero on any failure. It prints the specific gate that blocked the send. In the 72 hours after we shipped this, preflight has already blocked three would-be sends — two because the rolling bounce rate hadn't aged out yet, one because someone tried to point it at a .csv that wasn't verified.
In-code defenses
Beyond the preflight, run-campaign.js itself got two new checks:
// Preflight: refuse to send if SendGrid's rolling bounce rate
// (last 3 days) is above MAX_RECENT_BOUNCE_RATE_FOR_SEND.
if (!dryRun && !args.includes('--skip-bounce-check')) {
const recentRate = await fetchRecentBounceRate();
if (recentRate === null) process.exit(1);
if (recentRate > MAX_RECENT_BOUNCE_RATE_FOR_SEND) process.exit(1);
}
// Synthetic-pattern block: [email protected]
// is the Apr 17 root cause. Block unless contact row has
// verified='true' (set by a NeverBounce run).
const SYNTHETIC_PATTERN = /^[a-z]+\.[a-z]+@/;
if (isSyntheticPattern(c.email) &&
String(c.verified || '').toLowerCase() !== 'true') {
syntheticBlockedCount++;
return false;
}
The second check was the one that mattered. It codifies the lesson: if an address looks guessed, treat it as guessed until something external validates it. The verified=true flag is set by bin/neverbounce-verify.js as a side-effect of a real verification run. No guess can self-certify.
The activation-aware drip gate
A subtler bug surfaced during the audit. Our trial user drip sequence was firing "your demo case is ready" emails to users who had already activated. Because the drip was scheduled via setTimeout at register time, it didn't know if the user had activated before the timer fired.
Fix:
// Each per-user setTimeout drip re-reads the user from DB at // fire time. Skip if the user has already activated, paused, // unsubscribed, or been deleted. const freshUser = db?.getUserById(user.id); if (!freshUser) return; if (freshUser.first_tool_run_at) return; if (['paused', 'unsubscribed', 'cancelled'].includes(freshUser.status)) return; if (db?.isSuppressed(email)) return;
This cleaned up the drip behavior but more importantly closed a footgun — the previous version could theoretically send a come-back email to a user who'd activated, paused their account, reactivated, and then churned, all in a 7-day window. Unlikely, but possible, and exactly the kind of thing that trains people to tell their spam filter to eat your domain.
The lessons that generalize
This was all old knowledge — the SendGrid docs, the Mailchimp playbook, the Postmark engineering blog, and Laura Atkins's Word to the Wise all contain every warning we violated. But reading those docs isn't what makes you internalize them. A 65% bounce day is what makes you internalize them. So here are the things I'd tell a younger engineer specifically:
1. Treat your domain reputation like a cash balance, not a pride number
You can spend it on a campaign. You can rebuild it with good sends. But you can't replenish it on the timescale you'd like — Gmail's 30-day rolling window is real. Plan capital expenditures against it.
2. "Public list" doesn't mean "verified list"
Scraped data from public sources is input data, not deliverable data. The step between those two states is NeverBounce or equivalent. Skipping the step to save $24 and ignoring the domain-pattern signal is the single most common mistake in early-stage legal-tech outbound.
3. Build preflight checks that fail-closed
A warning you can dismiss is a warning that will be dismissed. A process that exits non-zero on unverified input is a process that gets followed. Every gate in our preflight is fail-closed: if the SendGrid stats API is unreachable, the send does not proceed. The --skip-bounce-check override exists for explicit testing, not convenience.
4. Distinguish transactional and campaign reputation
Transactional mail (the kind users asked for) and campaign mail (the kind you asked them to read) use the same sender domain but should be routed through different gates. Our transactional path kept running through Apr 17 — because it was already opted-in, it wasn't part of the reputation hit. Your preflight should know the difference.
5. A bad day is a feature, not a bug
The Apr 17 incident bought us infrastructure we'd been putting off for months — the preflight script, the synthetic-pattern detector, the NeverBounce wiring, the activation-aware drip gate. Without a real failure the organizational will to build these doesn't exist.
What we're doing next
We're not resuming campaign sends until Gmail's 30-day window clears the Apr 17 event (approximately May 7-15). Transactional mail keeps flowing normally. When we do resume, the ramp is:
- Week 1: NeverBounce-verified ≤40 addresses per day
- Week 2: 60/day if reputation signals hold
- Week 3: 100/day
- Week 4+: back to full warmup curve
We're also switching the campaign sender from [email protected] to [email protected] so any future reputation issues don't contaminate the owner's personal sends. If something like this happens again — and eventually something will — the blast radius is limited.
Code
The preflight script, the in-code bounce checks, and the synthetic-pattern block are all open in our repo. If you're running a similar operation and want to copy the scaffolding:
- bin/sendgrid-preflight.sh — the seven-gate preflight
- marketing/run-campaign.js — campaign send script with in-line gates
- bin/neverbounce-verify.js — the NeverBounce wrapper that produces
.verified.csvoutput
If you copy the scaffolding and then have a worse bounce day than we did, email me. I'd like to compare notes.
Building AI tools for plaintiff attorneys? We make the 23-tool platform our customers use to analyze medical records, prep depositions, and model damages — all without sending you cold email from a guess-list. Try it on a real case: 3 free case analyses, no credit card.
Start Free Trial →