Skip to content

Verify form leads before they reach your CRM

· 4 min read · Email validation

To verify form leads before they reach your CRM, send the form's webhook to a small serverless function instead of straight to the CRM. The function checks the email with a verification API, creates the contact only when the address is valid or risky (with the status stored on the record), and routes invalid addresses to a review log. Sales never sees a bounced first touch from a lead that typed gmial.com.

This is the verification half of a lead pipeline. If you also want to filter vendor pitches and disposable domains by category, filtering fake form leads with a webhook covers that, including webhook signature checks for Tally and Typeform.

Why verify between the form and the CRM

Most form tools can post straight into a CRM. That's convenient until bad addresses start costing you:

  • Every invalid address becomes a contact someone may try to call or sequence.
  • Sequences to those contacts bounce, and bounces count against the sending domain your sales team relies on.
  • Junk contacts inflate pipeline reports and lead-source numbers.

A function in the middle fixes all three at the one point every lead passes through.

The flow

  1. Form tool fires a webhook on submission. Webflow sites can register a webhook with the form_submission trigger, which includes the submitted data. Typeform and Tally have equivalents.
  2. Function checks the signature if your form tool signs requests. Webflow sends x-webflow-signature and x-webflow-timestamp headers for webhooks created through the API.
  3. Function verifies the email with POST https://pingutil.com/v1/verify.
  4. Function routes the lead based on status.
  5. Function always returns 200 to the form tool, so it doesn't retry a delivery you've already handled.

Routing rules

Status Action Why
valid Create the contact. Mailbox exists.
risky Create the contact with the status and reason recorded. Catch-all domains and role addresses are often real leads. Let sales decide.
unknown Create the contact, flagged for a re-check. The mail server wouldn't answer. Dropping a real buyer over greylisting is worse than one extra contact.
invalid Don't create. Log for review. Nonexistent mailbox, no mail server, disposable domain or broken syntax.

risky matters most on B2B forms: some company domains accept mail for any address (catch-all), and pingutil reports those as risky / accept_all rather than pretending they're verified. Email verification statuses explained has every reason code.

The function

This is a Cloudflare Worker, but it's a standard fetch(request) handler, so it ports to other serverless platforms with little change. It creates contacts with HubSpot's CRM contacts API (POST /crm/v3/objects/contacts) using a private app token. Swap createContact for your CRM's equivalent.

// worker.js
export default {
  async fetch(request, env) {
    if (request.method !== 'POST') return new Response('method not allowed', { status: 405 });

    const raw = await request.text();
    // Verify the form tool's signature on `raw` here before trusting it.
    const body = JSON.parse(raw);
    const email = findEmail(body);
    if (!email) return Response.json({ ok: true, skipped: 'no email' });

    const result = await verify(email, env.PINGUTIL_KEY);

    if (result?.status === 'invalid') {
      console.log(JSON.stringify({ rejected: email, reason: result.reason, did_you_mean: result.did_you_mean }));
      return Response.json({ ok: true, routed: 'review' });
    }

    await createContact(env.HUBSPOT_TOKEN, {
      email,
      // Custom contact properties: create them in HubSpot first.
      email_verification_status: result?.status ?? 'unknown',
      email_verification_reason: result?.reason ?? 'verification_failed',
    });
    return Response.json({ ok: true, routed: 'crm', status: result?.status ?? 'unknown' });
  },
};

// Form payloads differ per tool, so look for the first string that looks like an email.
function findEmail(value) {
  if (typeof value === 'string') return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim()) ? value.trim() : null;
  if (value && typeof value === 'object') {
    for (const v of Object.values(value)) {
      const found = findEmail(v);
      if (found) return found;
    }
  }
  return null;
}

async function verify(email, key) {
  try {
    const res = await fetch('https://pingutil.com/v1/verify', {
      method: 'POST',
      headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json' },
      body: JSON.stringify({ email }),
      signal: AbortSignal.timeout(20_000),
    });
    if (!res.ok) return null; // 429 or outage: fail open, the lead still reaches the CRM
    return await res.json();
  } catch {
    return null;
  }
}

async function createContact(token, properties) {
  const res = await fetch('https://api.hubapi.com/crm/v3/objects/contacts', {
    method: 'POST',
    headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
    body: JSON.stringify({ properties, associations: [] }),
  });
  if (!res.ok) console.log('hubspot error', res.status, await res.text());
}

Set the secrets with wrangler secret put PINGUTIL_KEY and wrangler secret put HUBSPOT_TOKEN.

A few decisions baked into that code:

  • Fail open. If the verification call times out or is rate limited, the lead goes to the CRM marked unknown. A verification outage shouldn't lose you a demo request.
  • findEmail is deliberately generic. If your form has more than one email field (say, "your email" and "your manager's email"), replace it with an explicit lookup of the field you want.
  • Duplicates. The sketch only creates contacts and logs any error HubSpot returns. If the same person submits twice, handle it the way the rest of your HubSpot setup handles existing contacts.
  • Typo suggestions. did_you_mean is logged for invalid addresses so someone can fix and re-add the lead. Better still, show it in the form itself. See catching email typos at signup.

Volume and limits

Form traffic is usually well under the API's limits: 60 requests a minute per account and 300 verifications a day. If one form gets more than that, or you're backfilling old leads, split the backfill into a separate job using the batch endpoint, as in the Node.js verification tutorial.

Try a few of your recent form submissions in the verification demo to see how many would have been routed to review, then check the fields in the API reference.

Questions people ask

Should I verify leads in the form or after submission?
Both, if you can. A check in the form lets the person fix a typo while they're still on the page. A check after submission, between the form and the CRM, protects the CRM from anything the form check missed or anything submitted by bots that skip your front end.
What should happen to leads with an invalid email?
Keep them out of the CRM and out of sequences, but log them somewhere a person can review. A lead with a mistyped address may still be a real buyer you can reach another way.
Do I need to write code to verify form leads?
Not necessarily. Automation tools can call a verification API between a form trigger and a CRM action. A small function gives you more control over retries and routing, and costs very little to run.