Catch a webhook in Apps Script without losing the payload

TL;DR — Catch webhooks in Apps Script with doPost: verify a shared secret, lock and append the payload, then return ContentService JSON without losing the data.

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 Logger alone).

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

  1. A spreadsheet with a Inbox (or Events) tab and headers you own
  2. A Script Property like WEBHOOK_SECRET
  3. Clarity on who can hit the URL (public webhooks ⇒ deploy "Anyone")
  4. 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

  1. Run verifySecret_ once manually after setting WEBHOOK_SECRET
  2. Deploy → New deployment → Web app → Execute as Me → Who has access Anyone
  3. Copy the /exec URL into the vendor
  4. Send a test event; confirm Inbox row + JSON { ok: true }
  5. Send the same event ID again; confirm dedupe path
  6. 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: false spikes

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_SECRET in 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 ok field
  • Errors land on a Log sheet, not only in Executions

Catch the payload first. Process it second. Everything else is choreography.

Happy Coding!