← Back to lab

Google's Indexing API 403'd on 4 of 6 Domains for 3 Months. The Fix Isn't in the UI.

Four of six domains silently lost Indexing API pushes for 3 months. FullUser covers stats but not publishing, and the Owner fix needs a DNS TXT record.

Google's Indexing API 403'd on 4 of 6 Domains for 3 Months. The Fix Isn't in the UI.

Sites: 6 GSC properties | Outage: 2026-04 → 2026-07 | Status: fixed, then hit again in October

In April, Google's Indexing API started returning 403 PERMISSION_DENIED — "Failed to verify the URL ownership" — on four of my domains. Two domains, running the same code with the same service account, kept returning 200. The difference was one Search Console permission level, and the screen that assigns it doesn't exist in the UI.

I run programmatic-SEO catalogs where discovery speed is the whole game, so every new and refreshed URL goes into a queue table and gets pushed via urlNotifications.publish. When the pushes started failing, nothing crashed. Nothing alerted. The sites just stopped getting indexed.

The symptom

One service account, six domain properties, identical pipeline:

GSC property roleIndexing API result
siteFullUser × 4403 PERMISSION_DENIED
siteOwner × 2200

That table took three months to build. The 403 message points at the URL — "Failed to verify the URL ownership" — so I spent that window debugging the URLs: sitemap syntax, canonical tags, redirect chains, property type (sc-domain vs URL-prefix). The URLs were fine. The account was unauthorized.

Why nothing noticed

Two failures stacked.

First: siteFullUser is enough for the Search Analytics API. My dashboards kept pulling impressions and clicks off the same key the whole time. Every observable signal said the credential worked.

Second, the real one: the queue worker marked URLs last_processed before the API call, not after. Every 403'd send still burned its queue row. By the time I found it, the two largest backlogs held 29,700 and 188,000 URLs — waiting on a channel that had been rejecting everything for a quarter.

A 403 you can see is annoying. A 403 your own bookkeeping records as success is a data-corruption bug wearing an auth costume.

The fix isn't in the UI

Search Console's Users & permissions page can grant a service account Full user or Restricted. It cannot make a service account an Owner — that role can't be added from the UI at all. The path is the Site Verification API, and it's three calls:

# 1. Mint a DNS token (auth = the service account's own key)
curl -s -X POST \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  https://www.googleapis.com/siteVerification/v1/token \
  -d '{"verificationMethod":"DNS","site":{"identifier":"example.com","type":"INET_DOMAIN"}}'
# → {"token": "site-verification=abc123..."}

# 2. Publish that token as a TXT record on the domain.
#    Google re-checked it via 1.1.1.1 within ~10 seconds of the DNS write.

# 3. Verify and register — this is what makes the SA an Owner
curl -s -X POST \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  "https://www.googleapis.com/siteVerification/v1/webResource?verificationMethod=DNS" \
  -d '{"site":{"identifier":"example.com","type":"INET_DOMAIN"}}'

One API detail cost me a detour: there is no verify method. Verification is an insert on webResource. The discovery document lists getToken/insert/list/get/update/patch/delete, and that's it.

After the TXT record resolved, the service account appeared as siteOwner on all four properties and urlNotifications.publish returned 200 the same hour. Three months of outage, closed by two POSTs and one DNS record.

It came back in October

A new site, a fresh GCP project, a new service account. I granted it Full user in the UI — stats pulls worked immediately, so I assumed indexing was wired. First Indexing API call: 403, same message. Same cause: FullUser, not Owner.

The recovery added two fresh-project gotchas:

# Fresh GCP projects don't have the API on — 403 before any permission check
gcloud services enable indexing.googleapis.com

And one from my own stack: the site's settings store silently serialized the credential array to the literal string "Array" because the column expected JSON and got a string. The stats API failing with a garbage key would have been a faster diagnosis than the permission maze — it failed quietly instead.

Same TXT flow, fixed again in an afternoon. The second occurrence is what turned this from "incident" into "checklist item."

The quota you hit on day one

Indexing API default quota is ~200 publish requests per day, per project — and it's shared across every domain using that service account. The new site has ~6,400 pages. Day one: ~200 URLs pushed, then 429s (Publish requests per day, which is the normal ceiling, not an error to chase). The remaining ~6,200 ride the sitemap.

At default quota, pushing a 6,400-page catalog by API takes a month. The Indexing API is a nudge for priority URLs, not a firehose for backfill. Budget it accordingly: queue your money pages and fresh content for pushes, let the sitemap carry the long tail.

What I'd change

Assert ownership at setup time, not after the first 403: one test publish call whose response is checked for 200, plus webResource.list to confirm the SA actually holds the Owner bit. And treat any PERMISSION_DENIED as stop-the-line for the queue — mark rows processed only after the call succeeds, because a retry loop that eats its own backlog is worse than no queue at all.