Express.js Signup Email Verification Tutorial
This tutorial builds a small Express 5 application with a browser form and a server endpoint for signup email verification. The finished app keeps the project key in Node.js, performs one external request, and returns a decision before any user or trial record is created.
Start the Express project
mkdir express-email-check
cd express-email-check
npm init -y
npm install express
mkdir public
Create these files:
express-email-check/
├─ app.mjs
└─ public/
└─ index.html
The tutorial uses a current Node.js release with the built-in fetch and AbortSignal.timeout APIs.
Set the server-only key
Create a project and copy its API key. Start the process with the variable set:
EMAILVERIFIER_API_KEY=replace_with_your_project_key node app.mjs
On Windows PowerShell, use:
$env:EMAILVERIFIER_API_KEY = "replace_with_your_project_key"
node app.mjs
Use the deployment platform's secret manager in production. The browser never sends or receives this key.
Create the server
Add app.mjs:
import express from 'express'
const app = express()
app.use(express.json({ limit: '2kb' }))
app.use(express.static('public'))
app.post('/signup/check-email', async (request, response) => {
const email = typeof request.body?.email === 'string'
? request.body.email.trim()
: ''
if (!email) {
return response
.status(400)
.json({ error: 'Email is required.' })
}
const apiKey = process.env.EMAILVERIFIER_API_KEY
if (!apiKey) {
return response.status(500).json({
error: 'Email verification is not configured.'
})
}
let upstream
try {
upstream = await fetch(
'https://emailverifier.dev/api/v1/verify',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': apiKey
},
body: JSON.stringify({ email }),
signal: AbortSignal.timeout(5000)
}
)
} catch {
return response.status(503).json({
error: 'Email verification is temporarily unavailable.'
})
}
if (!upstream.ok) {
return response.status(503).json({
error: 'Email verification is temporarily unavailable.'
})
}
const result = await upstream.json()
if (result.action === 'block') {
return response.status(422).json({
error: result.signals.includes('disposable_address')
? 'Use a permanent email address.'
: 'Check the email address and try again.',
result
})
}
return response.json({
accepted: true,
needsReview: result.action === 'review',
result
})
})
app.use((error, request, response, next) => {
console.error(error)
response.status(500).json({
error: 'Internal server error.'
})
})
app.listen(3000, () => {
console.log('Open http://localhost:3000')
})
The 2 KB body limit is sufficient for one address. Express 5 forwards rejected async-handler promises to the four-argument error middleware. The Express error-handling guide documents that behavior.
Add the browser form
Create public/index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport"
content="width=device-width, initial-scale=1">
<title>Signup email check</title>
</head>
<body>
<main>
<h1>Check a signup email</h1>
<form id="email-form">
<label for="email">Email</label>
<input
id="email"
name="email"
type="email"
autocomplete="email"
required
>
<button>Check email</button>
</form>
<pre id="result" aria-live="polite"></pre>
</main>
<script>
const form = document.querySelector('#email-form')
const result = document.querySelector('#result')
form.addEventListener('submit', async (event) => {
event.preventDefault()
result.textContent = 'Checking…'
const email = new FormData(form).get('email')
try {
const response = await fetch('/signup/check-email', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email })
})
const body = await response.json()
result.textContent = JSON.stringify(body, null, 2)
} catch {
result.textContent =
'Request failed. Start the server and try again.'
}
})
</script>
</body>
</html>
This page makes one local request to Express. Only the Express route calls emailverifier.dev.
Run the complete app
Start the process with the environment variable set, then open http://localhost:3000. You can test the route directly:
curl http://localhost:3000/signup/check-email \
-H "Content-Type: application/json" \
-d "{\"email\":\"person@example.com\"}"
The REST API reference defines the upstream result fields. The Express endpoint maps them to:
200withneedsReview: falsefor the default allow path.200withneedsReview: truefor role-address, catch-all, typo, or inconclusive review paths.422foraction: block.400for missing input and503when the verification service cannot finish.
Prove the failure behavior
Run these checks before connecting the route to persistence:
curl http://localhost:3000/signup/check-email \
-H "Content-Type: application/json" \
-d "{}"
curl http://localhost:3000/signup/check-email \
-H "Content-Type: application/json" \
-d "{\"email\":\"person@example.com\"}"
Stop the process, remove the environment variable, restart it, and confirm the second command returns a configuration error. In automated tests, mock fetch with fixed deliverable, risky, undeliverable, unknown, timeout, and non-200 fixtures. A permanent test should assert your response mapping rather than a live domain's current classification.
Insert the real signup after the check
Replace the final success response with the existing account-creation call, or call that service from the client after this endpoint accepts the address. The user insert and trial grant should happen after verification, and the grant should be idempotent.
Email verification does not enforce one trial per person. Keep IP and account rate limits, duplicate-account handling, CSRF protection for cookie sessions, and payment or device controls beside this route.
The disposable-email policy guide shows when this Express route should block immediately and when confirmation is enough.
The tutorial is complete when the browser form returns a structured result, missing JSON returns 400, a blocking fixture cannot reach persistence, and the project key never appears in browser code.