emailverifier.dev
Posts

Express.js Signup Email Verification Tutorial

| 4 min read | Usama Ejaz
Express.js signup route cover with an email inside API brackets and a check mark.

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:

  • 200 with needsReview: false for the default allow path.
  • 200 with needsReview: true for role-address, catch-all, typo, or inconclusive review paths.
  • 422 for action: block.
  • 400 for missing input and 503 when 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.

Continue reading