How to Submit Job Postings to the Google Indexing API (Step by Step)
TL;DR
Wiring up the Indexing API takes five pieces: a Google Cloud service account, adding that account as a Search Console Owner (the step people miss — skip it and every call fails with a permissions error), a JWT-authenticated client, a daily-quota counter with a safety margin, and a dedup layer keyed by job ID so a scheduled run never resubmits the same URL twice. A complete reference implementation is included.
Once you know what the Indexing API does and what quota you're working with (covered in the quota guide), the actual integration has five pieces: a Google Cloud service account, Search Console ownership verification for that account, a JWT-authenticated client, a batching/rate-limiting layer so you don't blow through your daily quota in one run, and a dedup layer so you don't resend the same URL every time your job runs. This walks through each one, with a complete reference implementation at the end.
Step 1: Create a Google Cloud Project and Enable the API
- In the Google Cloud Console, create a project (or use an existing one).
- Go to APIs & Services → Library, search for "Web Search Indexing API" (this is the Indexing API's listing name), and enable it.
Step 2: Create a Service Account
The Indexing API authenticates via a service account, not your personal Google login — this is what lets a server process call it unattended.
- IAM & Admin → Service Accounts → Create Service Account. Give it a name like
indexing-api-bot. - No project-level role is required for the Indexing API call itself — the permission that matters is granted in Search Console, not GCP IAM (next step).
- Create a JSON key for the service account (Keys → Add Key → Create new key → JSON) and download it. This file contains a private key — treat it like a password. Don't commit it to your repo.
Step 3: Add the Service Account as a Search Console Owner
This is the step people miss, and the one that produces a confusing 403 if skipped: the service account needs to be added as an owner of the property in Search Console, exactly as if it were a real user.
- In Search Console, open the property for your domain.
- Settings → Users and permissions → Add user.
- Enter the service account's email address (the
client_emailfield in the JSON key you downloaded, looks like[email protected]). - Set permission level to Owner — Full is not sufficient; the Indexing API specifically requires Owner-level access on the property.
If you skip this, every publish call will succeed at the HTTP-auth layer (you'll get a valid access token) but fail with a permissions error at the Indexing API level, because Google checks Search Console ownership of the target domain before accepting the notification.
Step 4: Authenticate with a JWT Client
Server-to-server calls to Google APIs use a signed JWT, exchanged for a short-lived OAuth access token. In Node, google-auth-library's JWT class handles this:
import { JWT } from 'google-auth-library';
// Store the downloaded JSON key base64-encoded in an env var —
// keeps a multi-line private key out of .env file quoting issues.
const key = JSON.parse(
Buffer.from(process.env.GOOGLE_SERVICE_ACCOUNT_B64!, 'base64').toString('utf-8')
);
const jwtClient = new JWT({
email: key.client_email,
key: key.private_key,
scopes: ['https://www.googleapis.com/auth/indexing'],
});
const { token } = await jwtClient.getAccessToken();To produce GOOGLE_SERVICE_ACCOUNT_B64: take the JSON key file you downloaded in Step 2 and base64-encode the whole file (base64 -i service-account.json on macOS/Linux), then store that single-line string as the env var. This sidesteps having to escape newlines inside the private key when it's embedded in a .env file or a platform's environment-variable UI.
Step 5: Publish a URL
With a valid access token, the actual publish call is a single POST:
const res = await fetch('https://indexing.googleapis.com/v3/urlNotifications:publish', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
url: 'https://myjobboard.com/jobs/12345',
type: 'URL_UPDATED', // or 'URL_DELETED' for a closed listing
}),
});That's the whole API surface for publishing. The engineering work is entirely in what happens around this call — deciding which URLs to send, how many per run, and not re-sending ones you've already submitted.
Step 6: Rate Limiting and Daily Quota Tracking
Google enforces your daily quota server-side (see the quota guide for checking your actual limit), but relying on Google's 429 to tell you when to stop means every run that hits the ceiling wastes calls on rejected requests and complicates your error handling. Track your own count and stop before you get there.
A Redis-backed counter, keyed by day, is a simple way to do this:
const todayStr = new Date().toISOString().split('T')[0];
const countKey = `google_indexing:sent_count:${todayStr}`;
const sentCount = Number((await redis.get(countKey)) || '0');
const DAILY_LIMIT = 180; // stay under your actual quota, not at the edge of it
if (sentCount >= DAILY_LIMIT) {
return { sent: 0, reason: `Daily limit of ${DAILY_LIMIT} reached.` };
}Two things worth building in beyond a bare counter:
- A safety margin below your real quota, not a value equal to it — a 200/day quota means budgeting for ~180, not 199, so a miscount or a second concurrent run doesn't tip you over into 429s.
- A per-call cooldown, separate from the daily cap. Google enforces 380 requests/minute across all Indexing API endpoints combined (180/minute for read-only metadata calls specifically) — see the quota guide for the source. One request per second (as in the reference implementation below) stays comfortably under that per-minute cap even in a burst, independent of your daily budget.
Step 7: Deduplicating Against Already-Submitted URLs
Without dedup, a scheduled job re-fetching "recently published jobs" on every run will resubmit the same URLs it already sent last run, burning quota on no-op notifications. Track what you've already sent, with a TTL long enough to cover the job's realistic lifetime on your site:
const alreadySent = await redis.get(`google_indexing:sent_job:${jobId}`);
if (alreadySent) continue; // skip, already notified Google about this one
// ...after a successful publish call:
await redis.set(`google_indexing:sent_job:${jobId}`, '1', 'EX', 7 * 24 * 3600); // 7-day TTL
await redis.incr(countKey);A 7-day TTL is a reasonable default for job postings specifically — long enough that you won't resend a listing that's still open, short enough that Redis doesn't accumulate an unbounded key set for a high-volume board. If a listing's content genuinely changes after that window (a salary update, a re-opened role), letting it get resent is the correct behavior, not a bug.
Putting It Together
A complete run, combining everything above:
import { JWT } from 'google-auth-library';
const DAILY_LIMIT = 180;
const COOLDOWN_MS = 1000;
async function submitRecentJobsToGoogle(redis: Redis, apiKey: string) {
const todayStr = new Date().toISOString().split('T')[0];
const countKey = `google_indexing:sent_count:${todayStr}`;
const sentToday = Number((await redis.get(countKey)) || '0');
const slotsLeft = DAILY_LIMIT - sentToday;
if (slotsLeft <= 0) return { sent: 0, reason: 'Daily limit reached.' };
// Pull recently-ingested jobs — created_max_age catches late-arriving
// postings regardless of the employer's original publish date.
const res = await fetch('https://api.cleanjobdata.com/jobs?created_max_age=2h&limit=200', {
headers: { 'X-API-Key': apiKey },
});
const { data: jobs } = await res.json();
const pending = [];
for (const job of jobs) {
const sent = await redis.get(`google_indexing:sent_job:${job.id}`);
if (!sent) pending.push(job);
}
const batch = pending.slice(0, slotsLeft);
if (batch.length === 0) return { sent: 0, reason: 'No new jobs to submit.' };
const key = JSON.parse(Buffer.from(process.env.GOOGLE_SERVICE_ACCOUNT_B64!, 'base64').toString('utf-8'));
const jwtClient = new JWT({ email: key.client_email, key: key.private_key, scopes: ['https://www.googleapis.com/auth/indexing'] });
const { token } = await jwtClient.getAccessToken();
let sent = 0;
for (const job of batch) {
const url = `https://myjobboard.com/jobs/${job.id}`;
const publishRes = await fetch('https://indexing.googleapis.com/v3/urlNotifications:publish', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ url, type: 'URL_UPDATED' }),
});
if (publishRes.ok) {
sent++;
await redis.set(`google_indexing:sent_job:${job.id}`, '1', 'EX', 7 * 24 * 3600);
await redis.incr(countKey);
await redis.expire(countKey, 48 * 3600);
}
await new Promise((r) => setTimeout(r, COOLDOWN_MS));
}
return { sent, reason: `Sent ${sent} job URL(s).` };
}Run this on a schedule (a cron job, a queue worker, a serverless scheduled function — anything that fires reliably every 15–60 minutes) against whatever job source you're syncing from. If you're pulling listings from CleanJobData specifically, created_max_age is the right filter to drive "what's new since last run" — see syncing job data in depth for why that field, and not the employer's published date, is the correct checkpoint for this kind of incremental job.
What to Watch After Launch
- Check Search Console's Indexing API quota page periodically (covered in the quota guide) — if you're consistently hitting your
DAILY_LIMITbefore running out of pending jobs, that's your signal to request a quota increase, not to raise your own cap and start hitting Google's actual 429s instead. - Log failures with the job ID, not just the count — a run that fails 12 of 180 calls is much easier to debug when you know which 12, especially if it's the same handful of URLs failing repeatedly (often a sign of a malformed URL or a page that 404s).
- Don't treat a successful publish call as a guarantee of indexing. The API confirms Google received your notification, not that the page has been crawled, indexed, or ranked — those still depend on Google's normal quality and crawl processes.