Webhooks and Zapier "Custom Request" steps hate moving targets. If you Deploy → New deployment every time you tweak doPost, partners get a new /exec URL — or worse, you keep "Test deployments" and wonder why production still runs last month’s code. Pin one web app URL; ship versions behind it.
TL;DR
- Create a versioned web app deployment once; reuse that deployment ID.
- Prefer Manage deployments → Edit → Version: New version over minting a brand-new deployment.
- "Test deployments" / HEAD are for you — not for customer webhooks.
- Execute-as and access settings matter as much as the URL (see execute-as guides).
- After a bad push, roll forward with another version — don’t thrash URLs.
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.
Why webhooks 404 after a "redeploy"
Common bootstrapper path:
- Deploy as web app → copy
/execinto Stripe / HubSpot / a Sheet cell - Change code → New deployment again → new URL
- Old URL returns 404 or an ancient version
- Support thread: "your webhook is broken"
The fix isn’t more retries. It’s treating the deployment like a stable endpoint.
New deployment vs new version
| Action | URL | Who should use it |
|---|---|---|
| New deployment (web app) | New /exec |
First time only (or intentional second endpoint) |
| Edit deployment → New version | Same /exec |
Everyday code ship |
| Test deployment | Separate test URL | You, locally / QA |
In the Apps Script UI: Deploy → Manage deployments → pencil → Version → New version → Deploy.
From clasp (when you use it): deploy with the existing deployment id rather than creating another.
Minimal doPost that stays boring
function doPost(e) {
try {
var body = e && e.postData && e.postData.contents
? JSON.parse(e.postData.contents)
: {};
var result = handleWebhook_(body, e);
return ContentService
.createTextOutput(JSON.stringify({ ok: true, result: result }))
.setMimeType(ContentService.MimeType.JSON);
} catch (err) {
return ContentService
.createTextOutput(JSON.stringify({
ok: false,
error: err && err.message ? err.message : String(err)
}))
.setMimeType(ContentService.MimeType.JSON);
}
}
function handleWebhook_(body, e) {
// validate signature / shared secret here
// enqueue work; return fast
return { received: true };
}
Keep the HTTP handler thin. Queue sheet rows or CacheService work for the heavy lift so webhook timeouts don’t tempt you into "just redeploy again."
Checklist when you change execute-as or access
URL stability doesn’t help if auth flips:
- Execute as: Me — your quotas / Drive; good for trusted inbound webhooks
- Execute as: User accessing — each caller; often wrong for server-to-server
- Who has access — "Anyone" vs "Anyone with Google account" vs domain
Document the pair (URL + execute-as + access) in the client runbook. Complements the queued execute-as and webhook guides.
How to verify after shipping a version
function smokeWebApp_() {
// Paste your STABLE /exec URL — not a test deployment
var url = PropertiesService.getScriptProperties().getProperty('WEBAPP_URL');
var resp = UrlFetchApp.fetch(url, {
method: 'post',
contentType: 'application/json',
payload: JSON.stringify({ ping: true }),
muteHttpExceptions: true
});
Logger.log(resp.getResponseCode() + ' ' + resp.getContentText());
}
Store WEBAPP_URL in Script Properties so menus and docs don’t drift from the live endpoint.
Soft Co-Pilot note
NitroGAS Co-Pilot can help sketch doPost + property checks; the deployment discipline (one URL, many versions) is still on you. Free extension; Co-Pilot optional.
Closing checklist
- One production
/execURL recorded (Script Property + runbook) - Code ships as new version on that deployment
- Test deployment URL never given to vendors
- Execute-as / access documented beside the URL
- Smoke POST after each version deploy
Happy Coding!


