Skip to content
TurboIndex

Google Indexing API on WordPress: With and Without a Plugin

Use the Google Indexing API on WordPress: the Google Cloud setup, Rank Math's Instant Indexing plugin, a wp_remote_post code sample, logging and the quota.

October 11, 20269 min read

On WordPress the Google Indexing API works the same way as anywhere else: you create a Google Cloud project, enable the API, create a service account, make that account an Owner of your Search Console property, and then send a notification for every URL that was published, updated or removed. A plugin can do the sending for you, or you can hook a few lines of your own code into the publish event. Google supports the API officially only for job posting and livestream pages, and the default quota is 200 notifications per day per project.

This article covers both routes, what to send, how to log the answers, and where the limits bite. The general background is in the Google Indexing API guide.

What a notification does

A call to urlNotifications:publish tells Google that a URL was updated (URL_UPDATED) or removed (URL_DELETED). Google then schedules a crawl. That is all. The response says "received", not "indexed", and whether the page ends up in the index depends on the page.

Three limits shape everything on WordPress:

  • 200 publish requests per day per Google Cloud project, URL_UPDATED and URL_DELETED counted together, reset at midnight Pacific time.
  • Up to 100 calls in one batch request, each still counted against the 200.
  • The account sending the call must be an Owner of the Search Console property. Full is not enough.

The full quota picture, including the per-minute limits and the increase form, is in Google Indexing API quota.

The Google side, needed by every method

Whatever sends the notifications, these four steps happen first:

  1. Create a Google Cloud project (or pick an existing one).
  2. Enable the Web Search Indexing API under APIs & Services, Library.
  3. Create a service account under IAM & Admin, Service accounts, and download a JSON key for it.
  4. In Search Console open Settings, Users and permissions, click Add user, paste the client_email from the JSON file and choose Owner.

The exact clicks, with the current console layout, are in how to create a Google Indexing API key. Keep the JSON file: a plugin will ask you to paste its contents, and the code below reads it from disk.

Route 1: a plugin

Rank Math

Rank Math SEO has an Instant Indexing module, and Rank Math also publishes a separate plugin called Instant Indexing for sites that use another SEO plugin. Both work the same way: you paste the JSON key, tick the post types to submit, and the plugin sends URL_UPDATED when a post of those types is published or updated and URL_DELETED when it is trashed. There is a console tab where you can submit a list of URLs by hand and read the raw API responses, which is the quickest way to see a 403 or 429 with Google's own message. The same settings screen can send URLs to Bing through IndexNow. The setup, screen by screen, is in Rank Math instant indexing.

Other plugins wrap the same endpoint. Whichever you pick, check that you can choose post types, that it shows the response per URL, and that it does not resend your whole site on activation, which burns the day's quota on pages that are already indexed.

Route 2: your own code with wp_remote_post

If you would rather not add a plugin for a dozen lines of logic, a must-use plugin does it. Put the file in wp-content/mu-plugins/, keep the JSON key outside the web root, and point to it from wp-config.php:

define( 'GIA_KEY_FILE', '/home/youruser/private/indexing-key.json' );

The plugin itself:

<?php
/**
 * Plugin Name: Indexing API notifier
 * Description: Tells the Google Indexing API when a post is published, updated or trashed.
 */

add_action( 'transition_post_status', function ( $new_status, $old_status, $post ) {
    if ( 'publish' !== $new_status || ! in_array( $post->post_type, array( 'post', 'page' ), true ) ) {
        return;
    }
    if ( get_transient( 'gia_sent_' . $post->ID ) ) {
        return; // sent within the last hour, do not spend quota on a typo fix
    }
    gia_notify( get_permalink( $post ), 'URL_UPDATED' );
    set_transient( 'gia_sent_' . $post->ID, 1, HOUR_IN_SECONDS );
}, 10, 3 );

add_action( 'wp_trash_post', function ( $post_id ) {
    $post = get_post( $post_id );
    if ( $post && 'publish' === $post->post_status && in_array( $post->post_type, array( 'post', 'page' ), true ) ) {
        gia_notify( get_permalink( $post ), 'URL_DELETED' );
    }
} );

function gia_notify( $url, $type ) {
    $token = gia_access_token();
    if ( ! $token ) {
        error_log( "Indexing API: no access token, skipped $type for $url" );
        return;
    }
    $res = wp_remote_post( 'https://indexing.googleapis.com/v3/urlNotifications:publish', array(
        'timeout' => 15,
        'headers' => array( 'Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json' ),
        'body'    => wp_json_encode( array( 'url' => $url, 'type' => $type ) ),
    ) );
    $code = is_wp_error( $res ) ? $res->get_error_message() : wp_remote_retrieve_response_code( $res );
    error_log( "Indexing API: $type $url returned $code" );
}

function gia_access_token() {
    $token = get_transient( 'gia_token' );
    if ( $token ) {
        return $token;
    }
    $key = json_decode( file_get_contents( GIA_KEY_FILE ), true );
    if ( empty( $key['client_email'] ) || empty( $key['private_key'] ) ) {
        return null;
    }
    $b64 = function ( $data ) {
        return rtrim( strtr( base64_encode( $data ), '+/', '-_' ), '=' );
    };
    $now    = time();
    $header = $b64( wp_json_encode( array( 'alg' => 'RS256', 'typ' => 'JWT' ) ) );
    $claims = $b64( wp_json_encode( array(
        'iss'   => $key['client_email'],
        'scope' => 'https://www.googleapis.com/auth/indexing',
        'aud'   => 'https://oauth2.googleapis.com/token',
        'iat'   => $now,
        'exp'   => $now + 3600,
    ) ) );
    openssl_sign( "$header.$claims", $signature, $key['private_key'], 'sha256WithRSAEncryption' );
    $jwt = "$header.$claims." . $b64( $signature );

    $res = wp_remote_post( 'https://oauth2.googleapis.com/token', array(
        'timeout' => 15,
        'body'    => array(
            'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
            'assertion'  => $jwt,
        ),
    ) );
    if ( is_wp_error( $res ) ) {
        return null;
    }
    $data = json_decode( wp_remote_retrieve_body( $res ), true );
    if ( empty( $data['access_token'] ) ) {
        return null;
    }
    set_transient( 'gia_token', $data['access_token'], 50 * MINUTE_IN_SECONDS );
    return $data['access_token'];
}

