ProductsDocsBlogConsultingAboutContactGet Started
Back to BlogDiagram of external services like Stripe, Magento, and Twilio sending webhook requests through Apps Script doPost into a Google Sheet, with an HMAC signature verification step in between
13 min readMageSheet Team

Connect Google Sheets to Any App with Webhooks (Apps Script, No Zapier)

Google WorkspaceApps ScriptWebhooksIntegrationsdoPostREST APIStripeGoogle SheetsEmail

Hey everyone! Welcome back to MageSheet.

We've talked about how to completely overhaul your internal workflows by using Apps Script to build custom HTML interfaces and secure B2B client portals.

But what if you don't even want a user interface? What if you want your Magento store, your HubSpot CRM, or a custom mobile app to push data directly into your Google Sheets silently in the background?

To do this, we need to turn our Google Workspace environment into an API. And in Apps Script, that's done by building Webhooks.

Write this pattern once and you can connect Google Sheets to almost any service that sends a webhook or accepts an API call — without paying for Zapier, Make, or a per-connection integration platform. The ones people wire up most:

Connect Google Sheets to…How
Stripe (payments, refunds)doPost receiver + HMAC verify
Magento / e-commerce ordersdoPost, or pull via the REST API
Twilio / WhatsApp messagesdoPost receiver
Email — SMTP / Mailjet / Mailgunoutbound via UrlFetchApp (or MailApp for simple sends)
Mailchimp / HubSpot / CRMsdoPost for inbound events, UrlFetchApp for outbound
Forms, Typeform, any SaaS eventdoPost receiver

The mechanism is the same every time: a doPost() endpoint receives the data, you verify it's legitimate, and you write it to the Sheet. Here's how it works.

What is a Webhook?

Think of an API as you walking up to a database and asking, "Hey, do you have any new orders?" (Polling). A Webhook, on the other hand, is the database saying, "Here's my phone number. Call me the millisecond a new order arrives."

Setting up a webhook listener in Google Apps Script is arguably the fastest, cheapest, and most reliable way to ingest live data from thousands of enterprise platforms like Magento, Shopify, Zapier, or Stripe without paying for third-party middleware like Make.com or Zapier.

Enter doPost() and doGet()

Apps Script has two magical, reserved functions that listen for incoming HTTP traffic:

  • doGet(e): Listens for incoming HTTP GET requests (usually data appended to a URL, like ?name=yusuf).
  • doPost(e): Listens for incoming HTTP POST requests (usually a hidden payload of JSON data).

If you define these functions and publish your script as a Web App, Google gives you a unique URL. Anytime an external system sends data to that URL, the corresponding function executes automatically!

Step 1: Writing the Webhook Listener

Let's say we have a Magento store and we want to push the customer's email and order total into a Google Sheet every time a successful checkout occurs.

Open Extensions > Apps Script in your blank Google Sheet and write this code:

// Code.gs

// The external system will send a JSON payload via HTTP POST
function doPost(e) {
  
  try {
    // 1. Parse the incoming JSON payload from the request body
    // e.postData.contents holds the raw JSON string sent by the webhook
    const payloadStr = e.postData.contents;
    const orderData = JSON.parse(payloadStr);
    
    // 2. Select the spreadsheet
    const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("Orders");
    
    // 3. Prepare the row data
    const timestamp = new Date();
    const customerEmail = orderData.email || "No Email provided";
    const orderTotal = orderData.total || 0.00;
    const orderId = orderData.order_id || "Unknown";
    
    // 4. Quickly append to the sheet
    sheet.appendRow([timestamp, orderId, customerEmail, orderTotal]);
    
    // 5. Always return a 200 OK response so the sending app knows it succeeded
    return ContentService.createTextOutput(JSON.stringify({ 
      "status": "success", 
      "message": "Order appended!" 
    })).setMimeType(ContentService.MimeType.JSON);
    
  } catch (error) {
    // Handle failures gracefully
    return ContentService.createTextOutput(JSON.stringify({ 
      "status": "error", 
      "message": error.toString() 
    })).setMimeType(ContentService.MimeType.JSON);
  }
}

