# Google Indexing API for job postings in Next.js

> Notify Google when job postings go live, change or close. Service account setup, the 200-a-day quota, URL_UPDATED vs URL_DELETED and tested App Router code.

Source: https://seofix.ai/blog/google-indexing-api-job-postings-nextjs  
Published: 2026-10-29  
Updated: 2026-10-09  
Author: SEOFix Team  
Category: Technical SEO  
Tags: Technical SEO, Guides  
Length: 2,308 words, 11 min read

The Google Indexing API lets a job site tell Google, URL by URL, that a job posting page was published, changed or removed, so Googlebot recrawls it sooner than it would from a sitemap. It only accepts pages with `JobPosting` structured data (or a livestream `BroadcastEvent` inside a `VideoObject`), it needs a service account that is an owner of your Search Console property, and new projects get 200 publish requests a day. Below is the setup from Google's own documentation, the quota as of October 2026, and TypeScript for the Next.js App Router that notifies Google when a job is created, closed or deleted, with batching, retries and error handling. We tested the code against a mock of Google's endpoints; how is at the end.

This is the follow-up to [Job board SEO: the technical fixes from a 37k-page job site](https://seofix.ai/blog/job-board-seo-technical-fixes), which covers expired jobs, `JobPosting` markup and sitemaps for job boards in more depth.

## What the Google Indexing API does for job postings

Google's [quickstart](https://developers.google.com/search/apis/indexing-api/v3/quickstart) (last updated 16 July 2026) is explicit on eligibility:

> The Indexing API can only be used to crawl pages with either JobPosting or BroadcastEvent embedded in a VideoObject.

For those pages Google recommends it over sitemaps, because "the Indexing API prompts Googlebot to crawl your pages sooner than updating the sitemap", and still recommends a sitemap for coverage of the whole site. The [JobPosting documentation](https://developers.google.com/search/docs/appearance/structured-data/job-posting) says the same for job pages specifically.

What the API isn't:

- **A way to index blog posts, category pages or anything else.** Only single-job pages qualify. A search results page or a company page listing ten jobs isn't one: Google says `JobPosting` markup belongs only on pages that contain a single job posting.
- **A guarantee.** A `200` response "means that Google may try to recrawl this URL soon" ([Using the API](https://developers.google.com/search/apis/indexing-api/v3/using-api)). Whether the page gets indexed is still Google's decision.
- **A quota you can multiply.** The quickstart warns that all submissions "undergo rigorous spam detection" and that abuse, "including the use of multiple accounts or other means to exceed usage quotas, may result in access being revoked."

## Setup: service account and Search Console owner

From Google's [prerequisites](https://developers.google.com/search/apis/indexing-api/v3/prereqs):

1. **Create a Google Cloud project and enable the Indexing API.** The prerequisites page links a setup tool that does both.
2. **Create a service account.** Service accounts → **Create Service Account**, name it, and skip the optional permissions section, which Google says isn't required.
3. **Create a JSON key.** On the same screen, **Create key** → JSON. The download is the only copy of the private key.
4. **Verify the site in Search Console**, with any method. A Domain property (`example.com`) or a URL-prefix property (`https://example.com`) both work.
5. **Add the service account as an owner.** In Search Console, open the property, and in the **Verified owner** list click **Add an owner**, then enter the service account's email: the `client_email` field of the JSON key, or the Service account ID column in the Cloud console. Google calls this a delegated owner. Skip this step and every request fails with the 403 below.
6. **Request quota and approval.** The default quota is for "API onboarding and submission testing", and use beyond that "requires additional approval" through the form on the [quota page](https://developers.google.com/search/apis/indexing-api/v3/quota-pricing). You'll need your Cloud project details.

Every request authenticates with an OAuth 2.0 access token for the scope `https://www.googleapis.com/auth/indexing`, obtained by signing a JWT with the service account's private key.

Store the key as one environment variable on the server, never in the repo and never in anything that ships to the browser:

```bash
# .env.local (not committed), or your host's secret settings
GOOGLE_INDEXING_KEY='{"type":"service_account","client_email":"indexer@your-project.iam.gserviceaccount.com","private_key":"-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n",...}'
SITE_URL=https://example.com
```

## Quota as of October 2026

From Google's [quota and pricing page](https://developers.google.com/search/apis/indexing-api/v3/quota-pricing), checked 8 October 2026:

| Quota (per project) | Default |
| --- | --- |
| Publish requests per day (`URL_UPDATED` and `URL_DELETED` together) | 200 |
| Metadata (read-only) requests per minute | 180 |
| All endpoints, requests per minute | 380 |

The daily quota resets at midnight Pacific Time. A batch doesn't save quota: "if you combine 10 requests into a single HTTP request, it still counts as 10 requests". Extra quota comes through the approval form, and Google notes the quota "may increase or decrease based on the document quality". The API costs nothing.

200 a day is tight for a real job board. On the site behind our [job board article](https://seofix.ai/blog/job-board-seo-technical-fixes), the job sitemap alone lists over 30,000 URLs. Until you have more quota, spend it on new jobs and removals first, and let the sitemap cover edits.

## URL\_UPDATED or URL\_DELETED

Every notification is one URL and one type ([Using the API](https://developers.google.com/search/apis/indexing-api/v3/using-api)):

```json
{ "url": "https://example.com/jobs/senior-accountant-dubai", "type": "URL_UPDATED" }
```

`URL_UPDATED` notifies Google "of a new URL to crawl or that content at a previously-submitted URL has been updated". `URL_DELETED` asks Google to remove the URL, and has a precondition: "the URL must return a 404 or 410 status code or the page must contain `<meta name="robots" content="noindex" />`".

That maps onto job events like this:

| What happened | Page afterwards | Send |
| --- | --- | --- |
| Job published | `200` with `JobPosting` | `URL_UPDATED` |
| Job edited (salary, description, location) | `200` with updated markup | `URL_UPDATED` |
| Job closed, page kept with a "closed" notice | `200`, `validThrough` in the past or no `JobPosting` | `URL_UPDATED` |
| Job deleted | `404` or `410` | `URL_DELETED` |
| Job page set to noindex | `200` with robots `noindex` | `URL_DELETED` |

The closed-but-live row is the one people get wrong. If the page is still live and answers `200` without noindex, `URL_DELETED` doesn't meet the precondition. The page changed, so it's an update, and the recrawl is how Google sees that the job closed.

## The client: lib/indexing-api.ts

Tested with [`google-auth-library`](https://www.npmjs.com/package/google-auth-library) 11.2.0, Next.js 16.4 and Node.js 24. The library signs the JWT, exchanges it for an access token and caches the token until it expires. The requests themselves use plain `fetch`, which makes the batch endpoint easy to reach.

```bash
npm install google-auth-library
```

```typescript
// lib/indexing-api.ts
import { JWT } from "google-auth-library";

const SCOPE = "https://www.googleapis.com/auth/indexing";
const PUBLISH_URL = "https://indexing.googleapis.com/v3/urlNotifications:publish";
const BATCH_URL = "https://indexing.googleapis.com/batch";

export type NotificationType = "URL_UPDATED" | "URL_DELETED";
export type Notification = { url: string; type: NotificationType };

export class IndexingApiError extends Error {
  constructor(public status: number, public reason: string, message: string) {
    super(`${status} ${reason}: ${message}`);
  }
  /** 429 (quota) and 5xx are worth retrying later; 400 and 403 are not. */
  get retryable() {
    return this.status === 429 || this.status >= 500;
  }
}

let client: JWT | undefined;

function auth(): JWT {
  if (!client) {
    // The service account's JSON key, stored as one env var (never commit the file).
    const key = JSON.parse(process.env.GOOGLE_INDEXING_KEY ?? "{}");
    if (!key.client_email || !key.private_key) throw new Error("GOOGLE_INDEXING_KEY is not set");
    client = new JWT({ email: key.client_email, key: key.private_key, scopes: [SCOPE] });
  }
  return client;
}

async function authHeader(): Promise<string> {
  // google-auth-library signs the JWT, exchanges it and caches the token until it expires.
  const { token } = await auth().getAccessToken();
  if (!token) throw new Error("Google returned no access token");
  return `Bearer ${token}`;
}

function toError(status: number, body: string): IndexingApiError {
  try {
    const { error } = JSON.parse(body);
    return new IndexingApiError(status, error?.status ?? "UNKNOWN", error?.message ?? body);
  } catch {
    return new IndexingApiError(status, "UNKNOWN", body.slice(0, 200));
  }
}

/** One URL, one request. Counts as 1 against the daily publish quota. */
export async function notifyGoogle(url: string, type: NotificationType) {
  const res = await fetch(PUBLISH_URL, {
    method: "POST",
    headers: { "Content-Type": "application/json", Authorization: await authHeader() },
    body: JSON.stringify({ url, type }),
  });
  const body = await res.text();
  if (!res.ok) throw toError(res.status, body);
  return JSON.parse(body); // UrlNotificationMetadata
}

export type BatchResult = Notification & { ok: boolean; status: number; error?: IndexingApiError };

/** Up to 100 notifications in one HTTP request. Quota still counts each URL. */
export async function notifyGoogleBatch(items: Notification[]): Promise<BatchResult[]> {
  if (items.length === 0) return [];
  if (items.length > 100) throw new Error("The Indexing API accepts at most 100 calls per batch");

  const boundary = `batch_${crypto.randomUUID()}`;
  const parts = items.map((item, i) =>
    [
      `--${boundary}`,
      "Content-Type: application/http",
      "Content-Transfer-Encoding: binary",
      `Content-ID: <item${i}>`,
      "",
      "POST /v3/urlNotifications:publish",
      "Content-Type: application/json",
      "",
      JSON.stringify({ url: item.url, type: item.type }),
    ].join("\r\n"),
  );
  const res = await fetch(BATCH_URL, {
    method: "POST",
    headers: { "Content-Type": `multipart/mixed; boundary=${boundary}`, Authorization: await authHeader() },
    body: `${parts.join("\r\n")}\r\n--${boundary}--\r\n`,
  });
  const text = await res.text();
  if (!res.ok) throw toError(res.status, text);

  // The response is multipart too. Each part carries Content-ID: <response-itemN>
  // and its own HTTP status line, so one bad URL doesn't fail the others.
  const resBoundary = /boundary="?([^";]+)"?/.exec(res.headers.get("content-type") ?? "")?.[1];
  if (!resBoundary) throw new Error("Batch response has no multipart boundary");
  const results: BatchResult[] = items.map((item) => ({ ...item, ok: false, status: 0 }));
  for (const part of text.split(`--${resBoundary}`)) {
    const id = /Content-ID:\s*<response-item(\d+)>/i.exec(part)?.[1];
    const status = /HTTP\/1\.1 (\d{3})/.exec(part)?.[1];
    if (id === undefined || status === undefined) continue;
    const r = results[Number(id)];
    if (!r) continue;
    r.status = Number(status);
    r.ok = r.status === 200;
    if (!r.ok) r.error = toError(r.status, part.slice(Math.max(0, part.indexOf("{"))));
  }
  return results;
}

/** When Google last received an update/remove notification for this URL. Uses the read quota. */
export async function getNotificationStatus(url: string) {
  const res = await fetch(
    `https://indexing.googleapis.com/v3/urlNotifications/metadata?url=${encodeURIComponent(url)}`,
    { headers: { Authorization: await authHeader() } },
  );
  const body = await res.text();
  if (!res.ok) throw toError(res.status, body);
  return JSON.parse(body);
}
```

Three things in there are worth knowing:

- **Errors are classified once.** Google answers `403` with `PERMISSION_DENIED` when ownership is missing and `429` with `RESOURCE_EXHAUSTED` when the quota is spent. `retryable` is true only for `429` and `5xx`, so the caller knows whether to queue or to fix the setup.
- **Batches report per URL.** The batch endpoint takes up to 100 calls in one `multipart/mixed` request and answers with one part per call, each with its own HTTP status. Google's batch format, as its [Cloud Storage batch guide](https://docs.cloud.google.com/storage/docs/json_api/v1/how-tos/batch) describes it, matches each response part to its request by a `Content-ID` prefixed with `response-`, and the outer response is a `200` even when some parts failed. That's why the code reads every part.
- **`getNotificationStatus`** **doesn't tell you about indexing.** Google says the metadata request "doesn't tell you when Google indexes or removes a URL; it only returns whether you successfully submitted a request."

## Notify Google when a job changes

Notifications belong in the same place the job changes: the server action or route handler that saves it. Use [`after`](https://nextjs.org/docs/app/api-reference/functions/after) (stable since Next.js 15.1) so the employer's request doesn't wait on Google, and so a Google outage can't fail a job post. `after` callbacks run once the response is sent, for up to the route's configured max duration.

A small wrapper decides between "send now" and "retry later":

```typescript
// lib/indexing-notify.ts
import { notifyGoogle, IndexingApiError, type NotificationType } from "@/lib/indexing-api";
import { queueIndexing } from "@/lib/jobs";

export const jobUrl = (slug: string) => `${process.env.SITE_URL}/jobs/${slug}`;

/** Try now; if Google says "later" (429, 5xx), leave it for the cron to retry. */
export async function notifyOrQueue(url: string, type: NotificationType) {
  try {
    await notifyGoogle(url, type);
  } catch (e) {
    if (e instanceof IndexingApiError && e.retryable) return queueIndexing(url, type);
    // 400 or 403: a bug or a setup problem (wrong property, service account not an owner).
    // Retrying won't help, so log it loudly and fix the setup.
    console.error("[indexing-api]", url, type, e);
  }
}
```

And the server actions:

```typescript
// app/jobs/actions.ts
"use server";

import { after } from "next/server";
import { revalidatePath } from "next/cache";
import { createJob, closeJob, deleteJob } from "@/lib/jobs";
import { jobUrl, notifyOrQueue } from "@/lib/indexing-notify";

export async function publishJob(formData: FormData) {
  const job = await createJob(formData);
  revalidatePath(`/jobs/${job.slug}`);
  // Runs after the response is sent, so the employer doesn't wait on Google.
  after(() => notifyOrQueue(jobUrl(job.slug), "URL_UPDATED"));
  return { slug: job.slug };
}

/** Filled or withdrawn, page stays up: validThrough moves to the past, JobPosting markup goes. */
export async function closeJobPosting(slug: string) {
  await closeJob(slug);
  revalidatePath(`/jobs/${slug}`);
  after(() => notifyOrQueue(jobUrl(slug), "URL_UPDATED"));
}

/** Page removed: /jobs/[slug] now answers 404 or 410. */
export async function removeJobPosting(slug: string) {
  await deleteJob(slug);
  revalidatePath(`/jobs/${slug}`);
  after(() => notifyOrQueue(jobUrl(slug), "URL_DELETED"));
}
```

`closeJobPosting` sends `URL_UPDATED`, not `URL_DELETED`, because the page stays up; that's the closed-but-live row in the table above. If your job page template is the one from the [job board article](https://seofix.ai/blog/job-board-seo-technical-fixes), closing a job already drops the `JobPosting` block from the page.

## Jobs that expire on their own, and retries

Many jobs are never closed by hand; their `validThrough` date passes. Nothing calls a server action, but the page changed (the markup now says the job expired), so Google should hear about it. A scheduled route handler picks those up, and also retries whatever got a `429` or `5xx` earlier:

```typescript
// app/api/cron/indexing/route.ts
import { notifyGoogleBatch } from "@/lib/indexing-api";
import { jobUrl } from "@/lib/indexing-notify";
import {
  jobsExpiredSince, queueIndexing, takeQueuedIndexing,
  markIndexingSent, markIndexingFailed, getLastCronRun, setLastCronRun,
} from "@/lib/jobs";

export const dynamic = "force-dynamic";

const PER_RUN = 100; // one batch per run; schedule the cron to fit your daily quota

export async function GET(request: Request) {
  const secret = process.env.CRON_SECRET;
  if (!secret || request.headers.get("authorization") !== `Bearer ${secret}`) {
    return new Response("Unauthorized", { status: 401 });
  }

  // 1. Jobs whose validThrough passed since the last run changed without anyone touching them.
  const now = new Date();
  for (const job of await jobsExpiredSince(await getLastCronRun())) {
    await queueIndexing(jobUrl(job.slug), "URL_UPDATED");
  }
  await setLastCronRun(now);

  // 2. Send the oldest queued notifications in one batch.
  const queued = await takeQueuedIndexing(PER_RUN);
  if (queued.length === 0) return Response.json({ sent: 0 });

  const results = await notifyGoogleBatch(queued.map(({ url, type }) => ({ url, type })));
  const sent: number[] = [];
  let retryLater = 0;
  for (const [i, r] of results.entries()) {
    if (r.ok) sent.push(queued[i].id);
    else if (r.error?.retryable || r.status === 0) retryLater++; // stays queued
    else await markIndexingFailed(queued[i].id, r.error?.message ?? `HTTP ${r.status}`);
  }
  await markIndexingSent(sent);
  return Response.json({ sent: sent.length, retryLater });
}
```

On Vercel, [Cron Jobs](https://vercel.com/docs/cron-jobs/manage-cron-jobs) send the `CRON_SECRET` environment variable as the `Authorization` header, which is what the check at the top expects. Any other scheduler that can call a URL with a header works the same way. Note the `!secret` check: without it, an unset variable would accept `Bearer undefined`.

The data layer (`queueIndexing`, `takeQueuedIndexing` and the rest) is your own: a table with `url`, `type`, `status`, `attempts`, `last_error` and `created_at` is enough. Two rules make it behave:

- **Collapse duplicates.** If a job is published and closed within the same hour, only the latest notification for that URL matters. Upsert on `url`.
- **Stop at the quota.** After a `429`, nothing else will succeed until midnight Pacific Time. The batch result keeps those URLs queued; don't loop.

## Errors you'll see

From Google's [Indexing API error reference](https://developers.google.com/search/apis/indexing-api/v3/core-errors):

| Status | Message | What it means | Retry? |
| --- | --- | --- | --- |
| `400` | `Missing attribute. 'url' attribute is required.` or `Invalid attribute. 'url' is not in standard URL format` | Bad request body: relative URL, typo in `type` | No, fix the code |
| `403` | `Permission denied. Failed to verify the URL ownership.` | The service account isn't an owner of a property that covers this URL | No, fix Search Console |
| `429` | `Insufficient tokens for quota 'indexing.googleapis.com/default_requests'` | Daily quota used up | After the reset |
| `5xx` | Backend errors | Google's side | Yes, later |

Most `403`s are a property mismatch. A URL-prefix property [includes only URLs with that exact prefix](https://support.google.com/webmasters/answer/34592), protocol included, so `https://www.example.com/jobs/x` is not covered by a property for `https://example.com/`. A Domain property covers all subdomains and protocols.

## JobPosting markup the API depends on

The API only gets Google to look sooner. What Google finds is your markup, so the page still has to meet the [JobPosting rules](https://developers.google.com/search/docs/appearance/structured-data/job-posting):

- **Required:** `datePosted`, `description` (the full description in HTML, not the title), `hiringOrganization`, `jobLocation` and `title`.
- **`validThrough`****:** required for postings that have an expiration date, in ISO 8601 (`"2026-11-30"` or `"2026-11-30T23:59:59+04:00"`). Google's guidance: "If a job posting never expires, or you do not know when the job will expire, do not include this property. If the job is filled before the expiration date occurs, remove the job posting." Don't fill it with a made-up default such as "posted date plus 30 days".
- **Expired jobs must be expired** in one of three ways: `validThrough` in the past, the page removed (`404` or `410`), or the `JobPosting` markup removed. Google warns that "failure to take timely action on expired jobs may result in a manual action."
- **One job per page.** No `JobPosting` on search, category or company pages.

A minimal block, rendered in the job page template:

```typescript
const jsonLd = {
  '@context': 'https://schema.org',
  '@type': 'JobPosting',
  title: job.title,
  description: job.descriptionHtml,
  datePosted: job.postedAt,              // "2026-10-08"
  validThrough: job.closesAt ?? undefined, // omitted when there is no deadline
  hiringOrganization: { '@type': 'Organization', name: job.company.name, sameAs: job.company.website },
  jobLocation: {
    '@type': 'Place',
    address: { '@type': 'PostalAddress', addressLocality: job.city, addressCountry: job.countryCode },
  },
}
```

`JSON.stringify` drops `undefined` values, so a job without a deadline gets no `validThrough` at all. The full template, with the closed-job and `404` handling, is in the [job board article](https://seofix.ai/blog/job-board-seo-technical-fixes).

Before you point the API at production, run one open job, one closed job and one remote job through the Rich Results Test, and check one notified URL in Search Console's URL Inspection a day later. A crawler helps with the site-wide view: SEOFix reports sitemap URLs that don't answer `200` (`SITEMAP_URL_NOT_200`) and structured data that doesn't parse (`STRUCTURED_DATA_INVALID`), though it doesn't apply Google's rich-result rules ([on-page checks](https://seofix.ai/help/checks-on-page)).

## Misuse that gets access revoked

Things not to do, each from Google's own pages:

- **Sending non-job pages.** The API is restricted to `JobPosting` and `BroadcastEvent` pages, and Google's [guidelines](https://developers.google.com/search/apis/indexing-api/v3/using-api) say its spam policies apply to everything submitted.
- **Spreading requests across projects or accounts to get more quota.** Named explicitly as abuse in the quickstart.
- **Resubmitting unchanged pages.** Google says to submit another update "if the page's contents change". A nightly job that re-notifies every open job burns your quota on pages Google already has, and the quota can go down with document quality.
- **`URL_DELETED`** **for pages that still answer** **`200`** **without noindex.** It doesn't meet the documented precondition.

For Bing, Yandex, Naver and the other engines on the [IndexNow list](https://www.indexnow.org/searchengines.json), which doesn't include Google, see [IndexNow in Next.js](https://seofix.ai/blog/indexnow-nextjs). Both can run from the same `after` callback.

## How we tested the code

We didn't have an approved Indexing API project to test with, so we ran the TypeScript above against a local mock of Google's token, publish, batch and metadata endpoints, with a throwaway RSA key in the service-account JSON. The mock verified the JWT's RS256 signature with the matching public key and checked its claims: `iss` was the service account email, `scope` was `https://www.googleapis.com/auth/indexing`, `aud` was `https://oauth2.googleapis.com/token`, and the grant type was `urn:ietf:params:oauth:grant-type:jwt-bearer`. It returned a `403 PERMISSION_DENIED` for a URL on another domain (classified as not retryable) and a `429 RESOURCE_EXHAUSTED` for one part of a four-URL batch (that URL flagged retryable, the other three `200`). One token request served all calls, and the 101-item batch was refused before any request went out. The Next.js files typecheck under TypeScript strict mode against Next.js 16.4.0. We did not send requests to Google's real publish endpoint, which needs an approved project and a verified property.

## FAQ

### Can I use the Indexing API for blog posts or product pages?

No. Google limits it to pages with `JobPosting` or `BroadcastEvent` in a `VideoObject`. For everything else, use a sitemap with accurate `lastmod` and Search Console's URL Inspection.

### How many URLs can I submit per day?

200 publish requests per project per day by default, as of October 2026, counting updates and deletions together. More needs Google's approval through the form on the quota page.

### Do I still need a job sitemap?

Yes. Google recommends the Indexing API for job pages and a sitemap "for coverage of your entire site".

### Does a 200 response mean the job is indexed?

No. It means Google received the notification and may recrawl the URL soon.

---

SEOFix crawls job boards at 2 requests per second and groups issues by template, so a broken `/jobs/[slug]` shows up as one fix, not 30,000. The first audit, up to 500 pages, is free at [seofix.ai](https://seofix.ai/).
