Build a payment-webhook receiver
Catch Stripe payment events into a Northwind sheet — paid invoices flip status instantly.
Published Oct 11, 2025
Northwind invoices clients through Stripe, but the invoice tracker lives in a Google Sheet. Someone used to check Stripe every morning and tick off whatever had been paid overnight — slow, and always a few hours behind reality.
A webhook closes that gap. Stripe can call a URL the moment an event happens,
and an Apps Script web app is a perfectly good URL to give it. This script
deploys a doPost endpoint that listens for invoice.paid events, finds the
matching row in the invoices sheet, and flips its status to paid — within
seconds of the customer’s card clearing, no morning check required.
What you’ll need
- A Google Sheet of invoices, with the Stripe invoice ID in column A and a status in column E. The script matches on the ID and writes to the status cell.
- A Stripe account with access to the Developers → Webhooks dashboard.
- The Stripe webhook signing secret (starts with
whsec_), saved asSTRIPE_WEBHOOK_SECRETin Script Properties — see Store API keys and secrets securely. - The script deployed as a web app with access set to Anyone, so Stripe’s servers can reach it.
The script
Deploy this as a web app — the doPost function is the URL Stripe calls.
// The spreadsheet that holds your invoice tracker.
const INVOICES_SHEET_ID = '1abcInvoicesId';
// Column positions in the invoices sheet (1-based for getRange).
const ID_COLUMN = 1; // Column A — Stripe invoice ID.
const STATUS_COLUMN = 5; // Column E — the cell we flip to "paid".
// The Stripe event type we care about. Stripe sends many others.
const PAID_EVENT = 'invoice.paid';
/**
* Web app endpoint. Stripe POSTs an event here; we update the matching
* invoice row and reply with a 200 so Stripe marks the delivery as done.
*/
function doPost(e) {
// 1. Reject anything without a body — bots and health checks hit web apps.
if (!e || !e.postData || !e.postData.contents) {
return ContentService.createTextOutput('No payload');
}
// 2. Verify the request really came from Stripe before trusting it.
if (!isFromStripe(e)) {
return ContentService.createTextOutput('Bad signature');
}
// 3. Parse the event and ignore everything except a paid invoice.
const event = JSON.parse(e.postData.contents);
if (event.type !== PAID_EVENT) {
return ContentService.createTextOutput('Ignored');
}
// 4. Pull the invoice object and find its row in the sheet.
const invoice = event.data.object;
const sheet = SpreadsheetApp.openById(INVOICES_SHEET_ID).getSheets()[0];
const values = sheet.getDataRange().getValues();
const row = values.findIndex((r) => r[ID_COLUMN - 1] === invoice.id);
// 5. Flip the status cell if we found a match.
if (row !== -1) {
sheet.getRange(row + 1, STATUS_COLUMN).setValue('paid');
Logger.log('Marked invoice ' + invoice.id + ' as paid.');
} else {
Logger.log('No row found for invoice ' + invoice.id);
}
// 6. Always reply 200 — otherwise Stripe keeps retrying the delivery.
return ContentService.createTextOutput('OK');
}
/**
* Verifies the Stripe-Signature header against the request body using
* the signing secret. Stripe signs every webhook with HMAC-SHA256.
*/
function isFromStripe(e) {
const secret = PropertiesService.getScriptProperties()
.getProperty('STRIPE_WEBHOOK_SECRET');
const header = e.parameter && e.parameter['Stripe-Signature'];
if (!secret || !header) return false;
// The header looks like: t=timestamp,v1=signature
const parts = Object.fromEntries(
header.split(',').map((p) => p.split('=')));
if (!parts.t || !parts.v1) return false;
// Stripe signs the string "timestamp.body".
const signed = parts.t + '.' + e.postData.contents;
const computed = Utilities.computeHmacSha256Signature(signed, secret)
.map((b) => ('0' + (b & 0xff).toString(16)).slice(-2))
.join('');
return computed === parts.v1;
}
How it works
doPostruns every time Stripe POSTs to the web app URL. It first rejects any request with no body — web apps attract bots and uptime pings.isFromStripechecks theStripe-Signatureheader. It rebuilds the signed string (timestamp.body), computes its HMAC-SHA256 with the signing secret, and compares it to the signature Stripe sent. A mismatch means the request is forged or stale, so the script bails out.- With the request trusted, it parses the JSON event and checks
event.type. Stripe fires dozens of event types; onlyinvoice.paidmatters here. - It reads the invoices sheet and uses
findIndexto locate the row whose column A equals the invoice ID from the event payload. - If it finds the row, it sets column E to
paid. If not, it logs the miss so you can spot invoices that exist in Stripe but not the sheet. - Whatever happens, it returns a 200 response. Stripe retries any delivery that does not get a 2xx, so a clean reply stops duplicate calls.
Example run
A client pays invoice in_1Q2x.... Stripe POSTs an invoice.paid event, and
the matching row updates in place:
| Invoice ID (A) | Client (B) | Amount (C) | Due (D) | Status (E) |
|---|---|---|---|---|
| in_1Q2x9aBc | Riverside Ltd | £2,400 | 2025-10-09 | |
| in_1Q2x7dEf | Harbour Co | £1,150 | 2025-10-15 | sent |
The execution log shows Marked invoice in_1Q2x9aBc as paid. The Harbour Co
row is untouched because its event has not arrived yet.
Trigger it
Stripe is the trigger — there is no time-based trigger to set.
- Deploy the script: Deploy → New deployment → Web app. Set Execute as to yourself and Who has access to Anyone. Copy the web app URL.
- In the Stripe dashboard, go to Developers → Webhooks → Add endpoint.
Paste the web app URL and select the
invoice.paidevent. - Stripe shows the signing secret once. Copy it into Script Properties as
STRIPE_WEBHOOK_SECRET. - Use Stripe’s Send test webhook button to confirm a row flips to
paid.
Watch out for
- Always verify the signature. Without
isFromStripe, anyone who finds the web app URL could POST a fakeinvoice.paidevent and mark invoices paid. The signing secret is what makes the endpoint trustworthy. - Re-deploying creates a new URL unless you choose Manage deployments and edit the existing deployment. A fresh URL means Stripe is calling a dead endpoint — update the Stripe webhook if the URL changes.
- Stripe expects a fast 2xx. Heavy work inside
doPostrisks a timeout, after which Stripe retries and you process the event twice. Keep the handler lean. - Web apps run single-threaded per execution, but a burst of payments can still arrive close together. Matching on a unique invoice ID keeps this safe — the same event simply overwrites the same cell.
- This only handles
invoice.paid. Refunds, failed payments, and disputes fire different event types; add moreevent.typebranches as you need them.
Related
Build a two-factor SMS verification step
Add phone verification to a Northwind workflow — code via Twilio, validated by web app.
Updated Dec 18, 2025
Bridge Sheets to Zapier or Make
Trigger external automations from Northwind Sheets via webhooks — no Apps Script logic needed downstream.
Updated Nov 8, 2025
Send rich notifications to Discord
Push Northwind deploy alerts and KPI updates to a Discord channel — embeds, not plain text.
Updated Oct 15, 2025
Build a WhatsApp notification sender
Push Northwind updates via the WhatsApp Business API — for client billing milestones.
Updated Jul 23, 2025
Send SMS notifications with Twilio
Text Northwind alerts straight from your scripts — for production outages or VIP events.
Updated Jul 19, 2025