Step 2: Deploying the Webhook

Code alone isn't enough. We need the physical URL.

  1. Click Deploy > New deployment.
  2. Select type: Web app.
  3. Execute as: Me (Crucial!).
  4. Who has access: Anyone (So Magento, which is unauthenticated to Google, can reach it).
  5. Click Deploy.

You will receive a URL that looks like this: https://script.google.com/macros/s/AKfycby..._aBCD/exec

Copy this URL. This is your secure Webhook Endpoint.

Step 3: Sending Data (Testing)

You don't even need Magento to test this. You can use a tool like Postman, or a simple curl command in your terminal to fire data at your new Google Sheet API.

curl -X POST "https://script.google.com/macros/s/AKfycby..._aBCD/exec" \
     -H "Content-Type: application/json" \
     -d '{"email": "boss@company.com", "total": 450.00, "order_id": "MAG-9912"}'

If you look back at your Google Sheet, the data will instantly appear!

The Resulting Table in Sheets

TimestampOrderIDEmailTotal
10/27/2026 14:32:01MAG-9912boss@company.com$450.00

Securing Your Webhook with HMAC Signatures

The deployment we just walked through is wide open. Anyone with the URL can post arbitrary data to your Sheet. For prototypes that is fine; for anything production-bound, you need authentication.

The standard pattern is HMAC signature verification. The webhook sender computes a signature by hashing the request body with a shared secret, and includes the signature as a header. Your doPost recomputes the same hash and rejects the request if the signatures do not match.

const WEBHOOK_SECRET = PropertiesService.getScriptProperties()
  .getProperty('WEBHOOK_SECRET');

function doPost(e) {
  const signature = e.parameter['X-Webhook-Signature'];
  const expected = computeHmacSha256(e.postData.contents, WEBHOOK_SECRET);

  if (signature !== expected) {
    return ContentService.createTextOutput(
      JSON.stringify({ error: 'Invalid signature' })
    ).setMimeType(ContentService.MimeType.JSON);
  }
  // ... proceed with order processing
}

function computeHmacSha256(payload, secret) {
  const sig = Utilities.computeHmacSha256Signature(payload, secret);
  return sig.map(b => ('0' + (b & 0xff).toString(16)).slice(-2)).join('');
}

Two non-obvious notes: store the secret in PropertiesService.getScriptProperties() (never hardcode), and use constant-time comparison when possible to avoid timing attacks. For most small-business webhooks the timing risk is negligible; for payment webhooks it matters. Stripe and Twilio both publish their exact signature schemes — copy them rather than inventing your own.

Handling Duplicate Webhooks (Idempotency)

Webhook senders retry. If your handler takes 5 seconds and the sender's timeout is 3, the sender assumes failure and resends the same event. Without protection, you log the same Stripe payment two or three times.

The fix is idempotency: track processed webhook IDs in CacheService (or a hidden Sheet for longer retention) and short-circuit duplicates.

function doPost(e) {
  const payload = JSON.parse(e.postData.contents);
  const eventId = payload.id || payload.event_id;

  const cache = CacheService.getScriptCache();
  if (cache.get(`processed:${eventId}`)) {
    return jsonResponse({ status: 'already_processed' });
  }

  processOrder(payload);
  cache.put(`processed:${eventId}`, '1', 3600); // 1 hour TTL
  return jsonResponse({ status: 'success' });
}

Stripe, Twilio, Shopify, and most modern providers always send a unique event ID. Trust the ID, not the timestamp — clocks drift, IDs do not. For long-term replay protection beyond CacheService's 6-hour ceiling, write the ID to a hidden _processed_events sheet and check both layers.

Provider-Specific Webhook Patterns

