DEV Community

Cover image for What I learned debugging GSC URL Inspection API 403: sc-domain vs URL-prefix properties
MORINAGA
MORINAGA

Posted on

What I learned debugging GSC URL Inspection API 403: sc-domain vs URL-prefix properties

I ran the Google Search Console URL Inspection API against my three Vercel sites and got 403 PERMISSION_DENIED from every request. The Service Account was valid, the OAuth scope was correct, and the inspection URLs themselves were accurate. The error body gave no indication of what was wrong.

The fix was a single line: sc-domain:${host}https://${host}/. The underlying cause — a mismatch between the property type registered in Search Console and the format of the siteUrl parameter — isn't obvious until you've read both the property registration docs and the API reference carefully. I hadn't done that.

This is what I found and how I diagnosed it.

The two GSC property types and why the API cares

Google Search Console registers sites in one of two formats, and the format determines how the URL Inspection API validates your access:

Property type siteUrl format DNS verification?
Domain property sc-domain:<host> Required (TXT at root domain)
URL-prefix property https://<host>/ Optional (also supports HTML tag, meta tag, or file)

I registered the three directory sites in April using Cloudflare DNS TXT verification. That verification step completed successfully — all three sites showed as owned in the Search Console interface. What I missed: DNS TXT verification works for both property types, but it doesn't mean you ended up with a domain property. The registration wizard defaults to URL-prefix unless you explicitly choose domain.

When you call the URL Inspection API, the siteUrl parameter must match the property format exactly as it's registered. If you pass sc-domain:aiappdex.com for a site registered as https://aiappdex.com/, GSC interprets that as a different property — one you don't have access to — and returns:

{
  "error": {
    "code": 403,
    "message": "The caller does not have permission",
    "status": "PERMISSION_DENIED"
  }
}
Enter fullscreen mode Exit fullscreen mode

There's no mention of "property type mismatch" in the error body. The message is identical to what you'd see if the Service Account genuinely lacked access. That's what made this hard to debug — the error looks like an auth problem, not a parameter format problem.

How I had it wrong in gsc-inspect.mjs

My inspection script built the siteUrl from the inspection URL's hostname:

// Before the fix
const host = new URL(url).host;
const siteUrl = `sc-domain:${host}`;

try {
  const result = await inspectUrl(accessToken, url, siteUrl);
  // ...
} catch (e) {
  console.error(JSON.stringify({ url, error: e.message }));
}
Enter fullscreen mode Exit fullscreen mode

I copied the sc-domain: prefix from the first Google developer guide example I found. That example happened to use a domain property, and I didn't re-read it when I registered my sites. The Service Account JWT authentication flow was working correctly — the problem was entirely downstream of auth, in the siteUrl value.

The error path in the catch block just printed the message. Since the message was generic, I spent time checking things that weren't broken: re-reading the OAuth token exchange, verifying the scope was webmasters.readonly, confirming the Service Account email appeared in Search Console's Users and Permissions panel. All correct.

The fix came from re-reading the API reference's siteUrl field description: "The site's URL, following the same format used in Google Search Console." That sentence is doing a lot of work. "The same format used in GSC" means check what format GSC actually shows for your property — not what format a documentation example uses.

The one-line fix and live verification

// After the fix
const host = new URL(url).host;
// Registered as URL-prefix properties (https://host/), not domain properties.
// sc-domain:<host> returns 403 PERMISSION_DENIED for URL-prefix registrations.
const siteUrl = `https://${host}/`;
Enter fullscreen mode Exit fullscreen mode

Immediately after the change, running against aiappdex.com's home page returned:

{
  "url": "https://aiappdex.com/",
  "coverageState": "送信して登録されました",
  "indexingState": "INDEXING_STATE_INDEXED",
  "lastCrawlTime": "2026-07-23T02:31:00Z",
  "googleCanonical": "https://aiappdex.com/",
  "userCanonical": "https://aiappdex.com/",
  "verdict": "PASS"
}
Enter fullscreen mode Exit fullscreen mode

findindiegame.com and ossfind.com returned the same shape. All three home pages: indexed, with crawl times from the same day.

One sample detail page — /models/timm-vit-base-patch16-clip-224-openai/ on aiappdex.com — also returned INDEXING_STATE_INDEXED. The sitemap setup and IndexNow pings I've been running since launch are doing their job at least for that class of URL.

