// Apps Script

Fix "Request failed for URL returned code 403" in Apps Script.

UrlFetchApp throws on non-2xx by default, hiding the real 403 error body. Learn to expose the API's actual error message, and fix the most common root cause: the Authorization header placed outside options.headers.

I'm getting "Request failed for URL returned code 403" from UrlFetchApp and I can't see what the API is actually rejecting.

The script

copy · paste · trigger
fetch_with_debug.gs
Apps Script
// Diagnose a 403 from UrlFetchApp without losing the response body
function fetchWithDebug() {
  var token = ScriptApp.getOAuthToken();
  var options = {
    method: 'get',
    muteHttpExceptions: true,
    headers: {
      Authorization: 'Bearer ' + token,
      Accept: 'application/json'
    }
  };

  var url = 'https://www.googleapis.com/drive/v3/files';
  var response = UrlFetchApp.fetch(url, options);

  var code = response.getResponseCode();
  var body = response.getContentText();
  Logger.log('Status: ' + code);
  Logger.log('Body: ' + body);
}

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

Walkthrough

Why you never see the real error

By default, UrlFetchApp throws a JavaScript exception the moment it receives any non-2xx response. That exception message contains only the status code and the URL — not the response body the API carefully wrote to explain the rejection. The body evaporates before you can read it.

The fix is one option: muteHttpExceptions set to true. With that flag, UrlFetchApp returns the full HTTPResponse object regardless of status code, and you call getResponseCode() and getContentText() yourself. The first time I added this to a script I'd been fighting for an hour, the API body immediately said 'The caller does not have permission' with a reason field pointing at a missing OAuth scope. Five-minute fix after that.

The header placement mistake that causes most 403s

After you can read the body, the single most common cause of a 403 on Google or third-party APIs in Apps Script is the Authorization header placed at the top level of the options object instead of inside options.headers. It looks like this: { method: 'get', Authorization: 'Bearer ' + token }. Apps Script silently ignores keys it does not recognize at the top level, so the header is never sent, and the API sees an unauthenticated request.

The correct shape is options.headers.Authorization. That is it. No other change needed. If your token itself is invalid or expired, the API body will tell you that explicitly — usually 'Invalid Credentials' or a 401-inside-the-403 pattern some APIs use.

OAuth scope gaps and service-account delegation

If the header placement is correct and the token is present but the API still returns 403, the next suspect is an insufficient OAuth scope. ScriptApp.getOAuthToken() issues a token scoped only to what the script's manifest declares in oauthScopes (or what Apps Script infers from the services you use in code). Call Logger.log(ScriptApp.getOAuthToken()) and decode the token at jwt.io — the scp or scope claim shows what was actually granted.

For Workspace Admin APIs (Directory API, Reports API, and similar), a user-level token is not enough regardless of scope. Those endpoints require a service account with domain-wide delegation, and the 403 body will typically say 'Not Authorized to access this resource/api'. That is a fundamentally different auth flow — OAuth2 for Apps Script library or the UrlFetchApp-based JWT exchange — not a header fix.

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
Does muteHttpExceptions affect whether the script continues running?
Yes. Without it, a non-2xx response throws and execution stops unless you have a try/catch. With muteHttpExceptions: true, execution continues past the fetch call and you decide what to do based on getResponseCode().
My Authorization header is inside options.headers and I still get 403. What next?
Check the token source. ScriptApp.getOAuthToken() only covers Google APIs. For third-party APIs you need an API key or a separately fetched OAuth token — often stored in PropertiesService. Log the first 20 characters of the token and confirm it matches the format the API expects (Bearer vs. Basic vs. a raw key).
The API returns 403 with the body 'Daily Limit for Unauthenticated Use Exceeded'. Is that a header bug?
No. That specific message means the request reached Google unauthenticated and hit the public quota ceiling — typically 100 requests per 100 seconds per project. It confirms the Authorization header was missing or ignored, which brings you back to the header placement check.
Can I use muteHttpExceptions: true in production, or is it only for debugging?
It is fine in production. The pattern is: fetch with muteHttpExceptions: true, check getResponseCode(), throw your own descriptive error or retry based on the code. That is strictly better than the default throw because you can log the body and route on specific codes like 429 (rate limit) vs. 403 (auth).