appscript.dev
Automation Advanced Sheets

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 as STRIPE_WEBHOOK_SECRET in 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

  1. doPost runs every time Stripe POSTs to the web app URL. It first rejects any request with no body — web apps attract bots and uptime pings.
  2. isFromStripe checks the Stripe-Signature header. 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.
  3. With the request trusted, it parses the JSON event and checks event.type. Stripe fires dozens of event types; only invoice.paid matters here.
  4. It reads the invoices sheet and uses findIndex to locate the row whose column A equals the invoice ID from the event payload.
  5. 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.
  6. 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_1Q2x9aBcRiverside Ltd£2,4002025-10-09sentpaid
in_1Q2x7dEfHarbour Co£1,1502025-10-15sent

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.

  1. Deploy the script: Deploy → New deployment → Web app. Set Execute as to yourself and Who has access to Anyone. Copy the web app URL.
  2. In the Stripe dashboard, go to Developers → Webhooks → Add endpoint. Paste the web app URL and select the invoice.paid event.
  3. Stripe shows the signing secret once. Copy it into Script Properties as STRIPE_WEBHOOK_SECRET.
  4. 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 fake invoice.paid event 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 doPost risks 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 more event.type branches as you need them.

Related