The cheapest bounce is the one that never enters your list. Verifying an address at the moment someone types it — in a serverless function behind your signup form — stops typos, dead domains, and disposable addresses at the door, instead of paying to clean them out later and taking the deliverability hit in between. This guide shows the pattern with a working serverless route.
The short answer#
Put verification in a server-side function your signup form calls: it holds your
API key, calls POST /verify, and acts on the immediate local
verdict — reject undeliverable, offer the did_you_mean correction, flag
disposable, and let everything else through. Two rules make or break it: never
call the API from the browser (the key must stay server-side), and never block a
signup when the API is slow or down (fail open).
Why at signup, not later#
Verification at capture is prevention; cleaning a list is cleanup, and prevention wins on every axis. A typo caught at the keyboard is a subscriber saved with a one-line correction. A dead domain rejected at signup never becomes a hard bounce that dents your bounce rate. A disposable address blocked at the gate never dilutes your metrics or your bill. The same address found in a list clean three months later has already cost you a send, a bounce, and a sliver of sender reputation.
The pattern#
Three moving parts:
- The form posts the address to your own endpoint — never straight to the verification API, because that would expose your key.
- A serverless function holds the key in an environment variable, calls
POST /verify, and turns the result into a decision. - Your signup logic acts on the decision: reject, suggest a correction, or accept.
A serverless route#
Here it is as a Next.js App Router Route Handler, which deploys as a serverless
function on Vercel. The key lives in QUALISEND_API_KEY; the client never sees
it:
// app/api/validate-email/route.ts
import { NextResponse } from "next/server";
const BASE = "https://app.qualisend.com/api/v1";
export async function POST(req: Request) {
const { email } = await req.json();
try {
const res = await fetch(`${BASE}/verify`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.QUALISEND_API_KEY!}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ email }),
signal: AbortSignal.timeout(4000), // don't let a slow probe stall signup
});
// On any API error, fail open — a real user must not be blocked by our outage.
if (!res.ok) return NextResponse.json({ ok: true, degraded: true });
const { result } = await res.json();
if (result.status === "undeliverable") {
return NextResponse.json({
ok: false,
reason: result.reason, // invalid_email | invalid_domain | rejected_email
suggestion: result.did_you_mean, // e.g. "jane@gmail.com"
});
}
if (result.sub_flags?.disposable) {
return NextResponse.json({ ok: false, reason: "disposable" });
}
// deliverable, risky, or unknown — accept, but pass along a typo suggestion if any
return NextResponse.json({ ok: true, status: result.status, suggestion: result.did_you_mean });
} catch {
return NextResponse.json({ ok: true, degraded: true }); // timeout or network error → fail open
}
}
Store the key with your platform's env tooling (vercel env add QUALISEND_API_KEY)
and it is injected at runtime — never bundled into the client.
Acting on the verdict#
The immediate result is the local verdict (syntax, DNS, disposable, role,
typo), which is exactly what you want at signup — fast, and enough to decide:
| Result | Signup action |
|---|---|
undeliverable | Reject inline — "that address doesn't look deliverable". |
did_you_mean present | Offer the correction — "did you mean jane@gmail.com?" — the highest-value save. |
disposable flag | Reject or flag, depending on how strict your product is — see role, disposable & free. |
risky / catch-all | Accept, but tag it so you can send to the catch-all segment carefully. |
unknown | Accept. The infrastructure didn't answer — never lose a real signup over it. |
deliverable | Accept. |
Fail open, always#
The one non-negotiable: if the verification call errors, times out, or returns a
non-2xx, let the signup through. A verification API is a quality filter, not an
authentication gate, and blocking a paying customer because of a transient outage
is a far worse outcome than letting one questionable address in. Both the
!res.ok branch and the catch above return ok: true for exactly this reason.
Log the degraded signups and catch them in your next
list clean instead.
Frequently asked questions#
Does verifying at signup slow the form down?#
Barely, if you do it right. The local verdict returns fast, and the 4-second timeout above caps the worst case — past which you fail open and accept the signup anyway. Users get near-instant "did you mean" corrections; they never wait on a slow mail server, because you're acting on the immediate local result, not polling for the SMTP probe.
Should I block disposable email addresses at signup?#
It depends on your product. For a paid or reputation-sensitive service, blocking disposables at the gate is worthwhile — they never intended to hear from you again. For a low-friction consumer signup, flagging them for later review can be better than adding friction. The route above rejects them; loosen it to a flag if that fits your funnel.
Do I need the full SMTP-confirmed result at signup?#
Usually not. The immediate local verdict catches the bulk of bad signups — typos,
dead domains, disposables — with no waiting. Reserve the full SMTP-confirmed
result (via GET /jobs or a bulk job) for list cleaning, where
latency doesn't matter and catching every dead mailbox does.
How do I keep my API key secret?#
Call the verification API only from server-side code — a serverless function, route handler, or backend — and store the key in an environment variable, never in client-side JavaScript. If the key would ship in the browser bundle, it's in the wrong place.
Ready to wire it up? The free plan includes 100 credits to test the
flow, and the API reference has the /verify endpoint with
copy-paste examples in seven languages.