Search Console API returns 403: four causes, one fix each
Google's 403 carries a reason code that names which of four failures happened. Read the reason before you change anything, because the fix is different in each case.

A 403 from the Google Search Console API is one of four failures, and the response body names which one. Google returns a reason code inside the error object: insufficientPermissions means the authenticated account has no rights on that property, accessNotConfigured means the Cloud project has not enabled the API, dailyLimitExceeded means quota, and a 403 whose message reads "Request had insufficient authentication scopes" means the access token was issued without the Search Console scope. Read the reason string first. Three of the four fixes are in a different place from the code, and guessing sends you to the wrong one.
Google's Search Console API documents its 403 reason codes under the heading FORBIDDEN (403), and the reason is what separates them. The error payload nests them: error.errors[0].reason holds the code, error.message holds the sentence. Log both before retrying anything, because a retry loop that ignores the reason will hammer a quota failure and will never fix a permission failure.
| Reason code | What it means | Where the fix is |
|---|---|---|
| insufficientPermissions | The authenticated user does not have sufficient permissions to execute this request | Search Console property users list |
| accessNotConfigured | Your project is not configured to access this API | Google Cloud console, API library |
| dailyLimitExceeded | A daily quota limit for the API has been reached | Your own call schedule, or a quota increase |
| Message names insufficient authentication scopes | The token was issued without the Search Console scope | Your OAuth consent request, then a reconnect |
Search Console grants access per Google account and per property, and sites.list returns a permissionLevel for every property the authenticated account can see. The four values are siteOwner, siteFullUser, siteRestrictedUser and siteUnverifiedUser. A restricted user can read some reports and cannot submit a sitemap; an unverified user is listed but holds nothing. If the API answers insufficientPermissions, call sites.list first and read the permissionLevel of the property you are asking about, because that single field usually explains the refusal without any further debugging.
Market4 makes the same check before it lets an app link a property: a row below siteFullUser comes back unselectable with the message "Insufficient permission (siteRestrictedUser) — siteOwner or siteFullUser is required." The point of doing it at link time is that the alternative is a 403 weeks later, in a scheduled report

The choice is not OAuth against service accounts. Both are OAuth 2.0. The question is whose Google account holds permission on the property, and whether a person has to be there.

The submission itself can be undone. The saved address is the part that keeps acting on its own, because it is re-submitted every time you publish.
Market4 turns one release note into a changelog page, a blog post, a mail-out and a week of social posts — and then tells you which of them brought anyone back.
accessNotConfigured is the 403 that has nothing to do with the user. Google's error reference gives it three descriptions: the project is not configured to access this API, the project has been blocked due to abuse, or the project has been marked for deletion. The first is by far the ordinary one, and it happens when credentials are created in a new Cloud project and the Search Console API is never switched on in that project's API library. Nothing about the OAuth flow warns you, because consent is granted against scopes and enablement is a property of the project.
The Search Console API defines two OAuth scopes: https://www.googleapis.com/auth/webmasters for read and write access, and https://www.googleapis.com/auth/webmasters.readonly for read-only. A token minted for Analytics alone, or for the read-only scope when the call writes, produces a 403 whose message reads "Request had insufficient authentication scopes". An HTTP status cannot separate this case from a permission failure, so match on the message text rather than on the status.
There is a second-order consequence worth planning for. Adding a scope to an application does not upgrade the tokens it already holds. Every stored refresh token was granted against the scope list that existed at consent time, so a scope added on Tuesday reaches a customer connected on Monday only when that customer reconnects. Detect the message, mark the connection as needing a reconnect, and say so on the screen; a silent retry cannot fix it.
The Search Console API lists dailyLimitExceeded and quotaExceeded under FORBIDDEN (403), and lists rateLimitExceeded separately under TOO_MANY_REQUESTS (429). So a client that treats 429 as "back off" and 403 as "stop and alert a human" will alert a human every time it exhausts its daily quota, which is the wrong response to a condition that clears at midnight. Match on the reason code, not on the status, and let dailyLimitExceeded schedule a retry for the next quota window instead of paging somebody.
One more 403 looks like a permission problem and is not. Search Console property identifiers come in two shapes: a domain property is "sc-domain:example.com" with no scheme and no slash, and a URL-prefix property is "https://example.com/" with a trailing slash. Every later call matches that string exactly, so code that strips the sc-domain prefix to make a URL, or that normalises away the trailing slash, is asking about a property the account does not hold. Carry the value through verbatim from sites.list and never rebuild it from a hostname.
No. A 403 is a decision about the API request, not about the site. It says the authenticated account may not perform that call: no permission on the property, an API not enabled in the Cloud project, a token without the Search Console scope, or an exhausted daily quota. Manual actions and indexing problems are reported inside Search Console and through the reports themselves, never as an HTTP status on the request.
Because permission in Search Console is granted per property and per Google account. One account can be siteOwner on one property and siteRestrictedUser, or absent altogether, on the next. Call sites.list with the same credentials and compare the permissionLevel of the two properties. If the failing one is missing from the list, the account has no access at all, and the fix is an owner adding it in Search Console rather than anything in your code.
Only when the reason code is dailyLimitExceeded or quotaExceeded, and then not immediately: a daily quota clears on Google's schedule, so the retry belongs in the next window rather than in a loop. insufficientPermissions, accessNotConfigured and a missing-scope 403 are all states that no number of retries changes, because the fix is in Search Console, in the Cloud project, or in the consent your application asks for.
By the message, because both arrive as 403. Google marks the scope case with the sentence "Request had insufficient authentication scopes" in the error message, while a permission failure carries the reason code insufficientPermissions. Match the message text for the first and the reason code for the second, and route them to different screens: one asks the user to reconnect Google, the other asks an owner of the property to grant access.

Inspecting a URL costs quota. Explaining what the inspection meant does not. The two are separate calls so that the expensive one happens when somebody asked for it.