A few notes on the choices:

  • transition_post_status fires when a post becomes public, including scheduled posts going live, and again on every later save of a published post. save_post also fires for drafts, autosaves and revisions, which is why it is not used here.
  • wp_trash_post runs before the post is moved to the bin, so get_permalink() still returns the public URL. After trashing, the URL answers 404, which is exactly when URL_DELETED is appropriate.
  • The one hour transient per post stops a run of small edits from spending ten requests on one page. Raise it to a day if your editors save often. The access token is cached for 50 minutes for the same reason.
  • Nothing here retries. If Google answers 429 the request is logged and dropped. For most blogs that is fine; a site that regularly hits the limit needs a queue, which is a reason to use a plugin or a service instead.

What to send

Every notification costs one of the 200. Spend them on URLs that can actually be indexed:

Send Do not send
New posts, pages and products Attachments, revisions, nav menu items
Posts whose main content changed Posts marked noindex by Rank Math, Yoast or AIOSEO
Trashed or deleted posts, as URL_DELETED Private and password protected posts
Tag, author and date archives, paginated URLs
URLs whose canonical points to another page

If your SEO plugin stores a noindex flag in post meta, read it before sending. The same goes for pages that redirect: send the destination, never the old URL. The indexability checker shows in a few seconds whether a given URL would pass.

Do not send URL_DELETED for a page that still returns 200. Google says the URL should actually be gone (404 or 410) or carry noindex.

Logging and quota on a real site

error_log() writes to wp-content/debug.log when WP_DEBUG_LOG is on, which is good enough for a blog. For a site with several editors, store the last response code and time in post meta so you can see it in the editor, and count requests per Pacific day so you know when the 200 are gone.

The responses you will meet:

Response Meaning Fix
200 Notification received Nothing to do
403 "Permission denied. Failed to verify the URL ownership." The service account is not an Owner of a property covering this URL Add it as Owner, check the protocol and host of the property
403 "... has not been used in project ... or it is disabled" The Indexing API is not enabled in the project the key belongs to Enable the Web Search Indexing API in that project
429 Daily or per-minute quota reached Per minute: back off. Per day: wait for midnight Pacific
400 The URL is malformed or not absolute Send the full permalink with protocol

A site that publishes 20 posts a day and edits a few more sits comfortably inside the quota. A site that bulk updates 2,000 products in an afternoon does not, and no amount of plugin configuration changes that. Creating several Cloud projects to multiply the quota is the behaviour Google's documentation names as abuse.

The policy caveat, in plain words

Google's documentation for the Indexing API says it is for pages with JobPosting structured data and for livestream pages with BroadcastEvent. WordPress sites that use it for ordinary posts are outside that scope. Today a notification usually leads to a crawl, but Google can change how it handles those requests at any time and says abuse can get access revoked.

So keep the fundamentals in place regardless: an XML sitemap with accurate lastmod dates, internal links from pages that already get crawled, and no technical blockers such as the "Discourage search engines" checkbox under Settings, Reading. If the API stopped working tomorrow, those are what would still get your pages found.

The route without any plugin or code

If you do not want to maintain code or a plugin, TurboIndex connects to Search Console, reads your sitemap, fetches each new or changed URL with TurboIndexBot first so that a 404, a redirect or a noindex page is never sent (see /bot), sends the rest to the Indexing API and to IndexNow, and then checks each URL with the URL Inspection API after 2, 3, 5, 7 and 14 days so you see what Google did. It can use its own key or the one you created above. It is bound by the same 200 per day and the same policy as everything else on this page, and it promises no indexing, only that every eligible page is sent and tracked.

What to do next

  1. Create the project, enable the API, create the service account and add it as Owner.
  2. Pick a plugin or the must-use plugin above, restricted to the post types you want indexed.
  3. Publish one post and check the log or the plugin console for a 200.
  4. Two or three days later, inspect that URL in Search Console. If it says "Crawled - currently not indexed", the API did its job and the page is the problem.

Frequently asked questions

Do I need a plugin to use the Google Indexing API on WordPress?
No. A plugin saves you writing code, but the API itself only needs a service account key and a POST request. A short must-use plugin with wp_remote_post on the publish hook does the same job as the commercial plugins. The Google Cloud and Search Console steps are identical either way.
Which post types should be sent to the Indexing API?
Only the ones you want in search results: posts, pages and products, usually. Skip attachments, revisions, menu items, private or password protected posts, anything marked noindex by your SEO plugin, and tag or date archives. Every notification spends one of the 200 daily requests.
Why does the plugin show 403 Permission denied?
The service account from your JSON key is not an Owner of a Search Console property that covers the URL. Open Settings, Users and permissions in Search Console and check two things: the email matches the client_email in the key file, and the permission is Owner, not Full. A URL-prefix property must also match the protocol and host of your permalinks.
Does the Indexing API work for normal blog posts?
Google's documentation says the API is for pages with JobPosting or BroadcastEvent structured data. In practice a notification for an ordinary post usually results in a crawl, but Google does not promise that and says it may revoke access for abuse. Keep your sitemap accurate and treat the API as an extra signal, not as the way your site gets indexed.

Keep reading