Different providers send webhooks differently. The shape of doPost(e) changes accordingly:

  • Stripe — Raw JSON body, Stripe-Signature header, requires verification with their signing secret. Read with JSON.parse(e.postData.contents).
  • Twilioapplication/x-www-form-urlencoded, parameters in e.parameter, not e.postData.contents. The body fields are e.parameter.From, e.parameter.Body, etc.
  • Magento — REST event subscriber, JSON body, custom headers from your webapi.xml. Often needs the order entity expanded with ?expand=items to include line items, the same gotcha we covered in our Magento order sync guide.
  • GitHub — JSON body, X-Hub-Signature-256 header for HMAC, X-GitHub-Event header for event type. Many event types — filter by header before parsing the body.
  • Slack — JSON body but requires a response within 3 seconds. Anything heavier must offload work to a queue (a Sheet append plus a separate trigger drain) and return 200 immediately. The 30-second doPost ceiling never gets close.

A small router at the top of your handler is usually enough:

function doPost(e) {
  const provider = detectProvider(e); // checks headers, content-type
  switch (provider) {
    case 'stripe':  return handleStripe(e);
    case 'twilio':  return handleTwilio(e);
    case 'magento': return handleMagento(e);
    default:        return handleGeneric(e);
  }
}

Debugging Webhooks That Silently Fail

The most common Apps Script webhook problem: the sender reports success, your Sheet stays empty, and the script editor shows nothing in its execution history. Three causes account for 90% of these:

  1. Wrong deployment URL. Apps Script gives you both a dev URL (changes every save) and a stable prod URL (created via New deployment). Webhook senders need the prod URL. Check Deploy > Manage deployments and confirm the URL you gave the sender matches.
  2. "Who has access" set to "Only myself". This is the default and silently rejects every external request with a 401. Must be set to "Anyone" for unauthenticated webhook senders. Re-deploy after changing — the existing URL keeps the old setting.
  3. Cold-start latency. The first request after a long quiet period can take 5–8 seconds. Some senders (Slack, Twilio voice callbacks) time out before the response arrives. Pair with a separate "warmup" trigger that hits the URL every 5 minutes during business hours, and the cold-start vanishes.

When debugging, always add console.log(JSON.stringify(e)) as the first line of doPost. The Apps Script Executions panel shows the full event object — headers, parameters, raw body — and reveals which field the sender actually used. The right side of Executions also shows execution time; if a webhook handler runs longer than 25 seconds you are approaching the 30-second hard timeout, and the workaround is the queue pattern documented in our long-running Apps Script jobs guide. For local testing without sending live HTTP, extract the handler body into a pure handleWebhook(payload) function so you can unit-test it — the same separation we recommend in our Apps Script unit testing guide.

Connect Google Sheets to Email (SMTP, Mailjet, Mailgun) Without Zapier

You don't need Zapier, Make, or an SMTP relay to send email from a Google Sheet. Apps Script does it two ways: natively with MailApp / GmailApp (zero setup, zero cost, uses your own Gmail), or via a transactional email API like Mailjet or Mailgun through UrlFetchApp.fetch when you need higher volume and better deliverability. Both run inside the same script that powers your webhooks above.

One honest clarification: Apps Script can't open a raw SMTP socket — there's no host/port/STARTTLS connection to configure. "SMTP-style sending" here means going out through Gmail (MailApp/GmailApp) or hitting a transactional provider's HTTPS API. For nearly every Sheets-to-email job that's an upgrade, not a limitation: no relay credentials to babysit, and deliverability is handled by Google or your ESP.

1. Native send — MailApp (no third party)

This is the simplest path. No API keys, no domain setup. The email goes out through the Google account that owns the script.

function sendFromSheet() {
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("Orders");
  const data = sheet.getDataRange().getValues(); // [timestamp, orderId, email, total]

  data.slice(1).forEach(row => {
    const [, orderId, email, total] = row;
    MailApp.sendEmail({
      to: email,
      subject: `Order ${orderId} confirmed`,
      htmlBody: `<p>Thanks — we received your order <b>${orderId}</b> for $${total}.</p>`
    });
  });
}

