Webhooks don't retry politely when your script throws mid-flight. A solid Apps Script receiver does four boring things well: accept doPost, check a shared secret, append under a lock, and answer with ContentService JSON. Here's the pattern we paste when a client says "Stripe/Typeform/Zapier hit us and nothing showed up."
TL;DR
- Deploy as a web app (
doPost) executed as you, accessible to Anyone (or Anyone with Google account — know the tradeoff). - Authenticate with a shared secret (header or query) stored in Script Properties — not hardcoded.
- Lock → parse → append → unlock in
finally. - Always return a clear JSON response via
ContentService. - Log failures somewhere you will actually read (a Log sheet beats
Loggeralone).
Using the Apps Script editor a lot? NitroGAS drops free themes & snippets right into script.google.com — optional Co-Pilot when you want a boost.
The failure mode
A vendor fires POST at your /exec URL. Your script:
- Assumes the body is always JSON (it was form-encoded)
- Looks up a sheet that was renamed last week
- Throws after a partial write
- Returns an HTML error page Apps Script generated for humans
The vendor marks it failed — or worse, marks it OK because they only check HTTP 200 on a blank response — and the payload is gone. You've been that person digging through vendor "recent deliveries" at midnight.
What you need before coding
- A spreadsheet with a Inbox (or Events) tab and headers you own
- A Script Property like
WEBHOOK_SECRET - Clarity on who can hit the URL (public webhooks ⇒ deploy "Anyone")
- A plan for idempotency if the vendor retries (event ID column helps)
Minimal doPost skeleton
function doPost(e) {
var lock = LockService.getScriptLock();
try {
if (!lock.tryLock(30000)) {
return jsonOut_({ ok: false, error: 'busy' }, 503);
}
if (!verifySecret_(e)) {
return jsonOut_({ ok: false, error: 'unauthorized' }, 401);
}
var payload = parseBody_(e);
var row = appendPayload_(payload, e);
return jsonOut_({ ok: true, row: row });
} catch (err) {
logError_('doPost', err);
return jsonOut_({ ok: false, error: String(err && err.message || err) }, 500);
} finally {
try { lock.releaseLock(); } catch (ignore) {}
}
}
function doGet() {
return jsonOut_({ ok: true, service: 'webhook-inbox', hint: 'POST only' });
}
doGet is optional but handy when someone pastes the URL in a browser — better a JSON hello than a blank stare.
Shared secret (don't skip this)
Public web app URLs leak. Treat the URL as necessary but not sufficient. Put a secret in Script Properties and require it on every POST:
function verifySecret_(e) {
var expected = PropertiesService.getScriptProperties().getProperty('WEBHOOK_SECRET');
if (!expected) {
throw new Error('WEBHOOK_SECRET is not configured');
}
var headers = (e && e.headers) || {};
// Apps Script lower-cases header names in many contexts — check both
var got = headers['x-webhook-secret'] || headers['X-Webhook-Secret'] || '';
if (!got && e && e.parameter) {
got = e.parameter.secret || '';
}
return String(got) === String(expected);
}
Tips:
- Prefer a header (
X-Webhook-Secret) over query strings (query strings show up in logs more often). - Rotate by setting a new property and updating the vendor; keep the old one briefly if you need overlap.
- This is shared-secret auth, not full HMAC signature verification — upgrade to HMAC when the vendor supports it and the threat model needs it.
Parse without losing the body
function parseBody_(e) {
if (!e) throw new Error('Empty event');
// JSON body (most modern webhooks)
if (e.postData && e.postData.type && e.postData.type.indexOf('application/json') !== -1) {
return JSON.parse(e.postData.contents || '{}');
}
// Raw JSON without a careful content-type
if (e.postData && e.postData.contents) {
var raw = e.postData.contents;
try {
return JSON.parse(raw);
} catch (ignore) {
// fall through to form fields
}
}
// form-urlencoded / multipart-ish parameter bag
return (e.parameter && Object.keys(e.parameter).length) ? e.parameter : {};
}
When in doubt, also store the raw postData.contents in a column. Future-you debugging a vendor schema change will worship that column.
Append under a lock
function appendPayload_(payload, e) {
var ss = SpreadsheetApp.getActiveSpreadsheet();
var sheet = ss.getSheetByName('Inbox');
if (!sheet) throw new Error('Missing sheet: Inbox');
var eventId = payload.id || payload.event_id || '';
if (eventId && findRowByEventId_(sheet, eventId) !== -1) {
return { deduped: true, eventId: eventId };
}
var raw = (e.postData && e.postData.contents) || JSON.stringify(payload);
sheet.appendRow([
new Date(),
eventId,
JSON.stringify(payload),
raw.substring(0, 50000), // soft cap
(e && e.parameter && e.parameter.source) || ''
]);
return { deduped: false, row: sheet.getLastRow(), eventId: eventId };
}
function findRowByEventId_(sheet, eventId) {
var values = sheet.getRange(2, 2, Math.max(sheet.getLastRow() - 1, 1), 1).getValues();
for (var i = 0; i < values.length; i++) {
if (String(values[i][0]) === String(eventId)) return i + 2;
}
return -1;
}
For high volume, swap linear scan for a cached map or a dedicated "processed IDs" sheet — but start honest and simple.
Header row we usually use:
timestamp | eventId | payloadJson | raw | source
JSON responses with ContentService
function jsonOut_(obj, optStatus) {
// Note: Apps Script web apps don't let you fully control HTTP status the way
// Express does; vendors still benefit from a clear JSON body. Include `ok`.
var out = ContentService.createTextOutput(JSON.stringify(obj));
out.setMimeType(ContentService.MimeType.JSON);
return out;
}
Be explicit in the body (ok, error, row). Many vendors only surface the response body in their dashboard — make it readable.
Deploy checklist
- Run
verifySecret_once manually after settingWEBHOOK_SECRET - Deploy → New deployment → Web app → Execute as Me → Who has access Anyone
- Copy the
/execURL into the vendor - Send a test event; confirm Inbox row + JSON
{ ok: true } - Send the same event ID again; confirm dedupe path
- Send with a bad secret; confirm rejection (and no row)
Access note: "Anyone" means the URL is callable without Google login — required for most third-party webhooks. Protect with the shared secret. If the caller can use a Google account you control, tighten access — most SaaS webhooks cannot.
Logging you'll read later
function logError_(scope, err) {
var ss = SpreadsheetApp.getActiveSpreadsheet();
var log = ss.getSheetByName('Log') || ss.insertSheet('Log');
if (log.getLastRow() === 0) {
log.appendRow(['timestamp', 'level', 'message', 'context']);
}
log.appendRow([
new Date(),
'ERROR',
String(err && err.message || err),
JSON.stringify({ scope: scope, stack: String(err && err.stack || '') })
]);
}
That shape matches the companion Log sheet guide — same columns, less regret.
Soft upgrade
When this inbox grows up, add:
- HMAC verification for vendors that sign bodies
- A watermarked worker that processes Inbox rows in chunks (see batch-writes guide)
- Alerts (email / Chat) on
ok: falsespikes
Until then: secret, lock, append, JSON. Ship that.
Keep the pattern handy
NitroGAS parks helpers like this next to the editor — Co-Pilot can adapt header names to your vendor. Free extension; Co-Pilot optional. The doPost skeleton above works without it.
Closing checklist
-
WEBHOOK_SECRETin Script Properties; verified on every POST - Lock around parse + append; release in
finally - Raw body retained; JSON parsed when possible
- Idempotency via event ID when the vendor provides one
- JSON responses with an
okfield - Errors land on a Log sheet, not only in Executions
Catch the payload first. Process it second. Everything else is choreography.
Happy Coding!


