I needed to submit 174 URLs to Google's Indexing API from a production server running Python 3.8 with system pip. The official quickstart uses google-auth, which meant either polluting system packages or spinning up a venv just to run one script.
So I did it with Node's built-in crypto instead. Zero dependencies, no install step.
What the API actually needs
Google's Indexing API takes an OAuth 2.0 access token from a service account. The flow is:
- Sign a JWT with the service account's private key (RS256)
- Exchange that JWT for an access token
- Call the API with
Authorization: Bearer <token>
Most tutorials stop at "use the library". Here's what's underneath.
Step 1: sign the JWT
import { createSign } from 'node:crypto';
import { readFileSync } from 'node:fs';
const b64url = (buf) => Buffer.from(buf).toString('base64')
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
const key = JSON.parse(readFileSync('service-account.json', 'utf8'));
const now = Math.floor(Date.now() / 1000);
const signingInput = [
b64url(JSON.stringify({ alg: 'RS256', typ: 'JWT' })),
b64url(JSON.stringify({
iss: key.client_email,
scope: 'https://www.googleapis.com/auth/indexing',
aud: 'https://oauth2.googleapis.com/token',
iat: now,
exp: now + 3600,
})),
].join('.');
const signer = createSign('RSA-SHA256');
signer.update(signingInput);
const assertion = `${signingInput}.${b64url(signer.sign(key.private_key))}`;
Two details that bite people:
- base64url, not base64. JWT uses the URL-safe alphabet with padding stripped. Standard base64 gives you a token that looks fine and validates as garbage.
-
createSigntakes the PEM as-is. No need to strip the-----BEGIN PRIVATE KEY-----header — Node parses it.
Step 2: exchange for an access token
const res = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
assertion,
}).toString(),
});
const { access_token } = await res.json();
That grant_type value is a mouthful and easy to typo. It's jwt-bearer, not jwt or bearer.
Step 3: publish the URL
await fetch('https://indexing.googleapis.com/v3/urlNotifications:publish', {
method: 'POST',
headers: {
authorization: `Bearer ${access_token}`,
'content-type': 'application/json',
},
body: JSON.stringify({
url: 'https://example.com/page',
type: 'URL_UPDATED',
}),
});
A 200 with {"urlNotificationMetadata":{"url":"..."}} means accepted. Not indexed — accepted. There's a difference and it will save you a support ticket.
The gotchas
The service account must be a Search Console owner
Creating the service account and downloading the key is the easy part. What the docs bury:
Go to Search Console → Settings → Users and permissions → Add user, paste the service account email (looks like name@project-id.iam.gserviceaccount.com), grant Owner.
Skip this and every request returns 403 with no useful detail about why.
Quota is 200 URLs per day
Hitting the limit returns HTTP 429. My sitemap had 174 URLs, so I got exactly one full pass per day. Submit more than once and the second run fails partway — log which URLs succeeded so tomorrow's run doesn't waste quota re-submitting them.
Bing's numbers are different
Bing's URL Submission API docs mention 10,000 URLs per day. The real limit, as of now:
{"ErrorCode":8,"Message":"ERROR!!! You have exceeded your daily url submission quota : 100"}
100 per site per day. Their batch endpoint (SubmitUrlbatch) exists but it's subject to the same 100. Plan for two days on a 174-URL site.
IndexNow and the Indexing API are not interchangeable
Google doesn't participate in IndexNow — it only accepts Search Console. Bing, Yandex, Seznam, and Naver do accept IndexNow and don't need an API key at all.
If you want both search ecosystems covered, you need both mechanisms:
| Channel | Covers | Auth needed |
|---|---|---|
| IndexNow | Bing, Yandex, Seznam, Naver | Just a key file on your server |
| Indexing API | Service account + Search Console owner |
Why use the API at all if I have a sitemap?
A sitemap says "here's what I have". The crawler decides when to care. The Indexing API says "this page changed, come now". On a new site with no domain authority, that's the difference between weeks and hours.
Whether it moves rankings is another question — it doesn't. It just gets you into the queue faster, which is a prerequisite for everything else.
I built this while working on the SEO layer for XNOW PRO, a social media growth panel. The part that took the longest wasn't the indexing — it was rendering 174 pages across 7 languages with correct hreflang clusters and RTL support. The pricing page is a good example: it renders real-time catalog data server-side so crawlers see actual prices instead of a loading spinner.
Top comments (0)