DEV Community

Dovetail Scripts
Dovetail Scripts

Posted on

Keep bots out of your Google Sheet lead form (honeypot + time trap)

The moment a lead form is public, bots start posting to it. In the webhook post I used a shared token, which stops drive-by scripts but not a bot that reads your page source. Here are two free additions that catch almost everything else: a honeypot field and a time trap. No CAPTCHA, no third-party service.

The form side

Add two things to the form from the webhook post:

<form action="YOUR_EXEC_URL" method="POST">
  <input name="name" placeholder="Your name">
  <input name="email" type="email" required placeholder="you@company.com">

  <!-- Honeypot: invisible to humans, irresistible to bots -->
  <input name="company" tabindex="-1" autocomplete="off"
         aria-hidden="true" style="position:absolute;left:-9999px">

  <input type="hidden" name="token" value="change-me">
  <input type="hidden" name="t" id="t">
  <button type="submit">Send</button>
</form>
<script>
  document.getElementById('t').value = Date.now();
</script>
Enter fullscreen mode Exit fullscreen mode

Two details matter. Hide the honeypot with off-screen CSS, not type="hidden", because many bots skip hidden inputs. And give it a tempting name like company or website, since bots fill every field they can see. The aria-hidden and tabindex="-1" keep screen readers and keyboard users out of it.

The script side

Extend the doPost from the webhook post:

const MIN_SECONDS = 3;

function doPost(e) {
  const p = e.parameter;
  if (p.token !== SECRET) {
    return json({ ok: false, error: 'bad token' });
  }
  // Honeypot: a human never fills this.
  if (String(p.company || '') !== '') {
    return json({ ok: true }); // pretend it worked, save nothing
  }
  // Time trap: real people need a few seconds to type an email.
  const t = Number(p.t || 0);
  if (!t || (Date.now() - t) < MIN_SECONDS * 1000) {
    return json({ ok: true }); // same fake success
  }
  const email = String(p.email || '').trim();
  if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
    return json({ ok: false, error: 'bad email' });
  }
  SpreadsheetApp.getActiveSpreadsheet().getSheetByName('Leads')
    .appendRow([new Date(), safe(p.name), safe(email), '', '']);
  return json({ ok: true });
}
Enter fullscreen mode Exit fullscreen mode

Notice both bot rejections return ok: true. Telling a bot exactly why it failed is free debugging help, so give it a fake win and move on.

Why this works, and where it stops

  • Bots fill every field; humans never see the honeypot. That alone catches most form spam.
  • Bots submit instantly; humans take a few seconds. The time trap catches the ones clever enough to skip odd fields.
  • A determined human spammer still gets through. At lead-form volumes, that is a reply-to-the-weird-email problem, not a code problem.

If you want one more layer, dedupe in the sheet: before appendRow, scan the email column and skip repeats. Keeps the sheet clean when someone double-submits.


This sits on top of the earlier pieces: the webhook that catches leads, the auto-reply that answers them, and the follow-up for the quiet ones. All of it, plus editable templates and a setup guide, is in the LeadReply Kit. The kit is mock-tested sample code, so read it before pointing it at real leads.

Top comments (1)

Collapse
 
tthanh2k6 profile image
Trung Thanh Tran •

Nice, practical setup. One thing worth knowing: t comes from the browser, so a bot that reads the page can simply post t = Date.now() - 10000 and pass the time trap. If the form page is served by the same script (doGet + HtmlService), you can issue the timestamp server-side with a signature:

function issueTs() {
  const t = String(Date.now());
  const sig = Utilities.base64EncodeWebSafe(
    Utilities.computeHmacSha256Signature(t, SECRET));
  return { t, sig };
}

// in doPost
const expected = Utilities.base64EncodeWebSafe(
  Utilities.computeHmacSha256Signature(String(p.t), SECRET));
if (p.sig !== expected) return json({ ok: true });
Enter fullscreen mode Exit fullscreen mode

Adding an upper bound too (reject a t older than a few hours) stops a bot from replaying one captured timestamp forever. And if you add the email dedupe, wrap the scan and appendRow in LockService.getScriptLock(), otherwise two near-simultaneous submits can both pass the check.