Skip to content
// Apps Script

Keep a stable web app URL across deployments.

Every time you hit "New deployment" in Apps Script you get a fresh /exec URL and everything pointing to the old one breaks. Use a named deployment and update its version instead.

I want my Apps Script web app URL to stay the same after I push changes, because every new deployment hands me a different URL and breaks bookmarks and integrations that saved the old one.

The script

copy · paste · trigger
bumpDeployment.gs
Apps Script
// Cut a new version, then repoint the SAME deployment at it.
// The /exec URL belongs to the deployment ID, so it never changes.
// Prerequisites, both easy to miss - see the walkthrough:
//   1. Apps Script API ON at script.google.com/home/usersettings
//   2. script.projects + script.deployments in appsscript.json oauthScopes
function bumpDeployment() {
  var scriptId = ScriptApp.getScriptId();
  var deploymentId = 'AKfycb_YOUR_DEPLOYMENT_ID_HERE';
  var base = 'https://script.googleapis.com/v1/projects/' + scriptId;
  var auth = { Authorization: 'Bearer ' + ScriptApp.getOAuthToken() };

  // 1. Create a version. The version number is assigned by Apps Script.
  var made = UrlFetchApp.fetch(base + '/versions', {
    method: 'post',
    contentType: 'application/json',
    headers: auth,
    payload: JSON.stringify({ description: 'Production' }),
    muteHttpExceptions: true
  });
  if (made.getResponseCode() !== 200) throw new Error('version: ' + made.getContentText());
  var versionNumber = JSON.parse(made.getContentText()).versionNumber;

  // 2. Point the existing deployment at that version.
  var moved = UrlFetchApp.fetch(base + '/deployments/' + deploymentId, {
    method: 'put',
    contentType: 'application/json',
    headers: auth,
    payload: JSON.stringify({
      deploymentConfig: {
        scriptId: scriptId,
        versionNumber: versionNumber,
        manifestFileName: 'appsscript',
        description: 'Production'
      }
    }),
    muteHttpExceptions: true
  });
  if (moved.getResponseCode() !== 200) throw new Error('deploy: ' + moved.getContentText());

  Logger.log('Deployment now serving version ' + versionNumber);
}

Need a variant? Gnaw writes a custom version from one sentence — fields, triggers, edge cases handled.

Walkthrough

Why a new deployment always means a new URL

Apps Script ties each /exec URL to a specific deployment record, not to the project. When you click Deploy > New deployment, the editor creates a brand-new record with its own ID, and that ID is part of the URL path. The old record still exists and its URL still works, pointing at whatever version it was pinned to when you created it. Nothing migrates automatically. Anyone who bookmarked, hardcoded, or integrated the old URL is now pointing at stale code.

So the URL is not really unstable. It is stable per deployment. What changes is which deployment you are talking about, and 'New deployment' is the button that changes it.

The fix needs no code: edit the deployment, do not create one

Create exactly one deployment for production, then update that same record every time you ship. In the editor: Deploy > Manage deployments, select the existing entry, click the pencil icon, set Version to 'New version', and click Deploy. The deployment ID does not change, so the /exec URL does not change. This is the whole answer for most people, and it involves no API, no Cloud project, and no scopes.

The trap is that 'New deployment' and 'Manage deployments' sit in the same menu one line apart, and only the second one preserves the URL. If your URL keeps changing, this is almost certainly why.

Automating the bump, and the two prerequisites that will 403 you

If you deploy from CI rather than the editor, the snippet above does it in two calls, because there is no single 'update to latest' endpoint. First POST /v1/projects/{scriptId}/versions to cut a version - Apps Script assigns the number and returns it as versionNumber. Then PUT /v1/projects/{scriptId}/deployments/{deploymentId} to repoint the existing deployment at that number. Note the PUT body is wrapped in a deploymentConfig object; a flat body is silently rejected.

Two prerequisites are easy to miss and both surface as a 403 rather than a helpful error. First, the Apps Script API must be switched on for your account at script.google.com/home/usersettings - it is off by default and the toggle is account-wide, not per-project. Second, ScriptApp.getOAuthToken() only returns a token carrying scopes the script has actually requested, so appsscript.json must declare https://www.googleapis.com/auth/script.projects and https://www.googleapis.com/auth/script.deployments in oauthScopes. Without the second one the token is valid and the call still fails.

Worth knowing before you build around it: the Apps Script API does not work with service accounts, so this path needs a real user's OAuth token.

Want a custom version?

Describe your sheet and the rule you want. Gnaw writes the Apps Script — fields, triggers, edge cases — in one shot.

FAQ

4 questions
Is there a way to deploy without a version number — just use HEAD?
Yes. When you create a deployment in the editor you can set the version to 'Latest (Head)'. Every request then executes whatever is saved, with no version pinning. The URL stays stable and you skip the version-bump step entirely. The downside is zero rollback capability: a broken save goes live immediately. Use Head for internal tools where you control all users; pin to a version number for anything external.
What is the deployment ID and where do I find it after the fact?
It is the long AKfycb... string that appears in the Manage deployments dialog next to each entry. You can also retrieve all deployments programmatically by calling GET https://script.googleapis.com/v1/projects/{scriptId}/deployments with a bearer token — the response lists every deployment record with its ID, current version, and web app URL. Useful if you lost track of which ID you pasted somewhere.
Why does my bumpDeployment() call return 403 when the same request works in the API Explorer?
Two separate causes, and they look identical from inside Apps Script. Either the Apps Script API is switched off for your account - it is off by default, and the toggle at script.google.com/home/usersettings applies to all applications at once - or your appsscript.json is missing the scopes. ScriptApp.getOAuthToken() hands back a token for the scopes the script requested, not for everything you personally can do, so a script without script.projects and script.deployments in oauthScopes gets a valid token that is not authorised for these endpoints. Add them, then re-run the function once from the editor to trigger the consent prompt.
Can I have one URL for staging and a different one for production?
Yes, and this is the right pattern. Create two deployments: one named 'Staging' pinned to Head, one named 'Production' pinned to a specific version. The two /exec URLs are different, which is what you want — staging tests code that hasn't been promoted yet. Only the production URL gets shared externally. When a staging build is validated, run bumpDeployment() pointing at the production deployment ID to promote it.
// one good script a week

Get a working Apps Script snippet in your inbox, weekly.