appscript.dev
Automation Advanced Sheets

Build a webhook receiver as a web app

Accept and process incoming Northwind events from Stripe, Slack, GitHub — log and act.

Published Aug 16, 2025

Northwind wants to know the moment a charge succeeds, a Slack message lands in the help channel, or a GitHub issue gets opened — without standing up a server to receive the webhook. Apps Script’s doPost is the right tool for the job: an /exec URL anyone on the internet can call, an HTTPS endpoint Google hosts for free, and a script behind it that logs the payload and reacts.

This receiver is built to be safe by default. Every incoming event is logged to a Sheet before any handler runs, so even a broken downstream step leaves a trail to debug from. A source query parameter routes events to per-provider handlers, which keeps the dispatch flat and easy to read. The response is always a tiny JSON acknowledgement — webhook senders judge success by HTTP status and a quick reply, not by a long body.

What you’ll need

The script

// Sheet that logs every incoming webhook. Append-only, one row per
// event, so even a handler crash leaves an audit trail.
const LOG_SHEET_ID = '1abcWebhookLogId';

// Where Slack notifications go. Stored in Script Properties so the
// secret never lives in source control.
const SLACK_WEBHOOK_KEY = 'SLACK_WEBHOOK';

/**
 * Webhook entry point. Apps Script calls doPost with the raw POST body
 * on e.postData.contents. The pattern below logs first, dispatches
 * second, and replies with a small JSON ack regardless of outcome.
 *
 * @param {GoogleAppsScript.Events.DoPost} e
 * @return {GoogleAppsScript.Content.TextOutput} JSON ack.
 */
function doPost(e) {
  // Read the source label from the query string (?source=stripe). It is
  // safer than guessing from the payload — each sender uses its own URL.
  const source = (e && e.parameter && e.parameter.source) || 'unknown';

  // Parse the body defensively. A junk POST should log and 200, not
  // crash and 500 — Stripe will keep retrying a 500 forever.
  let event = {};
  let raw = '';
  try {
    raw = e && e.postData ? e.postData.contents : '';
    event = raw ? JSON.parse(raw) : {};
  } catch (err) {
    event = { _parseError: String(err) };
  }

  // Step 1: log no matter what. The log is the source of truth for
  // debugging; everything else is best-effort.
  logEvent(source, event.type || '', raw);

  // Step 2: dispatch on source. New providers get a new branch — keep
  // the body of each branch tiny and delegate to a named handler.
  try {
    if (source === 'stripe' && event.type === 'charge.succeeded') {
      const amount = (event.data && event.data.object && event.data.object.amount) || 0;
      notifySlack('cha-ching: $' + (amount / 100).toFixed(2));
    } else if (source === 'github' && event.action === 'opened') {
      notifySlack('New issue: ' + (event.issue && event.issue.title));
    }
  } catch (err) {
    // Never throw out of doPost — the sender retries on 500s.
    logEvent('handler_error', source, String(err));
  }

  // Step 3: always reply 200 with a short body. Senders treat the body
  // as opaque; what matters is the status code.
  return ContentService
    .createTextOutput(JSON.stringify({ ok: true }))
    .setMimeType(ContentService.MimeType.JSON);
}

/**
 * Appends one row to the log Sheet. Called from both the happy path
 * and the error path, so every event leaves a trace.
 *
 * @param {string} source The ?source= value or 'handler_error'.
 * @param {string} type The event type, if known.
 * @param {string} payload The raw POST body.
 */
function logEvent(source, type, payload) {
  SpreadsheetApp.openById(LOG_SHEET_ID)
    .getSheets()[0]
    .appendRow([new Date(), source, type, payload]);
}

/**
 * Posts a short text message to Slack via incoming webhook. Kept
 * separate so other handlers can call it without copying the fetch.
 *
 * @param {string} text The message body.
 */
function notifySlack(text) {
  const url = PropertiesService.getScriptProperties()
    .getProperty(SLACK_WEBHOOK_KEY);
  if (!url) throw new Error('SLACK_WEBHOOK is not set in Script Properties.');
  UrlFetchApp.fetch(url, {
    method: 'post',
    contentType: 'application/json',
    payload: JSON.stringify({ text }),
    muteHttpExceptions: true,
  });
}

How it works

  1. doPost is the entry point Apps Script calls on every incoming POST. The e event has the query string on e.parameter and the raw body on e.postData.contents.
  2. The source query parameter tells the script which provider sent the event. Each sender gets a distinct URL like /exec?source=stripe so the dispatch is unambiguous.
  3. Parsing is wrapped in a try/catch. A malformed body logs an _parseError field instead of throwing — that keeps the reply at 200 and stops the sender from retrying the same broken payload forever.
  4. The log row is appended first, before any handler runs. That guarantees the event is on disk even if a downstream notifier breaks.
  5. The dispatch is a flat if/else keyed on source and a payload field. Each branch is a one-liner that delegates to a helper. Adding a new provider is one more branch, not a rewrite.
  6. The reply is a tiny JSON ack with status 200. Webhook senders treat the body as opaque — what matters is the status code arrives quickly.

Example run

Stripe posts to https://script.google.com/.../exec?source=stripe with a body shaped like {"type":"charge.succeeded","data":{"object":{"amount":4500}}}.

The log Sheet gains a row:

received_atsourcetypepayload
2026-05-27 09:14:02stripecharge.succeeded{"type":"charge.succeeded",...,"amount":4500}

A Slack message appears in the channel: “cha-ching: $45.00”. Stripe sees a 200 and stops retrying. A junk request to the same URL still logs and still 200s, so retries do not pile up.

Deploy it

  1. Paste the script into a new Apps Script project and set the two constants at the top.
  2. In Script Properties, save SLACK_WEBHOOK with your Slack incoming-webhook URL.
  3. DeployNew deployment → Web app. Execute as Me, access Anyone.
  4. Approve the authorisation prompt and copy the /exec URL.
  5. In each provider’s dashboard, paste the URL with the right query string — ?source=stripe for Stripe, ?source=github for GitHub.
  6. Trigger a test event from the provider’s UI and check that a row appears in the log Sheet.

Watch out for

  • Apps Script web apps are not signed by default. Anyone who finds the URL can POST to it. For real production use, also verify the provider’s signature (Stripe’s Stripe-Signature header, GitHub’s X-Hub-Signature-256) inside doPost and reject anything unsigned.
  • Every re-deploy that bumps the version gives you a fresh /exec URL only if you tick “New version”. Picking “Manage deployments” → existing deployment keeps the URL stable, which is what you want — providers do not enjoy URL changes.
  • doPost has no auto-retry safety. Idempotency is your problem: include the provider’s event ID in the log row and skip rows that already exist if duplicates would matter downstream.
  • Apps Script has a 30-second execution limit per request. Heavy handlers should write a job row to a Sheet and return 200 immediately; a time-driven trigger picks the job up. The receiver stays fast; the work happens later.
  • For the matching upstream-side article — generating outbound calls and storing per-call status — see Build an expiring secure-download generator, which uses the same Script Properties pattern for short-lived state.

Related