Listens for Clerk's user.created webhook, checks the new address and deletes the account if the address is disposable or dead.
The code
1 Add the keys
MAILVET_KEY=mv_live_your_key
CLERK_WEBHOOK_SIGNING_SECRET=whsec_from_the_clerk_dashboardIn the Clerk Dashboard, add a webhook endpoint for /api/webhooks/clerk, subscribe it to user.created and copy its signing secret.
2 Put the check in one place
lib/mailvet.ts
// The fields this code reads. The response has more: mailvet.dev/docs
export type Check = {
verdict: "valid" | "risky" | "invalid";
disposable: boolean;
did_you_mean: string;
};
// null means no answer: MailVet is down or slow, the quota is
// used up, or the key is wrong. Callers let the signup through.
export async function checkEmail(email: string): Promise<Check | null> {
try {
const res = await fetch(
"https://mailvet.dev/v1/check?email=" + encodeURIComponent(email),
{
headers: { Authorization: `Bearer ${process.env.MAILVET_KEY}` },
signal: AbortSignal.timeout(5000),
},
);
if (!res.ok) {
console.error(`mailvet: HTTP ${res.status}`);
return null;
}
return (await res.json()) as Check;
} catch (err) {
console.error("mailvet: no answer", err);
return null;
}
}3 Handle the webhook
app/api/webhooks/clerk/route.ts
import { clerkClient } from "@clerk/nextjs/server";
import { verifyWebhook } from "@clerk/nextjs/webhooks";
import type { NextRequest } from "next/server";
import { checkEmail } from "@/lib/mailvet";
export async function POST(req: NextRequest) {
const evt = await verifyWebhook(req).catch(() => null);
if (!evt) return new Response("Bad signature", { status: 400 });
if (evt.type !== "user.created") return new Response("Ignored");
const { id, email_addresses, primary_email_address_id } = evt.data;
const email = email_addresses.find(
(e) => e.id === primary_email_address_id,
)?.email_address;
if (!email) return new Response("No email");
// null means MailVet gave no answer. Keep the user and answer 200.
const check = await checkEmail(email);
if (check?.verdict === "invalid" || (check?.verdict === "risky" && check?.disposable)) {
const clerk = await clerkClient();
await clerk.users.deleteUser(id).catch((err) => {
// A retried event: this user is already gone.
if (err.status !== 404) throw err;
});
}
return new Response("OK");
}What to do with the answer
invalid or disposable deletes the user. Everything else stays. did_you_mean is ignored here: a webhook has no form to show a hint in, and deleting someone over a hint is too harsh.
- valid Let it through.
- risky Read
disposable. If it’s true, reject. If false, it’s a role address or DNS couldn’t be reached, so let it through. - invalid Bad format, or the domain has no mail server. Reject.
did_you_mean is empty or holds a domain, such as gmail.com for gamil.com. It doesn’t change the verdict. A known typo can come back valid because squatters give those domains a mail server, so check it first. See the docs for every field.
If MailVet is down, the signup goes through
checkEmail returns null, nothing gets deleted, and the route answers 200 so Clerk doesn't retry. That address stays on your books unchecked, so watch the logs. If the delete itself fails, the route answers 500 and Clerk retries the event.
A 401 or a 429 takes the same path, so a bad key or a used-up quota means addresses pass unchecked. Watch your logs for it.
Watch out
- Try Clerk's own block first. It's included on every plan, off by default, and it rejects known disposable domains before the account exists. It has no settings. Use this webhook if you want MailVet's mail-server check and daily list as well.
- This runs after the account exists. Clerk says webhooks are asynchronous and not for synchronous flows, so a user can be inside your app for a moment before the delete lands. Don't grant anything costly until you've seen the event.
user.createdalso fires for users you create in the Dashboard or through the API. A test account on a disposable domain gets deleted too.- The route must be public. If your
clerkMiddleware()protects routes, exclude this one.
Sources
Compared with these on November 5, 2026. If they’ve changed since, trust them over this page.