Honest caveat — the quota. MailApp.sendEmail is capped per day: roughly 100 recipients/day on consumer Gmail and 1,500/day on Google Workspace accounts. Check what's left with MailApp.getRemainingDailyQuota() before a bulk run. That's plenty for order confirmations, internal alerts, or a small newsletter — but it's a hard ceiling, and the mail comes "from" your own address (good for replies, less ideal for a branded sending domain).

2. Mailjet — UrlFetchApp to the Send API v3.1

When you outgrow the native quota or want a dedicated sending domain, deliverability reporting, and bounce handling, a transactional ESP is worth it. Mailjet authenticates with HTTP Basic auth — your API key as the username, your secret key as the password.

function sendViaMailjet(toEmail, subject, htmlBody) {
  const apiKey = PropertiesService.getScriptProperties().getProperty('MJ_APIKEY');
  const secret = PropertiesService.getScriptProperties().getProperty('MJ_SECRET');

  const payload = {
    Messages: [{
      From: { Email: "orders@yourdomain.com", Name: "Your Shop" },
      To: [{ Email: toEmail }],
      Subject: subject,
      HTMLPart: htmlBody
    }]
  };

  const res = UrlFetchApp.fetch('https://api.mailjet.com/v3.1/send', {
    method: 'post',
    contentType: 'application/json',
    headers: {
      // Basic auth: base64("APIKEY:SECRET")
      Authorization: 'Basic ' + Utilities.base64Encode(`${apiKey}:${secret}`)
    },
    payload: JSON.stringify(payload),
    muteHttpExceptions: true
  });

  Logger.log(res.getResponseCode() + ': ' + res.getContentText());
}

Store the key and secret in Script Properties (Project Settings > Script Properties), never in the code. The muteHttpExceptions: true flag lets you read Mailjet's error body instead of throwing — essential for logging which sends failed.

3. Mailgun — UrlFetchApp to the messages endpoint

Mailgun is the common alternative. It also uses Basic auth, but with the literal username api and your API key as the password, and it expects form fields rather than JSON.

function sendViaMailgun(toEmail, subject, textBody) {
  const apiKey = PropertiesService.getScriptProperties().getProperty('MAILGUN_KEY');
  const domain = 'mg.yourdomain.com'; // your verified Mailgun domain

  const res = UrlFetchApp.fetch(`https://api.mailgun.net/v3/${domain}/messages`, {
    method: 'post',
    headers: {
      Authorization: 'Basic ' + Utilities.base64Encode(`api:${apiKey}`)
    },
    payload: {
      from: `Your Shop <orders@${domain}>`,
      to: toEmail,
      subject: subject,
      text: textBody
    },
    muteHttpExceptions: true
  });

  Logger.log(res.getResponseCode() + ': ' + res.getContentText());
}

When payload is a plain object (not a JSON string), Apps Script sends it as application/x-www-form-urlencoded — exactly what Mailgun's messages endpoint expects. Note Mailgun's EU customers use the api.eu.mailgun.net host instead.

Native vs. transactional ESP — which to use

Use native MailApp when…Reach for Mailjet / Mailgun when…
Under ~100/day (consumer) or ~1,500/day (Workspace)You need higher daily volume
Internal alerts, order confirmations, replies to youYou want a branded sending domain + SPF/DKIM
You want zero setup and zero extra accountsYou need delivery, open, bounce, and spam reporting
Replies should land in your GmailYou need to keep transactional mail off your personal quota

The honest rule: start native. It's free and already wired into the account you have. Only add an ESP when you hit the quota wall or someone asks "did that email actually get delivered?" — that's the question native sending can't answer well.

Want this wired into your stack — a Sheet that emails customers on a new row, or order confirmations through your own Mailjet/Mailgun domain? Tell us what you want connected and we'll build it, fixed-price, running in your own Google account.

Why This Will Change Your Architecture

