RG Forms logoRG Forms
Google Sheets

Send contact form submissions to a Google Sheet

A spreadsheet is an underrated place for form submissions to land. It’s sortable, filterable, shareable, chartable, exportable, and everyone on your team already knows how to use it — which is more than most form dashboards can claim.

There are two ways to get there.

Last updated August 19, 2026

The short answer

To send website contact form submissions to a Google Sheet, you need a Google Apps Script web app bound to that sheet, deployed to run as you and be callable by anyone, with your form POSTing JSON to its /exec URL. You can write and deploy that script by hand, or RG Forms can create the whole thing — Drive folder, Sheet with a matching header row, script, and deployment — inside your own Google account in about ninety seconds. Either way the sheet, the script and the data belong to you, and Google hosts it at no cost.

The DIY route, honestly described

Doing this by hand is a real option, and it’s worth understanding because it’s exactly what RG Forms automates. You create a Sheet, open Extensions → Apps Script, write a doPost(e) that parses e.postData.contents and calls appendRow, then deploy it as a web app with Execute as: Me and Who has access: Anyone. Google gives you an /exec URL, and your form POSTs to it.

The parts that trip people up: getting the deployment access setting right (anything stricter and anonymous visitors get a login page instead of your endpoint), remembering that application/json triggers a CORS preflight Apps Script can’t answer, keeping the header row and the payload keys in sync by hand, and re-deploying a new version every time you edit the script — editing alone doesn’t change what the live URL runs.

None of it is hard. It’s just fiddly enough that most people get it working once, then never want to do it again for the next site.

The two-minute route

RG Forms does the same thing through the Google APIs, from your browser, with your own OAuth token. It creates a Drive folder, a Sheet whose header row matches the fields you defined, a bound Apps Script project, and a web app deployment — then hands you the endpoint URL.

The difference worth knowing about is the `_manifest` tab: a hidden tab in your sheet holding the project’s configuration as JSON. The script reads it on every request, so adding a form, renaming a field, changing the notification address or toggling spam protection takes effect immediately — no redeploy, and the endpoint URL never changes.

What your sheet looks like

One tab per form, one row per submission, one column per field:

submitted_atnameemailmessage
2026-08-19T14:02:11.480ZAda Lovelaceada@example.comDo you take on small projects?
2026-08-19T16:41:55.002ZGrace Hoppergrace@example.comSending over the brief now.

Column keys come from your field labels — lowercased, with any run of non-alphanumeric characters collapsed to _. So “Company Name” becomes company_name. That’s the key you send in fields.

Posting to it

index.html
<form id="contact-form">
  <label>Name <input name="name" required /></label>
  <label>Email <input type="email" name="email" required /></label>
  <label>Message <textarea name="message" required></textarea></label>

  <!-- Honeypot: humans never see it, bots fill it in -->
  <input type="text" name="_hp" tabindex="-1" autocomplete="off"
         style="position:absolute;left:-9999px" aria-hidden="true" />

  <button type="submit">Send</button>
  <p id="form-status" role="status"></p>
</form>

<script>
  const ENDPOINT = "https://script.google.com/macros/s/AKfycb.../exec";
  const form = document.getElementById("contact-form");
  const status = document.getElementById("form-status");

  form.addEventListener("submit", async (event) => {
    event.preventDefault();
    status.textContent = "Sending…";

    const res = await fetch(ENDPOINT, {
      method: "POST",
      // text/plain avoids the CORS preflight Apps Script can't answer
      headers: { "Content-Type": "text/plain" },
      body: JSON.stringify({
        tab: "contact",
        fields: Object.fromEntries(new FormData(form)),
      }),
    });

    const data = await res.json();
    status.textContent =
      data.result === "success" ? "Thanks — we'll be in touch." : "Something went wrong.";
    if (data.result === "success") form.reset();
  });
</script>

Common questions

Do I need to write any Apps Script code?

No. RG Forms generates and deploys it. But the code is right there in your account if you want to read it or extend it — add a Slack ping, write to a second sheet, whatever you need.

Why does Google warn me that the app isn’t verified?

Because the “app” in that dialog is your own script, created minutes ago in your own account. Google shows that screen for any Apps Script requesting sensitive permissions that hasn’t been through its verification program — which every personal script has in common. Click Advanced, then continue, then Allow.

Can several forms share one sheet?

Yes, and that’s the intended shape: one project per website, one tab per form, one endpoint URL for all of them. You choose the destination with the tab value in your request.

Is there a submission limit?

Nothing meaningful for a contact form. Google Sheets holds up to 10 million cells, and Apps Script’s free quotas are far above typical contact-form traffic. The one real cap is email: about 100 recipients/day on free Gmail, 1,500 on Workspace — and if you hit it, rows still save.

More answers in the full FAQ.

Your endpoint goes live in ~90 seconds