---
title: "Grav Google Indexing Plugin: automatically submit pages to Google"
description: "Building a Grav plugin from scratch to automatically submit modified pages to the Google Indexing API using RS256 JWT and a Google Cloud service account."
url: "https://www.arleo.eu/en/posts/grav-plugin-google-indexing/"
language: "en"
datePublished: "2026-04-17T14:00:00+02:00"
dateModified: "2026-05-15T23:51:25+02:00"
tags: ["grav","seo","google"]
categories: ["plugins"]
contentSignal: "ai-train=no, search=yes, ai-input=yes"
---

# Grav Google Indexing Plugin: automatically submit pages to Google
Building a Grav plugin from scratch to automatically submit modified pages to the Google Indexing API using RS256 JWT and a Google Cloud service account.



## ⚡ In short

Following the IndexNow plugin (Bing/Yandex), this second plugin completes the SEO pipeline by submitting modified pages directly to the **Google Indexing API** — with no Composer dependency, using a RS256 JWT signed in pure PHP from a Google Cloud service account.

Source code available on GitHub:

- 🔌 Plugin: [jmrGrav/grav-plugin-google-indexing](https://github.com/jmrGrav/grav-plugin-google-indexing)

## 🧠 Why

IndexNow covers Bing and Yandex, but **Google does not support IndexNow**. To notify Google instantly, you need to use its dedicated API: [Google Indexing API v3](https://developers.google.com/search/apis/indexing-api/v3/prereqs).

Without this plugin:

- Google discovers new or modified pages only on its next crawl
- The delay can range from a few hours to several days
- No feedback on whether the submission was accepted

With this plugin, every save triggers an immediate submission with HTTP 200 confirmation in the logs.

## 🔧 What was done

### 📁 Plugin structure

```
/var/www/grav/user/plugins/google-indexing/
├── google-indexing.php     ← main logic
├── blueprints.yaml         ← admin field definitions
└── google-indexing.yaml    ← default values
```

### 🔑 Google Cloud prerequisites

Four steps on the Google side before using the API:

1. Create a Google Cloud project
2. Enable the **Indexing API** in the project
3. Create a **Service Account** and download the JSON credentials file
4. Add the service account as **Owner** in Google Search Console

> ⚠️ The role must be **Owner** — not "Full access" (= simple user). The API returns `403 PERMISSION_DENIED` with a user role.

| Search Console role | API result |
|---|---|
| Full user | ❌ HTTP 403 PERMISSION_DENIED |
| Owner | ✅ HTTP 200 |

### 🔐 Credentials storage

The JSON file is stored outside the Grav directory, accessible only by `www-data`:

```bash
sudo mkdir -p /etc/grav-google-indexing
sudo chown www-data:www-data /etc/grav-google-indexing
sudo chmod 700 /etc/grav-google-indexing
sudo cp service-account.json /etc/grav-google-indexing/
sudo chown www-data:www-data /etc/grav-google-indexing/service-account.json
sudo chmod 600 /etc/grav-google-indexing/service-account.json
```

### 🔍 Hooks — identical to the IndexNow plugin

```php
public static function getSubscribedEvents(): array
{
    return [
        'onAdminAfterSave' => ['onAdminAfterSave', 0],
        'onMcpAfterSave'   => ['onMcpAfterSave', 0],
    ];
}
```

### ⚙️ RS256 JWT in pure PHP — no Composer

The Google Indexing API requires an OAuth2 access token. Since `google/auth` is not available in Grav, the JWT is generated manually.

> ⚠️ Note the **base64url** encoding (not standard base64) — `+` becomes `-`, `/` becomes `_`, and `=` padding is removed.

```php
private function base64url(string $data): string
{
    return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}

private function getGoogleAccessToken(): ?string
{
    $credentials = json_decode(
        file_get_contents('/etc/grav-google-indexing/service-account.json'),
        true
    );

    $now     = time();
    $header  = $this->base64url(json_encode(['alg' => 'RS256', 'typ' => 'JWT']));
    $payload = $this->base64url(json_encode([
        'iss'   => $credentials['client_email'],
        'scope' => 'https://www.googleapis.com/auth/indexing',
        'aud'   => 'https://oauth2.googleapis.com/token',
        'exp'   => $now + 3600,
        'iat'   => $now,
    ]));

    $toSign = $header . '.' . $payload;
    openssl_sign($toSign, $signature, $credentials['private_key'], OPENSSL_ALGO_SHA256);
    $jwt = $toSign . '.' . $this->base64url($signature);

    // Exchange JWT for access token
    $ch = curl_init('https://oauth2.googleapis.com/token');
    curl_setopt_array($ch, [
        CURLOPT_POST           => true,
        CURLOPT_POSTFIELDS     => http_build_query([
            'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
            'assertion'  => $jwt,
        ]),
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT        => 10,
    ]);
    $response = json_decode(curl_exec($ch), true);
    curl_close($ch);

    return $response['access_token'] ?? null;
}
```

### 📤 Submitting to the API

The API accepts one URL at a time (no bulk like IndexNow). All 3 variants are submitted in a loop:

```php
private function handleRoute(string $route): void
{
    $host = 'arleo.eu';
    $urls = [
        "https://{$host}{$route}",
        "https://{$host}/fr{$route}",
        "https://{$host}/en{$route}",
    ];

    $token = $this->getGoogleAccessToken();
    if (!$token) {
        $this->grav['log']->error('[GoogleIndexing] Unable to obtain access token.');
        return;
    }

    foreach ($urls as $url) {
        $ch = curl_init('https://indexing.googleapis.com/v3/urlNotifications:publish');
        curl_setopt_array($ch, [
            CURLOPT_POST           => true,
            CURLOPT_POSTFIELDS     => json_encode(['url' => $url, 'type' => 'URL_UPDATED']),
            CURLOPT_HTTPHEADER     => [
                'Content-Type: application/json',
                "Authorization: Bearer {$token}",
            ],
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT        => 10,
        ]);
        $response = curl_exec($ch);
        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);

        if ($httpCode === 200) {
            $this->grav['log']->info("[GoogleIndexing] ✅ OK {$url}");
        } else {
            $this->grav['log']->warning("[GoogleIndexing] ⚠️ HTTP {$httpCode} {$url} — {$response}");
        }
    }
}
```

### ✅ Verification in logs

```bash
tail -f /var/www/grav/logs/grav.log | grep -E "IndexNow|GoogleIndexing"

# [GoogleIndexing] ✅ OK https://arleo.eu/grav-plugin-indexnow
# [GoogleIndexing] ✅ OK https://arleo.eu/fr/grav-plugin-indexnow
# [GoogleIndexing] ✅ OK https://arleo.eu/en/grav-plugin-indexnow
# [IndexNow] ✅ Submission OK (HTTP 200): https://arleo.eu/fr/grav-plugin-indexnow, ...
```

### 📊 Complete SEO pipeline

```
        Page save
        (admin or MCP)
              │
    ┌─────────┴─────────┐
    │                   │
    ▼                   ▼
IndexNow          GoogleIndexing
(bulk POST)       (JWT RS256)
    │                   │
    ▼                   ▼
Bing / Yandex       Google
```

| Engine | Plugin | Mechanism | Status |
|---|---|---|---|
| Bing / Yandex | IndexNow | Bulk POST `api.indexnow.org` | ✅ Active |
| Google | GoogleIndexing | JWT RS256 + `indexing.googleapis.com` | ✅ Active |

## 🏁 Conclusion

The SEO pipeline is now complete — every page save instantly notifies Google, Bing and Yandex with no manual intervention. The RS256 JWT is generated in pure PHP with no Composer dependency, avoiding any risk of conflict with existing Grav dependencies.

**To go further:**

- 💡 Cache the Google access token (valid for 1h) to avoid an OAuth call on every save and reduce latency
- 💡 Add an initial bulk submission on plugin startup to index all existing pages in a single pass



## Tags

- grav
- seo
- google

## Categories

- plugins
