Check signup emails with Clerk webhooks
Clerk creates the user before your code runs, so the check happens right after signup, in a user.created webhook. Flagged users keep their account but don't get the perks abusers sign up for, which is the pending and passed pattern applied to Clerk.
Clerk's dashboard also has its own sign-up restrictions. Signitize adds the canonical email, which catches alias duplicates such as [email protected], and detection of new throwaway domains.
1. Keys
SIGNITIZE_API_KEY=sk_live_...
CLERK_WEBHOOK_SIGNING_SECRET=whsec_...
In the Clerk dashboard, open Webhooks, add an endpoint https://<your-domain>/api/webhooks/clerk, subscribe it to user.created, and copy its signing secret. Make sure your middleware does not protect /api/webhooks.
2. The webhook route
import { clerkClient } from '@clerk/nextjs/server'
import { verifyWebhook } from '@clerk/nextjs/webhooks'
import type { NextRequest } from 'next/server'
import { db } from '@/lib/db'
async function checkEmail(email: string) {
const response = await fetch('https://api.signitize.io/v1/check', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.SIGNITIZE_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ email }),
signal: AbortSignal.timeout(5000),
})
if (response.status === 400) return { invalid: true as const }
if (!response.ok) throw new Error(`Signitize answered ${response.status}`)
return (await response.json()) as { email: string; disposable: boolean; deliverable: boolean }
}
export async function POST(req: NextRequest) {
let event
try {
event = await verifyWebhook(req)
} catch {
return new Response('Invalid signature', { status: 400 })
}
if (event.type !== 'user.created') return new Response(null, { status: 204 })
const user = event.data
const email = user.email_addresses.find((address) => address.id === user.primary_email_address_id)?.email_address
if (!email) return new Response(null, { status: 204 })
let check
try {
check = await checkEmail(email)
} catch {
return new Response('Retry later', { status: 503 }) // Clerk retries the webhook
}
let flag: string | null = null
if ('invalid' in check) flag = 'invalid'
else if (check.disposable) flag = 'disposable'
else if (!check.deliverable) flag = 'undeliverable'
else if (await db.user.findFirst({ where: { emailCanonical: check.email, NOT: { clerkId: user.id } } })) flag = 'duplicate'
const emailCanonical = flag || 'invalid' in check ? null : check.email
await db.user.upsert({
where: { clerkId: user.id },
create: { clerkId: user.id, emailCanonical },
update: { emailCanonical },
})
const client = await clerkClient()
await client.users.updateUserMetadata(user.id, {
publicMetadata: { emailCheck: flag ? 'flagged' : 'passed', emailFlag: flag },
})
return new Response(null, { status: 204 })
}
When Signitize is unavailable, the route answers 503 and Clerk retries the delivery later, so the user is checked once Signitize answers. emailCanonical is a unique, nullable column in your own database.
3. Hold back the perks
Until the webhook has run, emailCheck is missing. Treat that like pending:
import { currentUser } from '@clerk/nextjs/server'
const user = await currentUser()
const canClaimCredits = user?.publicMetadata.emailCheck === 'passed'
Grant free credits, trials and referral rewards only when emailCheck is passed. For flagged users, show why: "Use a permanent email address to unlock your free credits." Don't delete flagged users right away: a real person may be on the other side.
To read the flag from the session token instead of fetching the user, add publicMetadata to your session token in the Clerk dashboard (Sessions > Customize session token).