Aanand Madhav
Aanand MadhavSenior PM · UX
development23 July 202612 min read

A Zero-Plugin WordPress Lead Pipeline to Google Sheets

A zero-plugin WordPress lead collection setup using an embedded HTML form, Google Apps Script, Google Sheets, and confirmation email.

WordPressGoogle Apps ScriptGoogle SheetsLead CollectionSecurity
Hand-drawn systems illustration of a plain HTML WordPress form passing through Google Apps Script into a protected Google Sheet and confirmation email

Imagine you build a WordPress landing page for a client. Clean design, fast load, one job: capture leads. Every submission needs to land in a Google Sheet the client already lives in, and the person filling it out should get an instant thank-you email.

Easy, right? Install a form plugin, grab a "Google Sheets Connector" add-on, done.

Before I go further, credit where it is due: Bhaskar, my department head at YAMU Media, created the original version of this system. He was the person who first told me that a plain HTML form could write directly to Google Sheets without a connector plugin. Only after he told me it could be done did I build this new WordPress implementation and work through the form submission, deployment, validation, and email details below.

But here's the problem

Every plugin you add is another dependency to maintain, load, and pay for.

The popular route is Contact Form 7 or WPForms plus a paid Sheets connector. That's two plugins minimum, a recurring add-on cost, and a page that loads more scripts than the actual form needs. On a landing page where speed is the whole point, that's backwards.

So I built the entire thing with one HTML block and one Google Apps Script. The form itself is plain HTML embedded in WordPress. There is no form plugin, connector plugin, or connector subscription.

It works great now. But getting the Google Sheets integration to behave was where the real fight happened, and that's what this post is about.

Why do this without a plugin?

  • Less front-end overhead - No form plugin, connector plugin, or their extra scripts.
  • No connector subscription - Apps Script and Google Sheets are included within the limits of your Google account.
  • Full control - The form, the sheet columns, the email, all yours to shape.
  • Fewer compatibility dependencies - Less plugin code to conflict with Elementor or your theme.
  • Portable - The same block drops into any WordPress site, or any HTML site really.

The architecture in one breath

Here's the whole flow before we touch code:

  1. A plain HTML form sits directly inside the WordPress page, using an Elementor HTML widget, a Custom HTML block, or the equivalent field in another page editor.
  2. On submit, it POSTs to a Google Apps Script web app.
  3. The script validates the data, appends a row to Google Sheets, and sends a thank-you email.
  4. The script replies, and the page shows an inline success message.

Sounds simple. Step 3 and step 4 are where I hit four separate walls. Let me walk you through each one, because the fix for each is the actual lesson here.


Wall #1: You can't just read the response

My first instinct was the obvious one. Use fetch() to POST the form data to the Apps Script URL, read the JSON it returns, show success or error.

Here's what happens: the browser blocks your JavaScript from reading the response.

Google Apps Script web apps live on a different origin (script.google.com) than your WordPress site. A normal cross-origin fetch() without the required CORS permission rejects. If you switch to no-cors, the request can be sent, but the response is opaque and unreadable. Either way, a submission may technically work while your page has no trustworthy result to show. You're flying blind.

You can chase CORS headers for an hour. I did. Apps Script makes it painful.

The fix: a hidden iframe + postMessage

Instead of fighting fetch(), I let the form submit the old-school way, into a hidden iframe. The Apps Script then returns a tiny HTML page whose only job is to shout the result back to the parent window using window.top.postMessage().

The WordPress page still renders the form directly as normal HTML. This hidden iframe is only an invisible submission target for the Apps Script response; it does not contain or display the form itself.

The form targets the iframe:

<form id="gs-lead-form" method="post" target="gs-form-response" novalidate>
  <!-- fields here -->
</form>

<!-- The response lands here, invisibly -->
<iframe id="gs-form-response" name="gs-form-response" title="Form submission response" hidden></iframe>

And on the Apps Script side, the response isn't JSON. It's a micro HTML page that posts a message:

function responseHtml_(payload) {
  const json = JSON.stringify(payload).replace(/</g, '\\u003c');
  const targetOrigin = JSON.stringify(CONFIG.formOrigin);
  const html = `<!doctype html>
    <html>
      <head><meta charset="utf-8"></head>
      <body>
        <script>window.top.postMessage(${json}, ${targetOrigin});<\/script>
      </body>
    </html>`;

  return HtmlService
    .createHtmlOutput(html)
    .setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL);
}

CONFIG.formOrigin is the exact WordPress origin, such as https://example.com. Back on the WordPress page, I listen for the message and verify that it came from the hidden response iframe:

const responseFrame = document.getElementById('gs-form-response');

window.addEventListener('message', (event) => {
  const data = event.data;

  // Only trust messages from OUR form, for THIS exact submission
  if (
    !responseFrame ||
    event.source !== responseFrame.contentWindow ||
    !data ||
    data.source !== 'gs-contact-form' ||
    data.submissionId !== activeSubmissionId
  ) {
    return;
  }

  setLoading(false);

  if (data.success) {
    form.reset();
    showStatus('success', 'Thank you! Your message has been received.');
    return;
  }

  showStatus('error', data.message || 'Something went wrong. Please try again.');
});

Notice the submissionId and event.source checks. Every submission generates a unique ID (a crypto.randomUUID()), sent along with the form and echoed back in the response. The submission ID rejects stale responses, while event.source confirms that the message came from the iframe this form submitted into. On the sending side, the exact targetOrigin stops the response page from posting its result to an unexpected parent site.

This one pattern is the heart of the whole build. Once the page and the script could actually talk, everything else fell into place.


Wall #2: The iframe response was blank

Solved the response, wired up postMessage, and... nothing happened. The iframe loaded, but the message never fired.

The reason: Google was refusing to let its own response render inside my iframe.

By default, Apps Script sends an X-Frame-Options header that blocks the page from being embedded anywhere. So the browser loads it, sees "do not frame me," and silently kills it. No message, no error, no clue.

The fix: one line

You saw it above, but it deserves its own spotlight because it's easy to miss:

return HtmlService
  .createHtmlOutput(html)
  .setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL); // this line

ALLOWALL tells Apps Script it's fine to render this response inside a frame on another site. Without it, the hidden-iframe trick just doesn't work. One line. An hour of my life.

It also removes Apps Script's default framing protection. Keep this response document inert, target only the exact WordPress origin, and never put controls, credentials, or sensitive content inside the framed response page.


Wall #3: I was POSTing to the wrong URL

This one is embarrassing and extremely common, so I'm calling it out loud.

When you're building an Apps Script, the editor gives you a test URL ending in /dev. It's tempting to paste that into your form because it's right there. Don't.

The /dev URL is only available to users who have edit access to the script. Your website visitors do not. To them, it fails or asks them to sign in.

A Google Drive folder link or a raw Sheet URL doesn't work either. A Sheet URL cannot receive a form POST. It's not an endpoint, it's a document.

The fix: deploy it, use the /exec URL

You have to actually deploy the script as a web app. In the Apps Script editor:

  1. Click Deploy → New deployment.
  2. Type: Web app.
  3. Execute as: Me (so the script has permission to write to your sheet).
  4. Who has access: Anyone (so visitors who are not logged into Google can submit). Google Workspace administrators can restrict whether this public option is available.
  5. Deploy, then copy the URL. It ends in /exec, not /dev.
// In your form's JavaScript, use the DEPLOYED url
const GOOGLE_APPS_SCRIPT_URL = 'https://script.google.com/macros/s/YOUR_DEPLOYMENT_ID/exec';

Every time you change the script, redeploy. A common gotcha: you edit the code, test, and nothing changes, because the live /exec URL still points at the old version. Manage your deployment and update it, or create a new one.


Wall #4: Making the Google Sheet write safe

By now data was landing in the sheet. But "it writes" and "it writes safely" are two different things. Four problems showed up the moment I imagined real, messy, and sometimes hostile traffic.

Problem A: Spreadsheet formula injection

If someone types =IMPORTXML(...) or +1+1 into a form field, Google Sheets will happily treat it as a formula when it lands in a cell. That's a real security hole. It can pull data, break the sheet, or worse.

The fix is to neutralize any value that starts with a formula trigger character by prefixing it with an apostrophe, which forces Sheets to treat it as plain text:

function safeSheetValue_(value) {
  const text = String(value || '');
  // If it starts with = + - or @, it becomes text, not a formula
  return /^[=+\-@]/.test(text) ? `'${text}` : text;
}

Every value gets run through this before it's written. Never trust form input in a spreadsheet.

Problem B: Two people submitting at the same time

Apps Script can run your doPost more than once at the same moment. If two submissions hit while the script is creating the header row or appending, you can get duplicated headers or a mangled write.

The fix is LockService. Grab a lock, do the write, release it:

function saveSubmission_(data) {
  const lock = LockService.getScriptLock();
  lock.waitLock(10000); // wait up to 10s for a turn

  try {
    const spreadsheet = SpreadsheetApp.openById(CONFIG.spreadsheetId);
    let sheet = spreadsheet.getSheetByName(CONFIG.sheetName);

    if (!sheet) {
      sheet = spreadsheet.insertSheet(CONFIG.sheetName);
    }

    // Only create the header row once
    if (sheet.getLastRow() === 0) {
      sheet.appendRow(HEADERS);
      sheet.setFrozenRows(1);
    }

    if (isDuplicate_(sheet, data.submissionId)) {
      return; // already saved, skip it
    }

    sheet.appendRow([
      new Date(),
      safeSheetValue_(data.submissionId),
      safeSheetValue_(data.firstName),
      safeSheetValue_(data.email),
      safeSheetValue_(data.message),
      // ...rest of the fields
    ]);
  } finally {
    lock.releaseLock(); // always release, even if it errored
  }
}

Problem C: Duplicate submissions

Networks are flaky. A user double-clicks, or the request retries, and you get the same lead twice in your sheet.

Because every submission carries a unique ID, I check for it before writing. If it's already in the sheet, I skip:

function isDuplicate_(sheet, submissionId) {
  const lastRow = sheet.getLastRow();
  if (lastRow < 2) return false;

  // Search the Submission ID column for this exact ID
  const result = sheet
    .getRange(2, 2, lastRow - 1, 1)
    .createTextFinder(submissionId)
    .matchEntireCell(true)
    .findNext();

  return Boolean(result);
}

That same submissionId is doing triple duty now: it matches the response to the request, it de-dupes the sheet, and it's a clean record ID. Cheap to generate, worth its weight.

Problem D: A public endpoint attracts junk

An Apps Script web app deployed for anonymous visitors can be called directly, without loading your WordPress page first. Client-side validation alone does not protect it.

Validate every expected field again inside doPost: required values, email format, allowed options, and maximum lengths. Reject unknown fields. Add a hidden honeypot and a minimum completion time for basic bot filtering. For a form that attracts persistent abuse, add server-side throttling and a CAPTCHA whose token is verified by the script before the Sheet write or email send.


The bonus: an instant thank-you email

Since the script already has the submitter's email and runs on Google's infrastructure, sending a confirmation is included within the account's Apps Script quota. MailApp handles it:

MailApp.sendEmail({
  to: data.email,
  subject: 'Thank you for your message',
  body: plainBody,     // plain-text fallback
  htmlBody: htmlBody,   // your branded HTML version
  name: CONFIG.websiteName,
  replyTo: CONFIG.replyToEmail,
});

Two things I learned the hard way here:

  • Escape user input in the HTML email. Same logic as the sheet, different reason. Run names and messages through an HTML-escape function so a <script> in a message field can't do anything nasty in your inbox.
  • MailApp has a daily quota. At the time of writing, Google lists 100 email recipients per day for consumer accounts and 1,500 for Google Workspace accounts. These limits can change, so check the quota page for the current numbers. Deliverability also depends on your sending domain's authentication.

The Sheet now contains personal information, so treat it like a lead database rather than a casual document. Put a clear privacy notice beside the form, collect only the fields you need, restrict Sheet access, and define when old submissions are deleted.


Test your setup

Before you call it done, run through this:

  • Submit the form. A new row appears in Google Sheets shortly afterward.
  • Confirm that the row has a timestamp, a submission ID, and every field in the right column.
  • Confirm that the thank-you email arrives in the inbox you submitted with.
  • Type =1+1 into a field and submit. The sheet should show the literal text =1+1, not 2.
  • Double-click submit quickly. You should get one row, not two.
  • Submit an empty, oversized, or honeypot-filled request and confirm that the script rejects it before writing or sending email.
  • Open the page in an incognito window while logged out of Google. It should still work. If it does not, check the deployment access setting and any Workspace administrator restrictions.

Final thoughts

The plugin route feels easier for about ten minutes, until you're paying for a connector and debugging a conflict with your page builder. Going direct with Apps Script takes more upfront thinking, but you end up with a leaner form, no connector subscription, and full control over the flow.

The four walls, boiled down:

  1. Can't read the cross-origin response → hidden iframe + postMessage, matched by a unique submission ID.
  2. Iframe response blanksetXFrameOptionsMode(ALLOWALL).
  3. Submissions failing for visitors → deploy the web app, use the /exec URL, redeploy on every change.
  4. Unsafe public writes → validate on the server, neutralize formula characters, lock concurrent writes, de-dupe by ID, and limit abuse.

Get those four right and you have a lead pipeline with no connector subscription and a failure surface you can actually understand.

AM
Aanand Madhav

Senior PM and UX Expert with 9 years of experience shipping products across fintech, ed-tech, ecommerce, and government sectors. Leads UX and development at YAMU Media and runs MediaMen Services.

Get in touch →