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.