Skip to content
TurboIndex

Google Indexing API Code Examples: Python, PHP, Node.js

Working Google Indexing API code in Python, PHP and Node.js: URL_UPDATED, URL_DELETED, getMetadata, batches of 100, and the 400, 403 and 429 errors explained.

October 11, 20267 min read

The Google Indexing API is one POST endpoint, https://indexing.googleapis.com/v3/urlNotifications:publish, that takes a JSON body with a url and a type of URL_UPDATED or URL_DELETED, authorised with a service account token for the scope https://www.googleapis.com/auth/indexing. Below are working examples in Python, PHP and Node.js, plus getMetadata, batching 100 notifications in one request, and the errors each one returns. Google supports the API only for pages with JobPosting or BroadcastEvent structured data, with 200 publish requests per day per project.

What every example needs

  1. A Google Cloud project with the Web Search Indexing API enabled.
  2. A service account in that project, with a JSON key downloaded. The click path is in how to create a Google Indexing API key.
  3. The service account's email added as an Owner of the Search Console property under Settings > Users and permissions. Full is not enough.
  4. The scope https://www.googleapis.com/auth/indexing.

The endpoints:

Call Method and URL
Publish POST https://indexing.googleapis.com/v3/urlNotifications:publish
Get metadata GET https://indexing.googleapis.com/v3/urlNotifications/metadata?url=<encoded URL>
Batch POST https://indexing.googleapis.com/batch, multipart/mixed, up to 100 parts

A successful publish returns the notification echoed back inside urlNotificationMetadata. It means Google received the notification. It does not mean the page was crawled or indexed.

Python with google-auth

# pip install google-auth requests
from google.oauth2 import service_account
from google.auth.transport.requests import AuthorizedSession

SCOPES = ["https://www.googleapis.com/auth/indexing"]
PUBLISH = "https://indexing.googleapis.com/v3/urlNotifications:publish"
METADATA = "https://indexing.googleapis.com/v3/urlNotifications/metadata"

creds = service_account.Credentials.from_service_account_file(
    "service-account.json", scopes=SCOPES
)
session = AuthorizedSession(creds)


def notify(url, notification_type="URL_UPDATED"):
    r = session.post(PUBLISH, json={"url": url, "type": notification_type}, timeout=30)
    if r.status_code != 200:
        message = r.json().get("error", {}).get("message", r.text)
        raise RuntimeError(f"{r.status_code}: {message}")
    return r.json()["urlNotificationMetadata"]


def metadata(url):
    r = session.get(METADATA, params={"url": url}, timeout=30)
    if r.status_code == 404:
        return None  # never notified for this URL
    r.raise_for_status()
    return r.json()


print(notify("https://www.example.com/jobs/warehouse-operative-leeds"))
print(notify("https://www.example.com/jobs/filled-role", "URL_DELETED"))
print(metadata("https://www.example.com/jobs/warehouse-operative-leeds"))

AuthorizedSession signs a JWT with the private key, exchanges it for an access token and refreshes it when it expires. For batches, the discovery client adds a helper:

# pip install google-api-python-client
from googleapiclient.discovery import build

service = build("indexing", "v3", credentials=creds)


def on_result(request_id, response, exception):
    if exception is not None:
        print(request_id, "failed:", exception)
    else:
        print(request_id, "ok:", response["urlNotificationMetadata"]["url"])


urls = ["https://www.example.com/jobs/a", "https://www.example.com/jobs/b"]
batch = service.new_batch_http_request(callback=on_result)
for url in urls[:100]:
    batch.add(service.urlNotifications().publish(body={"url": url, "type": "URL_UPDATED"}))
batch.execute()

Each part gets its own result in the callback, so one bad URL does not fail the other 99.

PHP with google/apiclient

<?php
// composer require google/apiclient
require 'vendor/autoload.php';

$client = new Google\Client();
$client->setAuthConfig('service-account.json');
$client->addScope('https://www.googleapis.com/auth/indexing');

$service = new Google\Service\Indexing($client);