Once you master doPost(), you unlock real-time digital architecture that costs exactly $0 in server fees.

  • Form submissions from Webflow or WordPress? Push them directly to Sheets.
  • Stripe payment succeeded? Log it via a webhook.
  • Custom internal mobile app? Make it completely Serverless connected straight to Google Workspace.

By bypassing rigid, slow middleware options and coding direct webhooks in Apps Script, you own your logic stream completely.

Want a Specific Integration Built?

If you want a particular connection built — Stripe to Sheets, Magento order sync, a WhatsApp or Twilio webhook, an email/SMTP flow, or anything in the table above — that's exactly what we do.

Get a free build plan: describe what you want connected and you'll get a written, fixed-price plan within 24–48 hours. You own the source code, it runs in your own Google Workspace, and there's no per-connection subscription. For larger e-commerce setups that need bi-directional Magento ↔ Workspace syncing, see the Magento automation playbook.

Frequently Asked Questions

Do I need a separate Google Cloud project for Apps Script webhooks, or does the Sheet-attached script work?

The script attached to a Sheet works fine for most webhook integrations and is what 95% of small-business setups use. You only need a separate Cloud project when you want to call Apps Script from another script (advanced Execution API), use OAuth for outbound auth, or hit Apps Script execution metrics in Cloud Logging. For ingesting Stripe, Magento, Twilio, or HubSpot webhooks the Sheet-attached script is sufficient — you authenticate the webhook sender via your own HMAC signature, not Google's auth layer.

What is the maximum payload size Apps Script doPost can receive?

50 MB request body, which is the same as UrlFetchApp's outbound limit. In practice, no real webhook ever approaches this — Stripe events average 2–5 KB, Magento orders 8–15 KB, Slack message payloads stay under 50 KB. The size limit is more relevant for file-upload webhooks (image uploads via Twilio MMS, attached invoices). For those, store the asset in Drive via DriveApp and write the URL to your Sheet rather than the file itself.

Can my Apps Script webhook respond fast enough for time-sensitive providers like Slack (3-second deadline)?

Yes, but only if you keep the handler thin. The pattern is: parse the payload, append a queue row to a Sheet, return 200 — total work under one second. A separate time-driven trigger drains the queue every minute. This decouples the synchronous response from the actual processing, and it is the single most reliable webhook pattern at scale.

How do I version my webhook URL when I make breaking changes?

Apps Script lets you publish multiple deployments simultaneously, each with its own stable URL — version 1 stays live for legacy senders while you migrate version 2 in parallel. Use Deploy > New deployment and label them clearly. Both URLs hit the same script project, but you can fork the handler with a version parameter (?v=2 in the URL or a custom header) to route requests. Once all senders are migrated, archive the old deployment to revoke its URL.

How do I connect Google Sheets to email without Zapier?

Use Apps Script, which is built into every Google Sheet. For simple sends, MailApp.sendEmail() goes out through your own Gmail with no setup and no third party — capped at roughly 100 recipients/day on consumer accounts and 1,500/day on Workspace. For higher volume, a branded sending domain, or delivery reporting, call a transactional email API like Mailjet (POST to api.mailjet.com/v3.1/send) or Mailgun (POST to api.mailgun.net/v3/DOMAIN/messages) with UrlFetchApp.fetch using Basic auth. Both run inside the Sheet's own script — no Zapier, Make, or monthly per-task fees, and you own the code.

Does my Apps Script webhook count toward the daily UrlFetchApp quota?

No. Inbound webhooks (data coming into Apps Script via doPost) are completely separate from outbound UrlFetchApp calls. The 20,000-call free / 100,000 Workspace daily limit applies only to UrlFetchApp.fetch — your script reaching out to other APIs. Your webhook can receive unlimited inbound traffic, though heavy sustained inbound for hours can affect overall execution time, which is its own daily quota (90 minutes free / 6 hours Workspace).

Stay Updated

Get the latest insights on AI, e-commerce, and Magento delivered to your inbox.