Skip to content

Filtering fake leads from website forms with a webhook

· 8 min read · Company enrichment

To filter fake leads from website forms, point the form tool's webhook at a small service you control instead of straight at your CRM. The service verifies the webhook signature, looks up the submitter's email, sends spam and vendor pitches to a review folder, and forwards everything else to the CRM with the company data attached. This post builds that service for Tally and Typeform, whose webhook docs both publish a signature scheme you can verify.

What an email check catches on a form

Contact and demo-request forms attract three kinds of junk that the email address alone gives away:

  • Throwaway addresses. Disposable domains such as mailinator.com, used by bots and by people who want the gated PDF but not the follow-up.
  • Machine-generated addresses. Random local parts like ccjxx90sze@ on otherwise normal domains.
  • Vendors pitching you. sales@, outreach@ and similar addresses from agencies and lead-gen shops using your contact form as a cold channel.

It doesn't catch a real person typing a real address into your form and then pasting junk into the message field. Content filtering, a honeypot field and rate limits at the form level handle that. Fake signups: where they come from covers the broader picture.

What Tally and Typeform send

Tally (webhook docs) posts JSON with eventType: "FORM_RESPONSE" and a data.fields array. Each field has key, label, type and value; an email question has type: "INPUT_EMAIL". If you set a signing secret, Tally sends a Tally-Signature header with a base64 HMAC-SHA256. It expects a 2xx within 10 seconds and retries on a schedule that stretches to a day if it doesn't get one.

Typeform (securing webhooks) posts a form_response object whose answers array includes entries like { "type": "email", "email": "laura@example.com", "field": {...} }. With a secret set, the Typeform-Signature header is sha256= followed by the base64 HMAC-SHA256 of the raw body. Typeform allows 30 seconds, retries most failures, and disables the webhook immediately on a 404 or 410.

Two rules follow from that. Verify against the raw request bytes, before any JSON parsing middleware touches them. And answer with a 2xx for every request you've authenticated, spam included: the form tool's retry logic is for delivery failures, not for your filtering decisions.

Routing rules

Lookup result Destination Tag
verdict.is_spam (disposable, randomized, flagged) spam folder verdict.reason
API says the input is invalid (400) spam folder invalid_email
verdict.reason is sales_outreach vendors folder sales_outreach
Anything else CRM category:<category>, plus role_based or system_generated if flagged
Lookup failed or rate-limited twice CRM unchecked

Role addresses like info@ go to the CRM with a tag, not to spam. A small company's only inbox is often info@. Role-based email addresses has more on handling them.

Failures route to the CRM. A spam lead in the CRM wastes a minute of someone's time; a real lead stuck in a spam file can cost the deal.

The service

One Express app, two routes, no dependencies beyond Express. Set a signing secret on both webhooks; the code rejects unsigned requests. Node 20 or later for global fetch and AbortSignal.timeout. The pingutil key, both signing secrets and the CRM endpoint come from environment variables on the server, so nothing sensitive reaches the form tool or the browser.

// server.ts  (Node 20+, Express 4 or 5)
import express from 'express';
import { createHmac, timingSafeEqual } from 'node:crypto';
import { appendFile } from 'node:fs/promises';

const { PINGUTIL_KEY, TALLY_SIGNING_SECRET, TYPEFORM_SECRET, CRM_WEBHOOK_URL } = process.env;

type Lead = { source: 'tally' | 'typeform'; submissionId: string; email: string; raw: unknown };
type Lookup = {
  domain: string;
  category: string;
  company: { name: string | null; industry: string | null; employee_size_estimate: string | null; confidence: number | null } | null;
  verdict: { is_spam: boolean; low_trust: boolean; reason: string | null };
};

// ---------- signature checks ----------

function safeEqual(a: string, b: string): boolean {
  const ab = Buffer.from(a);
  const bb = Buffer.from(b);
  return ab.length === bb.length && timingSafeEqual(ab, bb);
}

function hmacBase64(secret: string, data: Buffer | string): string {
  return createHmac('sha256', secret).update(data).digest('base64');
}

