DEV Community

Takuya Auto
Takuya Auto

Posted on

Google Apps Script answers webhooks with a 302: why Stripe retries forever, and how to fix it

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:

  • doPost does 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:

  1. Stripe sends checkout.session.completed
  2. GAS sends the delivery email (success)
  3. The response is a 302, so Stripe marks it failed and retries
  4. GAS sends the delivery email again → the buyer gets the same email several times
  5. 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';
Enter fullscreen mode Exit fullscreen mode

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();
  }
}
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

-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');
Enter fullscreen mode Exit fullscreen mode
  • 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 empty return this was 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)