I replaced Zapier with one webhook and a Google Sheet

April 5, 2026 · 6 min read

Warm server lights — a self-hosted webhook replacing a SaaS automation

My Zapier bill hit $49/month last quarter and I made a face I couldn't undo.

It wasn't the money exactly. $49 is fine. It was looking at what I was paying $49 for: three Zaps, each doing roughly the same thing — "when a form is submitted, put it in a spreadsheet and ping Slack." One of them had been running for two years. None of them had been touched in at least eight months. All three were using a combined total of about 40 tasks a month out of my 750-task plan, which means I was paying roughly $1.20 per actual automation.

I spent a weekend replacing the whole thing with a single Google Sheet, an Apps Script web app, and a webhook. It took about ninety minutes. It has been running for four months. The cost is zero.

Here's the whole thing, so you can do the same if you want.

What I was doing before

Three Zaps, one per form on my site:

  1. Contact form → Zapier → Google Sheet + Slack message
  2. Newsletter signup → Zapier → Google Sheet + ConvertKit
  3. "Book a call" → Zapier → Google Sheet + Calendar invite

The common denominator was always "append a row to a spreadsheet, then do one other thing." That's not a workflow, that's a function call with extra steps.

The replacement, in one paragraph

Point every form at a FormTo endpoint. Configure the endpoint's webhook to post to a Google Apps Script web app URL. The Apps Script reads the payload, appends a row to the right sheet tab based on which form it was from, and optionally fires a Slack message. That's the entire architecture.

The Apps Script

Open any Google Sheet you want to receive submissions. Go to Extensions → Apps Script. Replace the contents of Code.gs with this:

const SLACK_WEBHOOK = 'https://hooks.slack.com/services/your/webhook/here'

function doPost(e) {
  const body = JSON.parse(e.postData.contents)
  const formName = body.form || 'default'

  const sheet = SpreadsheetApp
    .getActiveSpreadsheet()
    .getSheetByName(formName) || SpreadsheetApp
      .getActiveSpreadsheet()
      .insertSheet(formName)

  if (sheet.getLastRow() === 0) {
    sheet.appendRow(['Timestamp', 'Name', 'Email', 'Message', 'Raw'])
  }

  sheet.appendRow([
    new Date(),
    body.name || '',
    body.email || '',
    body.message || '',
    JSON.stringify(body),
  ])

  if (formName === 'contact') {
    UrlFetchApp.fetch(SLACK_WEBHOOK, {
      method: 'post',
      contentType: 'application/json',
      payload: JSON.stringify({
        text: `New contact form: *${body.name}* (${body.email})\n> ${body.message}`,
      }),
    })
  }

  return ContentService
    .createTextOutput(JSON.stringify({ ok: true }))
    .setMimeType(ContentService.MimeType.JSON)
}

Save. Click "Deploy" → "New deployment" → type "Web app." Set "Who has access" to "Anyone" and deploy. Google will give you a URL that looks like https://script.google.com/macros/s/.../exec.

Copy that URL. That's your new webhook endpoint.

Wiring it up

In your FormTo dashboard, open the form settings and paste the Apps Script URL into the webhook field. Save. That's it.

Every submission now flows:

browser → FormTo endpoint → Google Apps Script → Sheet + Slack

No Zapier account, no Make subscription, no n8n instance to host. The Apps Script runs on Google's infrastructure for free up to something like 20,000 executions a day, which is enough to handle every form I will ever ship.

What about forms with different shapes?

The example above handles name, email, and message. Most contact forms. If you have forms with different fields — a signup form, a feedback form, a "book a call" form — the Apps Script can handle all of them in the same endpoint, by branching on body.form and writing to a different sheet tab.

Here's the upgraded version:

function doPost(e) {
  const body = JSON.parse(e.postData.contents)
  const formName = body.form || 'default'

  const handlers = {
    contact: handleContact,
    newsletter: handleNewsletter,
    booking: handleBooking,
  }

  const handler = handlers[formName] || handleDefault
  handler(body)

  return ContentService
    .createTextOutput(JSON.stringify({ ok: true }))
    .setMimeType(ContentService.MimeType.JSON)
}