function verifyTally(raw: Buffer, header: string | undefined): boolean {
  if (!header || !TALLY_SIGNING_SECRET) return false;
  // Tally's own example signs JSON.stringify(parsedBody); check that and the raw bytes.
  const reserialized = JSON.stringify(JSON.parse(raw.toString('utf8')));
  return (
    safeEqual(header, hmacBase64(TALLY_SIGNING_SECRET, raw)) ||
    safeEqual(header, hmacBase64(TALLY_SIGNING_SECRET, reserialized))
  );
}

function verifyTypeform(raw: Buffer, header: string | undefined): boolean {
  if (!header || !TYPEFORM_SECRET) return false;
  return safeEqual(header, `sha256=${hmacBase64(TYPEFORM_SECRET, raw)}`);
}

// ---------- payload parsing ----------

function tallyLead(body: any): Lead | null {
  const field = body?.data?.fields?.find((f: any) => f.type === 'INPUT_EMAIL' && f.value);
  if (!field) return null;
  return { source: 'tally', submissionId: body.data.submissionId, email: String(field.value), raw: body };
}

function typeformLead(body: any): Lead | null {
  const answer = body?.form_response?.answers?.find((a: any) => a.type === 'email' && a.email);
  if (!answer) return null;
  return { source: 'typeform', submissionId: body.form_response.token, email: String(answer.email), raw: body };
}

// ---------- classification ----------

const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

async function lookup(email: string, attempt = 1): Promise<Lookup | 'invalid' | null> {
  let res: Response;
  try {
    res = await fetch('https://pingutil.com/v1/lookup', {
      method: 'POST',
      headers: { Authorization: `Bearer ${PINGUTIL_KEY}`, 'Content-Type': 'application/json' },
      // Full lookup: we want the company profile for the CRM, not just the verdict.
      body: JSON.stringify({ input: email }),
      signal: AbortSignal.timeout(15_000),
    });
  } catch {
    return null;
  }
  if (res.status === 429 && attempt === 1) {
    // Off the request path, so waiting is fine. One retry, then give up.
    const body = (await res.json().catch(() => ({}))) as { retry_after_seconds?: number };
    await sleep((Number(body.retry_after_seconds) || 60) * 1000);
    return lookup(email, 2);
  }
  if (res.status === 400) return 'invalid';
  if (!res.ok) {
    console.warn(JSON.stringify({ msg: 'lookup failed', status: res.status }));
    return null;
  }
  return (await res.json()) as Lookup;
}

type Route = { folder: 'crm' | 'spam' | 'vendors'; tags: string[] };

function classify(result: Lookup | 'invalid' | null): Route {
  if (result === 'invalid') return { folder: 'spam', tags: ['invalid_email'] };
  if (result === null) return { folder: 'crm', tags: ['unchecked'] }; // fail open
  const { verdict, category } = result;
  if (verdict.is_spam) return { folder: 'spam', tags: [verdict.reason ?? 'spam'] };
  if (verdict.reason === 'sales_outreach') return { folder: 'vendors', tags: ['sales_outreach'] };
  const tags = [`category:${category}`];
  if (verdict.low_trust && verdict.reason) tags.push(verdict.reason);
  return { folder: 'crm', tags };
}

async function deliver(lead: Lead, route: Route, result: Lookup | 'invalid' | null) {
  const company = result && result !== 'invalid' ? result.company : null;
  const record = {
    email: lead.email,
    source: lead.source,
    submission_id: lead.submissionId,
    tags: route.tags,
    company_domain: result && result !== 'invalid' ? result.domain : null,
    company_name: company?.name ?? null,
    industry: company?.industry ?? null,
    employee_range: company?.employee_size_estimate ?? null,
    enrichment_confidence: company?.confidence ?? null,
  };

  if (route.folder === 'crm') {
    const res = await fetch(CRM_WEBHOOK_URL!, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(record),
    });
    if (!res.ok) throw new Error(`CRM returned ${res.status}`);
    return;
  }
  // Spam and vendor pitches go to a file you can review, never to /dev/null.
  await appendFile(`${route.folder}.jsonl`, JSON.stringify({ ...record, received_at: new Date().toISOString() }) + '\n');
}

async function processLead(lead: Lead) {
  const result = await lookup(lead.email);
  const route = classify(result);
  await deliver(lead, route, result);
  console.log(JSON.stringify({ msg: 'lead routed', source: lead.source, id: lead.submissionId, ...route }));
}

// ---------- HTTP ----------

const app = express();
const rawJson = express.raw({ type: 'application/json', limit: '1mb' });

