emailverifier.dev
Posts

HubSpot Email Validation: Check Forms Before Creating Contacts

| 5 min read | Usama Ejaz
Simple form, arrow, and check mark for a HubSpot email validation tutorial.

A custom HubSpot form can validate an email before the submission creates or updates a contact. The important boundary is your server: it holds the emailverifier.dev project key, makes one address check, and forwards only accepted submissions to HubSpot.

The finished flow is:

browser form
    ↓
your POST /api/lead route
    ↓ one email verification request
allow or review ──→ HubSpot Forms submission API
block           ──→ field error, no HubSpot contact

Prepare the two form identifiers

In HubSpot, create a form containing an Email field and an optional First name field. Copy the account ID and the form’s unique ID. This tutorial uses HubSpot’s unauthenticated Forms submission endpoint, which identifies the destination with those two path values.

The example form has no required consent field. If your HubSpot form requires consent, add the matching legalConsentOptions object to the submission body. Do not bypass a required form field or your own privacy obligations.

Next, create an emailverifier.dev account and project and copy the project key.

Create the Express application

Create a directory and add package.json:

{
  "name": "hubspot-email-validation",
  "private": true,
  "type": "module",
  "engines": { "node": ">=22" },
  "scripts": { "start": "node --env-file=.env server.mjs" },
  "dependencies": { "express": "^5.1.0" }
}

Add .env:

EMAILVERIFIER_API_KEY=ev_your_project_key
HUBSPOT_ACCOUNT_ID=12345678
HUBSPOT_FORM_ID=00000000-0000-0000-0000-000000000000
PORT=3000

Install the dependency:

npm install

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>Contact sales</title>
  </head>
  <body>
    <main>
      <h1>Contact sales</h1>
      <form id="lead-form">
        <label>
          First name
          <input name="firstname" autocomplete="given-name">
        </label>
        <label>
          Work email
          <input name="email" type="email" autocomplete="email" required>
        </label>
        <button type="submit">Send</button>
        <p id="message" role="status"></p>
      </form>
    </main>

    <script>
      const form = document.querySelector('#lead-form')
      const message = document.querySelector('#message')

      form.addEventListener('submit', async (event) => {
        event.preventDefault()
        message.textContent = 'Checking…'

        const response = await fetch('/api/lead', {
          method: 'POST',
          headers: { 'content-type': 'application/json' },
          body: JSON.stringify(Object.fromEntries(new FormData(form)))
        })
        const result = await response.json()

        message.textContent = response.ok
          ? 'Thanks. Your request was submitted.'
          : result.error || 'The form could not be submitted.'
      })
    </script>
  </body>
</html>

The browser performs normal type="email" validation. It sends the address to your own route and never receives either secret.

Verify first, then submit to HubSpot

Create server.mjs:

import express from 'express'

const app = express()
const port = Number(process.env.PORT || 3000)
const verifierKey = process.env.EMAILVERIFIER_API_KEY
const accountId = process.env.HUBSPOT_ACCOUNT_ID
const formId = process.env.HUBSPOT_FORM_ID

if (!verifierKey || !accountId || !formId) {
  throw new Error('EMAILVERIFIER_API_KEY, HUBSPOT_ACCOUNT_ID, and HUBSPOT_FORM_ID are required')
}

app.use(express.json({ limit: '10kb' }))
app.use(express.static('public'))

async function verifyEmail(email) {
  const response = await fetch('https://emailverifier.dev/api/v1/verify', {
    method: 'POST',
    headers: {
      'content-type': 'application/json',
      'x-api-key': verifierKey
    },
    body: JSON.stringify({ email }),
    signal: AbortSignal.timeout(5000)
  })

  if (!response.ok) throw new Error(`Verifier returned HTTP ${response.status}`)
  return response.json()
}

async function submitHubSpotForm({ email, firstname }) {
  const url = new URL(
    `/submissions/v3/integration/submit/${accountId}/${formId}`,
    'https://api.hsforms.com'
  )
  const fields = [{ name: 'email', value: email }]
  if (firstname) fields.push({ name: 'firstname', value: firstname })

  const response = await fetch(url, {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({
      fields,
      submittedAt: String(Date.now()),
      context: {
        pageName: 'Contact sales',
        pageUri: 'http://localhost:3000/'
      }
    }),
    signal: AbortSignal.timeout(5000)
  })

  if (!response.ok) {
    const detail = await response.text()
    throw new Error(`HubSpot returned HTTP ${response.status}: ${detail}`)
  }

  return response.json()
}

app.post('/api/lead', async (request, response) => {
  const email = typeof request.body.email === 'string' ? request.body.email.trim() : ''
  const firstname = typeof request.body.firstname === 'string'
    ? request.body.firstname.trim().slice(0, 100)
    : ''

  if (!email || email.length > 320) {
    response.status(400).json({ error: 'Enter an email address.' })
    return
  }

  try {
    const verification = await verifyEmail(email)

    if (verification.action === 'block') {
      response.status(422).json({
        error: verification.signals.includes('disposable_address')
          ? 'Use a long-term email address.'
          : 'Check the email address and try again.',
        verification
      })
      return
    }

    const hubspot = await submitHubSpotForm({ email, firstname })
    response.status(201).json({ submitted: true, verification, hubspot })
  } catch (error) {
    console.error(error)
    response.status(503).json({ error: 'The form is temporarily unavailable. Try again.' })
  }
})

app.listen(port, () => {
  console.log(`Form: http://localhost:${port}`)
})

Run npm start, open http://localhost:3000, and submit the form. A blocked address never reaches HubSpot. An allowed or reviewable address is submitted with the fields expected by the form.

Choose how each status enters the CRM

Verification statusAction in this exampleCRM treatment
deliverableSubmit when action is allow or review.Create or update the contact normally.
riskyBlock disposable addresses and typos when the API recommends block.Do not create the contact until corrected.
undeliverableBlock.Show a field-level correction instead of storing unusable data.
unknownSubmit for this lead form.Keep it eligible for confirmation or manual follow-up.

A high-cost demo request may use a stricter rule for unknown. A low-friction contact form normally should not reject an address merely because the receiving provider withheld mailbox evidence.

Production details that change by form

  • Include every HubSpot field marked required in the form definition.
  • Pass the correct consent object when the form requires consent.
  • Use the real public page URL in context.pageUri.
  • Preserve HubSpot’s 429 as a service failure; do not turn it into an email rejection.
  • Keep both outbound calls behind timeouts and log their HTTP status separately.

HubSpot documents the request body and response for the unauthenticated Forms submission endpoint. The production result-handling guide covers retries, logs, and user messages for the verification request.

Continue reading