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
- A Google Sheet to log events. Column A header
received_at, Bsource, Ctype, Dpayload. - A Slack incoming-webhook URL saved as
SLACK_WEBHOOKin Script Properties. See Store API keys and secrets securely. - An Apps Script web app deployment — see Deploy Apps Script as a public web app — with access set to Anyone.
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
doPostis the entry point Apps Script calls on every incoming POST. Theeevent has the query string one.parameterand the raw body one.postData.contents.- The
sourcequery parameter tells the script which provider sent the event. Each sender gets a distinct URL like/exec?source=stripeso the dispatch is unambiguous. - Parsing is wrapped in a try/catch. A malformed body logs an
_parseErrorfield instead of throwing — that keeps the reply at 200 and stops the sender from retrying the same broken payload forever. - The log row is appended first, before any handler runs. That guarantees the event is on disk even if a downstream notifier breaks.
- The dispatch is a flat
if/elsekeyed onsourceand 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. - 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_at | source | type | payload |
|---|---|---|---|
| 2026-05-27 09:14:02 | stripe | charge.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
- Paste the script into a new Apps Script project and set the two constants at the top.
- In Script Properties, save
SLACK_WEBHOOKwith your Slack incoming-webhook URL. - Deploy → New deployment → Web app. Execute as Me, access Anyone.
- Approve the authorisation prompt and copy the
/execURL. - In each provider’s dashboard, paste the URL with the right query string —
?source=stripefor Stripe,?source=githubfor GitHub. - 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-Signatureheader, GitHub’sX-Hub-Signature-256) insidedoPostand reject anything unsigned. - Every re-deploy that bumps the version gives you a fresh
/execURL 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. doPosthas 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
Build a form-to-PDF web service
Convert Northwind form submissions to PDFs on the fly — POST in, PDF out.
Updated Oct 27, 2025
Build a branded approval interface
Approve Northwind requests through a custom UI — clients click, decision is logged.
Updated Nov 8, 2025
Build an interactive quiz or assessment app
Run Northwind tests with scoring and feedback — questions in a Sheet, results in another.
Updated Nov 4, 2025
Build a multi-page web app with routing
Structure a real Northwind app across views — query-param routing, shared layout.
Updated Oct 31, 2025
Build an expiring secure-download generator
Issue time-limited Northwind links via a web app — token in URL, server-side check.
Updated Oct 23, 2025