function handle(
  verify: (raw: Buffer, sig: string | undefined) => boolean,
  header: string,
  parse: (body: unknown) => Lead | null,
) {
  return (req: express.Request, res: express.Response) => {
    const raw = req.body as Buffer;
    let ok = false;
    try {
      ok = Buffer.isBuffer(raw) && verify(raw, req.get(header));
    } catch {
      ok = false;
    }
    if (!ok) {
      res.status(401).send('bad signature');
      return;
    }
    const lead = parse(JSON.parse(raw.toString('utf8')));
    // Acknowledge first. The form tool only needs a 2xx; enrichment can take seconds.
    res.status(200).send('ok');
    if (!lead) return;
    processLead(lead).catch((err) =>
      // Log enough to replay by hand. Use a queue if losing a lead is unacceptable.
      console.error(JSON.stringify({ msg: 'lead processing failed', lead: { ...lead, raw: undefined }, err: String(err) })),
    );
  };
}

app.post('/webhooks/tally', rawJson, handle(verifyTally, 'tally-signature', tallyLead));
app.post('/webhooks/typeform', rawJson, handle(verifyTypeform, 'typeform-signature', typeformLead));

app.listen(Number(process.env.PORT ?? 3000));

CRM_WEBHOOK_URL is whatever accepts a JSON lead in your stack: an automation-tool webhook, an internal service, or a CRM's own inbound endpoint. If your CRM is HubSpot, enriching HubSpot contacts with a workflow webhook shows the HubSpot side.

Why it's built this way

Acknowledge, then process. The handler returns 200 as soon as the signature checks out, then classifies in the background. That keeps you inside Tally's 10-second window even when the lookup is slow.

Full lookup, not skip_enrichment. For a signup check you'd pass skip_enrichment: true and get a verdict in milliseconds. Here you want the company profile too, so the request runs the full lookup. For a domain pingutil hasn't seen, that includes an LLM enrichment step that takes a few seconds; cached domains come back quickly. If enrichment fails, the API still returns 200 with the category and verdict and sets meta.enrichment_error, so the lead is still routed.

One retry on 429, then fail open. pingutil allows 60 requests a minute per key and returns retry_after_seconds on a 429. A signup flow can't wait a minute; a background webhook job can. The code waits once, then gives up and sends the lead to the CRM tagged unchecked.

Spam goes to a file, not the bin. spam.jsonl and vendors.jsonl are the review queues. Look through them weekly for the first month. If a real buyer shows up there, you'll want to know which signal put them there, which is why every record carries its tags.

Tally's signature is checked two ways. The signature is an HMAC over the payload. Tally's own Express example computes it over JSON.stringify(req.body), the re-serialized body, rather than the raw bytes. The code accepts either, so it keeps working whichever form Tally's payload takes.

The background job can lose a lead. If the process crashes between the 200 and the CRM call, that submission is gone from your side, though it's still in the form tool's response list. The error log keeps enough to replay failures. If that isn't good enough, write the lead to a queue or a table before acknowledging.

What to watch after launch

  • Count leads per destination per week. A sudden jump in unchecked usually means a bad key or a rate-limit problem, not an attack.
  • Spot-check the spam folder. Privacy-relay addresses (Apple Hide My Email, Firefox Relay) and some real non-English names can be flagged as randomized.
  • The disposable list behind the API is community-maintained and synced weekly. A brand-new throwaway domain will reach the CRM until it's added.
  • Company data is LLM-generated, and confidence is the model's own estimate. Don't route on a low-confidence profile without a human look. Routing inbound leads by company size shows a confidence gate.

The full response, including every company field you can map into CRM properties, is in the API reference.

Questions people ask

Can an email check stop all fake form submissions?
No. It catches throwaway domains, machine-generated addresses and vendors pitching you through your own contact form. It does nothing about a real address typed by a person submitting junk, or a message full of links from a valid inbox. Pair it with a honeypot field or a CAPTCHA at the form.
Should the webhook reject spam submissions with an error status?
No. Return a 2xx for every correctly signed request and route the junk yourself. An error status makes the form tool retry, and Typeform disables a webhook that returns 404 or 410.
Where should filtered-out leads go?
Somewhere a person can review them, such as a separate CRM list, a spreadsheet or a file. Filters have false positives, and a deleted lead from a real buyer is unrecoverable.