function notify(Google\Service\Indexing $service, string $url, string $type = 'URL_UPDATED'): array
{
    $notification = new Google\Service\Indexing\UrlNotification();
    $notification->setUrl($url);
    $notification->setType($type);
    $response = $service->urlNotifications->publish($notification);
    $metadata = $response->getUrlNotificationMetadata();
    $latest = $type === 'URL_DELETED' ? $metadata->getLatestRemove() : $metadata->getLatestUpdate();
    return ['url' => $latest->getUrl(), 'type' => $latest->getType(), 'time' => $latest->getNotifyTime()];
}

try {
    print_r(notify($service, 'https://www.example.com/jobs/warehouse-operative-leeds'));
    print_r(notify($service, 'https://www.example.com/jobs/filled-role', 'URL_DELETED'));
    $meta = $service->urlNotifications->getMetadata(['url' => 'https://www.example.com/jobs/warehouse-operative-leeds']);
    echo $meta->getLatestUpdate()->getNotifyTime(), PHP_EOL;
} catch (Google\Service\Exception $e) {
    // 403 and 429 arrive here with Google's message in the body
    echo $e->getCode(), ': ', $e->getMessage(), PHP_EOL;
}

Batching with the same library:

<?php
$urls = ['https://www.example.com/jobs/a', 'https://www.example.com/jobs/b'];
$client->setUseBatch(true);
$batch = $service->createBatch();

foreach (array_slice($urls, 0, 100) as $i => $url) {
    $notification = new Google\Service\Indexing\UrlNotification();
    $notification->setUrl($url);
    $notification->setType('URL_UPDATED');
    $batch->add($service->urlNotifications->publish($notification), 'url-' . $i);
}

$results = $batch->execute();
$client->setUseBatch(false);

foreach ($results as $key => $result) {
    // keys come back as response-url-0, response-url-1, ...
    if ($result instanceof Google\Service\Exception) {
        echo $key, ' failed: ', $result->getCode(), ' ', $result->getMessage(), PHP_EOL;
    } else {
        echo $key, ' ok', PHP_EOL;
    }
}

Remember to switch batch mode off again, or the next single call returns a request object instead of a response.

Node.js with google-auth-library

// npm install google-auth-library
import { readFileSync } from "node:fs";
import { JWT } from "google-auth-library";

const key = JSON.parse(readFileSync("./service-account.json", "utf8"));
const client = new JWT({
  email: key.client_email,
  key: key.private_key,
  scopes: ["https://www.googleapis.com/auth/indexing"],
});

const PUBLISH = "https://indexing.googleapis.com/v3/urlNotifications:publish";
const METADATA = "https://indexing.googleapis.com/v3/urlNotifications/metadata";

export async function notify(url, type = "URL_UPDATED") {
  const res = await client.request({ url: PUBLISH, method: "POST", data: { url, type } });
  return res.data.urlNotificationMetadata;
}

export async function metadata(url) {
  try {
    const res = await client.request({ url: METADATA, params: { url } });
    return res.data;
  } catch (err) {
    if (err.response?.status === 404) return null; // never notified
    throw err;
  }
}