function handleContact(body) {
  appendRow('contact', [new Date(), body.name, body.email, body.message])
  pingSlack(`New contact: ${body.name} (${body.email})`)
}

function handleNewsletter(body) {
  appendRow('newsletter', [new Date(), body.email, body.source || ''])
}

function handleBooking(body) {
  appendRow('booking', [new Date(), body.name, body.email, body.time])
  pingSlack(`New call booked: ${body.name} at ${body.time}`)
}

function appendRow(sheetName, row) {
  const ss = SpreadsheetApp.getActiveSpreadsheet()
  const sheet = ss.getSheetByName(sheetName) || ss.insertSheet(sheetName)
  sheet.appendRow(row)
}

function pingSlack(text) {
  UrlFetchApp.fetch('https://hooks.slack.com/services/your/webhook/here', {
    method: 'post',
    contentType: 'application/json',
    payload: JSON.stringify({ text }),
  })
}

Every form gets its own handler. Each handler writes to the sheet it cares about and optionally pings Slack. You can add new handlers in three lines. You'll never look at this file again unless you add a new form.

To tell FormTo which form is which, add a hidden input to each form:

<input type="hidden" name="form" value="contact">

Or configure the webhook payload in the dashboard to include the form slug.

What I gave up

This is the honest section. Zapier and Make do things my setup does not.

  • Built-in retries. If my Apps Script endpoint is down, FormTo's webhook layer will still retry (that's the point of picking a backend with real retry logic), but the Apps Script layer itself is one-shot. If Google has an incident, I lose that submission's downstream work — though FormTo still has the raw data stored for me to replay manually.
  • Pretty error dashboards. Apps Script's error logs are functional but ugly.
  • Third-party app marketplace. Zapier has thousands of integrations I didn't need but might have wanted someday. Apps Script can call any HTTP API but I have to write the code.
  • Non-technical editability. A teammate who doesn't code can edit a Zap through a UI. They cannot edit an Apps Script. If your team needs non-technical automation ownership, Zapier is genuinely the right answer.
  • Multi-step branching. Complex Zaps with filters and conditional branches are tedious to replicate in code. For "append row + ping Slack" they're overkill, but for "if user is from US and mentions Enterprise, route to AE, else route to support," Zapier is still easier.

If any of those apply to you, keep paying the bill. I'm not trying to talk you into a worse setup.

What I got

  • $588/year back. ($49 × 12)
  • A file I own. The Apps Script is mine. I can read it, version it, copy it to another project, fork it for a client.
  • One fewer SaaS dependency. Every SaaS you can remove from the "critical path if this dies my business dies" list is a small win for night-time peace of mind.
  • Better latency. Zapier's free-tier and cheap-tier plans can take 15 minutes to fire a Zap. My Apps Script runs in about 1.2 seconds from FormTo's webhook delivery. Not a competition.
  • Debuggability. When something weird happens, I can read the code and see exactly why. Not always possible with a Zap.

The mental shift

Here's the part I want to leave you with. A lot of SaaS automation tools are sold as "no code" but what they actually mean is "no code you can read." You're writing code — you're just writing it in a proprietary drag-and-drop editor, with proprietary triggers, proprietary functions, and proprietary limits, for a monthly fee that scales with usage.

For most form-to-spreadsheet-to-Slack workflows, that's the world's worst deal. The actual code is forty lines of JavaScript. The hard parts — authentication, hosting, running on a schedule, retries — are handled by the services you already have.

You do not need Zapier to append a row to a spreadsheet. You needed Zapier in 2015, before webhooks were universal and before Apps Script was free. You don't need it now.

Delete the Zap. Keep the sheet. Write the fifty lines.


The piece that makes this work is having a form backend that exposes a real webhook with retries and logs. FormTo does that on every plan, including the free one. Paste the Apps Script URL, point your forms at it, and enjoy watching the Zapier bill disappear.

And if you want to see a full four-minute setup for any stack, start with the Astro walkthrough or the version for your framework of choice.

← All posts