What the indexing data tells me (and what it doesn't)

Coverage state and indexing state confirm that Google has crawled and added a URL to its index. They tell me nothing about ranking position or traffic.

My immediate question was: how long does it take from publication to indexed state? The script now runs on a daily GitHub Actions cron and logs each result as JSON:

{ "url": "...", "coverageState": "...", "indexingState": "...", "lastCrawlTime": "..." }
Enter fullscreen mode Exit fullscreen mode

I have a growing dataset for the three sites. I'm not publishing indexing rates yet — I need at least 30 days of data before I can say anything that isn't noise. If the AdSense approval timeline is sensitive to indexing velocity, I'll know it by month three.

Diagnosing the same problem in your own project

If you're hitting 403 from the URL Inspection API and the Service Account auth looks correct, work through this checklist:

  1. Check your property type in GSC. Open Search Console, click the property dropdown in the left sidebar. URL-prefix properties display as https://host/; domain properties display as sc-domain:host. Your siteUrl must use that exact format.

  2. Verify the Service Account email has explicit GSC access. GSC → Settings → Users and permissions. Cloud IAM roles don't grant GSC access automatically. The Service Account email must appear in the GSC users list.

  3. Confirm the inspection URL is within the property's scope. For https://aiappdex.com/, any URL starting with that prefix is in scope. For a URL-prefix property rooted at a path (e.g., https://example.com/blog/), only URLs under that path are accessible.

  4. Check the OAuth scope. The inspection endpoint requires at minimum webmasters.readonly. A Service Account with webmasters (read-write) scope also works.

The GSC pipeline health monitor tracks when these daily inspection runs fail and opens a GitHub issue automatically — which is how I noticed this bug before it silently aged into months of missing data. A cron that fails silently is indistinguishable from one that's working until you look at the output directly.

Should I switch to domain properties?

Domain properties are broader: they cover all protocols (http://, https://), all subdomains, and don't require separate verification per subdomain. They require DNS TXT verification (no HTML file or tag option), but I'm already using Cloudflare DNS for all three sites, so that's not a constraint.

If I were starting fresh today, I'd register domain properties. The URL-prefix format adds friction: if I ever move to a subdomain, add an http:// redirect, or want to inspect URLs under a different prefix, I'd need a separate property per variation.

For the existing three sites, I'm not switching mid-experiment. Property type changes in GSC require re-verification and temporarily lose historical data. Three months into a six-month experiment, that's a cost I don't want to pay for what amounts to a housekeeping improvement.

The https://${host}/ format works correctly for URL-prefix properties, the data is flowing, and I can revisit domain properties when I'm prepared to also migrate the historical coverage data into the new property's annotated view.

FAQ

Q: Does the URL Inspection API have a cost?

No billing beyond the standard Google Cloud project setup. The API has a free quota of 2,000 requests per day per project. At 6 URLs per daily run (3 sites, home + one sample detail page each), this is nowhere near the limit.

Q: Will this fix work if I have both URL-prefix and domain properties for the same site?

Yes — you'd just pass the siteUrl that matches whichever property you want to inspect against. You can have both registered simultaneously in GSC.

Q: The error says PERMISSION_DENIED but I know the account has access. What else could cause it?

Two other causes I've seen: (1) the scope in the JWT claim doesn't include webmasters.readonly, so the access token lacks the required permission; (2) the access token has expired (tokens are valid for 1 hour) and you're not refreshing before the inspection loop. The script I use generates a fresh token per run, which avoids the expiry case.

Q: How do I know if a URL-prefix property includes a trailing slash?

Always add one. The GSC interface shows URL-prefix properties with a trailing slash (https://aiappdex.com/), and the API validates against that exact string. https://aiappdex.com (no slash) would be treated as a different, unregistered property and return the same 403.

Q: Is coverageState localized?

Yes. I set languageCode: "ja-JP" in my request body because the script runs on infrastructure in Japan. Passing en-US returns English strings. The API reference doesn't say this clearly; I found it by testing both values.

The official URL Inspection API reference is at developers.google.com/webmaster-tools/v1/urlInspection.index/inspect — the siteUrl field description is what finally made the property-type distinction clear to me.


Part of an ongoing 6-month experiment running three AI-curated directory sites. The technical claims here are real; this article was AI-assisted.

Top comments (0)