I run a small setup where a Stripe payment triggers Google Apps Script (GAS) to email the product to the buyer. While building it I hit the kind of trap where everything "works" and you still get an incident. Here is what went wrong, how I fixed it, and how I test GAS logic without opening the editor.
Trap 1: a GAS web app answers POST with a 302
When you deploy GAS as a web app (/exec) and receive a Stripe webhook in doPost(e), this happens:
-
doPostdoes run - but the response is a 302 redirect (to a result page on
script.googleusercontent.com)
That is how GAS web apps work: after a POST, the result is served from a different URL.
The problem is on the sender's side. Stripe, like many webhook senders, treats a 3xx as a failed delivery. So:
- Stripe sends
checkout.session.completed - GAS sends the delivery email (success)
- The response is a 302, so Stripe marks it failed and retries
- GAS sends the delivery email again → the buyer gets the same email several times
- If failures continue, the webhook endpoint may eventually be disabled
Fix: put a relay in front that returns 200, and make GAS idempotent
The relay can live anywhere. It is a tiny endpoint that forwards to GAS and returns 200 to the sender. I used PHP on an ordinary shared host.
<?php
// Stripe -> this relay -> GAS. The sender gets a 200 and never sees GAS's 302.
$payload = file_get_contents('php://input');
$sig = $_SERVER['HTTP_STRIPE_SIGNATURE'] ?? '';
// Verify the signature here (doPost in GAS cannot read request headers)
$secret = getenv('STRIPE_WEBHOOK_SECRET');
parse_str(str_replace(',', '&', $sig), $parts); // split t=...,v1=...
$expected = hash_hmac('sha256', ($parts['t'] ?? '') . '.' . $payload, $secret);
if (!isset($parts['v1']) || !hash_equals($expected, $parts['v1']) || abs(time() - (int)$parts['t']) > 300) {
http_response_code(400);
exit;
}
$ch = curl_init('https://script.google.com/macros/s/XXXXXXXX/exec');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_FOLLOWLOCATION => true, // follow GAS's 302 through to the result
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 25,
]);
$body = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($body === false || $code >= 500) {
http_response_code(500); // only report failure when GAS is really down, and let Stripe retry
exit;
}
http_response_code(200);
echo 'ok';
Two points:
- Return 500 only when GAS is actually down. That way Stripe's automatic retries apply to real failures only.
-
Verify the Stripe signature in the relay.
doPost(e)in GAS cannot read request headers, so it cannot verify it. (Between the relay and GAS, use a hard-to-guess URL or check a shared secret passed in the query string.)
The other half is making the GAS side idempotent. Even with a relay, the same event can arrive twice for ordinary network reasons.
function doPost(e) {
const event = JSON.parse(e.postData.contents);
const props = PropertiesService.getScriptProperties();
const key = 'done_' + event.id; // Stripe event ID
const lock = LockService.getScriptLock();
lock.waitLock(20000);
try {
if (props.getProperty(key)) {
return ContentService.createTextOutput('duplicate'); // already handled: do nothing
}
deliver_(event); // send the delivery email, etc.
props.setProperty(key, new Date().toISOString());
return ContentService.createTextOutput('ok');
} finally {
lock.releaseLock();
}
}
On a duplicate, do not send again. If you make it "send once more just in case", every retry loop adds another email.
Trap 2: testing GAS with curl -X POST gives a 411
When I tested GAS directly with curl (no relay), adding -X POST failed.
# NG: fails with 411 Length Required and similar
curl -X POST -L -d '{"test":1}' "https://script.google.com/macros/s/XXXX/exec"
# OK: no -X, just -d and -L
curl -L -d '{"test":1}' -H "Content-Type: application/json" "https://script.google.com/macros/s/XXXX/exec"
-X POST forces the method on the redirect target too. The request that should turn into a GET after the 302 stays a POST with no body, and gets rejected. With -d alone, curl uses POST automatically and then correctly fetches the result with GET after the redirect.
Trap 3 (more of a technique): test GAS logic in Node, using the production code as is
GAS is awkward to test: you run things by hand in the editor or wait for a trigger. But a large part of real GAS code is plain JavaScript that never touches SpreadsheetApp — amount checks, date arithmetic, duplicate detection.
So I load the production .gs files unchanged with Node's vm module, stub only the GAS globals, and call the functions.
// test/rules.test.js (no dependencies; run with: node test/rules.test.js)
const fs = require('fs');
const vm = require('vm');
const assert = require('assert');
const sandbox = {
console,
Utilities: {
formatDate: (d, tz, fmt) => fmt
.replace('yyyy', d.getFullYear())
.replace('MM', String(d.getMonth() + 1).padStart(2, '0'))
.replace('dd', String(d.getDate()).padStart(2, '0')),
},
SpreadsheetApp: {}, GmailApp: {}, DriveApp: {}, UrlFetchApp: {},
PropertiesService: {}, LockService: {}, Session: {}, Logger: { log() {} },
};
vm.createContext(sandbox);
// Like GAS in production, load every file into one namespace
for (const f of fs.readdirSync('src').filter(f => f.endsWith('.gs'))) {
vm.runInContext(fs.readFileSync('src/' + f, 'utf8'), sandbox, { filename: f });
}
// Call a pure-logic function with an object shaped like one sheet row (names are examples)
const r = sandbox.evaluateInvoice({ amount: 110000, paid: 100000, dueDate: '2026-09-30' });
assert.strictEqual(r.status, 'underpaid');
console.log('ok');
- If your top level is only function definitions and
vars (the usual GAS style), loading succeeds even with empty-object stubs. They are only touched when a function actually runs. - Functions that write to a sheet also run if you pass an in-memory sheet mock that implements just
getRange().getValues()/setValues()/appendRow(). For formatting methods, an emptyreturn thiswas enough. - This finds far more bugs than a static syntax check. For my invoice-checking GAS I run 39 assertions this way.
Deploying to GAS is clasp push. It updates with the local token, without logging in through the browser again.
Summary
- If a GAS web app receives webhooks, pair a relay that hides the 302 from the sender with idempotency keyed on the event ID
- Test with curl without
-X POST - GAS logic can be tested in Node's
vm, using the production code as is
I don't write code myself; an AI agent wrote and maintains all of this under rules I set. How that arrangement works is in I don't write code. Here's how I made Claude Code the steward of my one-person business, and as a short Kindle book: Let Claude Code Run Your One-Person Business.
Top comments (0)