export async function publishBatch(urls, type = "URL_UPDATED") {
  const { token } = await client.getAccessToken();
  const boundary = "batch_" + Date.now();
  const parts = urls.slice(0, 100).map(
    (url, i) =>
      `--${boundary}\r\nContent-Type: application/http\r\nContent-ID: <item-${i}>\r\n\r\n` +
      `POST /v3/urlNotifications:publish HTTP/1.1\r\nContent-Type: application/json\r\n\r\n` +
      JSON.stringify({ url, type }) + "\r\n"
  );
  const res = await fetch("https://indexing.googleapis.com/batch", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${token}`,
      "Content-Type": `multipart/mixed; boundary=${boundary}`,
    },
    body: parts.join("") + `--${boundary}--\r\n`,
  });
  const text = await res.text();
  // one HTTP response per part, in order
  return [...text.matchAll(/^HTTP\/1\.1 (\d{3})/gm)].map((m) => Number(m[1]));
}

console.log(await notify("https://www.example.com/jobs/warehouse-operative-leeds"));
console.log(await notify("https://www.example.com/jobs/filled-role", "URL_DELETED"));
console.log(await publishBatch(["https://www.example.com/jobs/a", "https://www.example.com/jobs/b"]));

If you keep the private key in an environment variable rather than the JSON file, the \n sequences inside it must survive as real line breaks, or signing fails with an unhelpful error. Node 18 or newer has fetch built in.

The errors you will meet

HTTP Message you see Cause Fix
400 Missing attribute. 'url' attribute is required Body is not JSON, or the field is misnamed Send {"url": ..., "type": ...} with Content-Type: application/json
400 Invalid value for type Typo in URL_UPDATED or URL_DELETED Use the exact strings
401 Request had invalid authentication credentials Expired token, wrong key file, server clock off Mint a new token, check the clock
403 Permission denied. Failed to verify the URL ownership Service account is not an Owner of a property covering the URL Add it as Owner; check www and https match the property
403 accessNotConfigured, or the API has not been used in this project Indexing API not enabled in the project that owns the key Enable Web Search Indexing API under APIs & Services
404 On getMetadata only No notification was ever sent for that URL Informational
429 Insufficient tokens for quota 200 per day used, or 380 per minute exceeded Per minute: back off. Per day: stop until midnight Pacific
5xx Backend error Temporary at Google Retry with exponential backoff

The 403 for ownership is the one everybody hits once. Two checks solve nearly all cases: the email you added in Search Console must be the client_email from the same JSON file you load, and the role must read Owner, not Full. If you have a URL-prefix property for https://www.yoursite.com/, a notification for https://yoursite.com/page fails even though it is the same site. The quotas behind the 429 are in Google Indexing API quota.

Rules that keep the code useful

  • Send only real changes. A new page, a meaningful edit, or a removal. Resubmitting unchanged URLs on a timer burns the 200 and changes nothing.
  • Send URL_DELETED only for pages that are gone. The page should return 404 or 410, or carry noindex, before you tell Google it is deleted.
  • Check the URL before you send it. A redirecting or noindexed URL costs a request and achieves nothing. The indexability checker tests one URL; the URL list cleaner removes duplicates and non-canonical variants from a list first.
  • Log every call with URL, type, time and response. Then you never need getMetadata, which only tells you what you already sent.
  • Verify the outcome separately. Inspect a sample in Search Console a few days later. A 200 from this API is not an index entry.
  • Keep to the documented use. Job pages with JobPosting markup and livestreams with BroadcastEvent, which the job posting article covers in detail. Google's docs call using several accounts to beat the quota abuse, and the policy side is set out in the Google Indexing API guide.

The no-code route

If what you want is "every new sitemap URL goes to Google and I can see what happened", you do not have to run any of the above. TurboIndex does the same calls after a Google sign-in, either with a project it sets up or with your own service account key, and it fetches each URL with its crawler, TurboIndexBot, before sending it so that a 404, a redirect or a noindex page never spends a request (see the bot page). It then checks each URL with the URL Inspection API over the following days, which is the part this API cannot do for you.

Frequently asked questions

Which library do I need for the Google Indexing API?
Any library that can sign a service account JWT and attach the resulting token: google-auth for Python, google/apiclient for PHP, google-auth-library for Node.js. The API itself is two plain JSON endpoints, so curl works too once you have a token.
Why does my Indexing API call return 403 Permission denied?
The service account is not an Owner of the Search Console property that contains the URL, or the URL does not match the property's protocol and host. Full permission is not enough. Add the service account email under Settings, Users and permissions with the Owner role.
Does a batch of 100 URLs count as one request?
No. A batch combines up to 100 calls into one HTTP request, but every URL inside it counts against the 200 publish requests per day. Batching saves round trips, not quota.
Can I use these examples for blog posts and product pages?
The API accepts any URL on a property you own, but Google's documentation says it is only for pages with JobPosting or BroadcastEvent structured data. Other use is unsupported, and Google says abuse can get access revoked. Decide with that in mind.
How do I check whether the page was indexed after the call?
Not through the Indexing API. getMetadata only returns the notifications you sent. Use the URL Inspection tool, or the URL Inspection API, a few days later.

Keep reading