// Apps Script

Fix "Unexpected token < in JSON at position 0" in Apps Script.

The less-than sign at position 0 means your API returned an HTML error page, not JSON. Learn how to capture and read that error body using muteHttpExceptions, getResponseCode(), and getContentText() before calling JSON.parse().

I called an external API from Apps Script and JSON.parse() threw "Unexpected token < in JSON at position 0", and I need to find out what the server actually sent back.

The script

copy · paste · trigger
fetchWithGuard.gs
Apps Script
// Safe fetch: inspect status + Content-Type before parsing
function fetchWithGuard(url, options) {
  options = options || {};
  options.muteHttpExceptions = true;

  var response = UrlFetchApp.fetch(url, options);
  var code = response.getResponseCode();
  var contentType = response.getHeaders()['Content-Type'] || '';
  var body = response.getContentText();

  if (code !== 200) {
    throw new Error('HTTP ' + code + ': ' + body.substring(0, 300));
  }
  if (contentType.indexOf('application/json') === -1) {
    throw new Error('Expected JSON, got: ' + contentType + ' | ' + body.substring(0, 300));
  }
  return JSON.parse(body);
}

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

Walkthrough

What the < actually tells you

The error message is literal. JSON.parse() read the first character of the response, found <, and stopped. The < is the opening of <!DOCTYPE html> — the server sent an HTML page instead of a JSON payload. That HTML is almost always a 401 login redirect, a 403 forbidden page, a 404 not-found page, or a Cloudflare/WAF interstitial. Your code never sees any of that because, by default, UrlFetchApp.fetch() throws on non-2xx responses and swallows the body.

The first time I hit this, I spent twenty minutes second-guessing my JSON.stringify() call on the request side. The response body was the actual clue the whole time — I just had no way to read it.

Why muteHttpExceptions is the first fix, not JSON.parse wrapping

A common reflex is to wrap JSON.parse() in a try/catch and log the error. That still tells you nothing about what the server sent. The correct move is to set muteHttpExceptions: true on the fetch options before the request goes out. Without it, Apps Script throws its own generic exception on any 4xx or 5xx, discarding the response body entirely.

With muteHttpExceptions on, a 401 or 404 comes back as a normal HTTPResponse object. You can then call getResponseCode() to read the HTTP status code (an integer, not a string) and getContentText() to read the HTML body that explains the failure. Those 300 characters of body text will almost always name the exact problem: an expired API key, a wrong endpoint path, a missing OAuth scope, or a rate-limit message.

Check the Content-Type header too. A response can have status 200 and still contain HTML — some APIs return 200 with an error page when a session has expired. If Content-Type is text/html rather than application/json, do not call JSON.parse().

The pattern in the snippet above is what I keep in a shared utils file across every project that calls external APIs from Apps Script. The three checks — status code, Content-Type, then parse — take twelve lines and have prevented this error from surfacing in production more than once.

Common root causes and their fixes

Wrong base URL. Typing https://api.example.com/v1 when the endpoint is https://api.example.com/v2 often hits a redirect page, not a 404, because the server serves a marketing page at the old version path. Verify the exact URL in a browser or curl first.

Expired or missing API key. Most REST APIs return a 401 or 403 with a JSON body, but some — especially older enterprise APIs and anything behind a reverse proxy — return an HTML login page. If getResponseCode() returns 401 and the body starts with <!DOCTYPE, the key is wrong or missing from the Authorization header.

OAuth token has expired. Apps Script's ScriptApp.getOAuthToken() is valid for roughly one hour. A trigger that ran fine interactively can fail in a scheduled run if the token isn't refreshed. Calling ScriptApp.getOAuthToken() at the top of the triggered function gets a fresh token each execution.

Cloudflare or similar WAF blocking the request. Apps Script's outbound IP ranges are known and occasionally rate-limited by WAF rules. The response will be a Cloudflare HTML challenge page with status 403 or 429. There is no in-script workaround for this; the fix is on the API provider side or requires routing through a proxy.

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
Why does the error say position 0 specifically?
Position 0 means the very first character of the string failed to parse. JSON must start with {, [, ", a digit, t (true), f (false), or n (null). The < from <!DOCTYPE html> matches none of those, so the parser fails immediately without reading further.
I set muteHttpExceptions to true but I'm still getting the same error. What else could it be?
If the response code is 200 and Content-Type is application/json but parsing still fails, the body likely contains a JSON syntax error from the API itself — a trailing comma, an unescaped quote, or a truncated payload due to a size limit. Log body.substring(0, 500) to see the raw text. Apps Script's UrlFetchApp has a 50 MB response size limit; responses above that are silently truncated, producing broken JSON.
Does this error ever happen with Google's own APIs called from Apps Script?
Yes, most often when the OAuth scope for the API is missing from the appsscript.json manifest. Google's APIs return a 401 HTML page rather than a JSON error when the request arrives without a valid token. Add the missing scope to the oauthScopes array in appsscript.json and re-authorize the script.
Is there a way to make JSON.parse() return null instead of throwing when the input is bad?
You can wrap it: var data = null; try { data = JSON.parse(body); } catch(e) { data = null; }. That stops the script from crashing, but it hides the root cause. Better to check getResponseCode() and Content-Type first so you know why the parse failed, then decide whether to retry, log, or alert.