DEV Community

ahmet gedik
ahmet gedik

Posted on

Implementing IndexNow Protocol in PHP for Instant Indexing

If you publish content frequently and want search engines to discover it faster, IndexNow is one of the easiest wins available. In this tutorial, I'll walk through a complete PHP implementation of the IndexNow protocol, based on what I built for DailyWatch, a video discovery platform that publishes hundreds of new pages daily.

What Is IndexNow?

IndexNow is an open protocol that lets you notify participating search engines (Bing, Yandex, Seznam, Naver) the moment a URL is created, updated, or deleted. Instead of waiting for crawlers to find your changes, you push the information to them.

Step 1: Generate Your API Key

The key can be any string of hexadecimal characters (a-f, 0-9), between 8 and 128 characters:

// Generate a random IndexNow API key
$apiKey = bin2hex(random_bytes(16));
echo $apiKey; // e.g., "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6"
Enter fullscreen mode Exit fullscreen mode

Step 2: Place the Key File

Create a text file at your domain root containing only your API key:

function createKeyFile(string $apiKey, string $webRoot): void {
    $keyFilePath = rtrim($webRoot, '/') . '/' . $apiKey . '.txt';
    file_put_contents($keyFilePath, $apiKey);

    // Verify it's accessible
    echo "Key file created at: {$keyFilePath}\n";
    echo "Verify at: https://yoursite.com/{$apiKey}.txt\n";
}
Enter fullscreen mode Exit fullscreen mode

This file proves to search engines that you own the domain.

Step 3: Build the Submission Client

Here's a complete, production-ready IndexNow client class:

class IndexNowClient {
    private const ENDPOINT = 'https://api.indexnow.org/indexnow';
    private const MAX_URLS_PER_BATCH = 10000;
    private const TIMEOUT = 10;

    public function __construct(
        private readonly string $host,
        private readonly string $apiKey,
        private readonly string $keyLocation = '',
    ) {}

    /**
     * Submit a single URL
     */
    public function submit(string $url): IndexNowResult {
        return $this->submitBatch([$url]);
    }

    /**
     * Submit multiple URLs in a single request
     */
    public function submitBatch(array $urls): IndexNowResult {
        if (empty($urls)) {
            return new IndexNowResult(success: true, submitted: 0, message: 'No URLs to submit');
        }

        // Chunk if exceeding max batch size
        if (count($urls) > self::MAX_URLS_PER_BATCH) {
            return $this->submitChunked($urls);
        }

        $payload = [
            'host' => $this->host,
            'key' => $this->apiKey,
            'urlList' => array_values($urls),
        ];

        if ($this->keyLocation) {
            $payload['keyLocation'] = $this->keyLocation;
        }

        $ch = curl_init(self::ENDPOINT);
        curl_setopt_array($ch, [
            CURLOPT_POST => true,
            CURLOPT_POSTFIELDS => json_encode($payload),
            CURLOPT_HTTPHEADER => [
                'Content-Type: application/json; charset=utf-8',
            ],
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT => self::TIMEOUT,
        ]);

        $response = curl_exec($ch);
        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        $error = curl_error($ch);
        curl_close($ch);

        if ($error) {
            return new IndexNowResult(
                success: false,
                submitted: 0,
                message: "cURL error: {$error}"
            );
        }

        return new IndexNowResult(
            success: $httpCode >= 200 && $httpCode < 300,
            submitted: count($urls),
            message: "HTTP {$httpCode}",
            httpCode: $httpCode
        );
    }

    private function submitChunked(array $urls): IndexNowResult {
        $chunks = array_chunk($urls, self::MAX_URLS_PER_BATCH);
        $totalSubmitted = 0;
        $errors = [];

        foreach ($chunks as $chunk) {
            $result = $this->submitBatch($chunk);
            if ($result->success) {
                $totalSubmitted += $result->submitted;
            } else {
                $errors[] = $result->message;
            }
            usleep(100000); // 100ms delay between batches
        }

        return new IndexNowResult(
            success: empty($errors),
            submitted: $totalSubmitted,
            message: empty($errors) ? 'All batches submitted' : implode('; ', $errors)
        );
    }
}

readonly class IndexNowResult {
    public function __construct(
        public bool $success,
        public int $submitted,
        public string $message,
        public int $httpCode = 0,
    ) {}
}
Enter fullscreen mode Exit fullscreen mode

Step 4: Integrate with Your Content Pipeline

At DailyWatch, we call IndexNow at the end of each cron fetch cycle:

// After fetching and storing new videos
$newVideoIds = $db->getNewVideoIdsSinceLastFetch();

if (!empty($newVideoIds)) {
    $urls = array_map(
        fn(string $id) => "https://dailywatch.video/watch/{$id}",
        $newVideoIds
    );

    $client = new IndexNowClient(
        host: 'dailywatch.video',
        apiKey: $config['indexnow_key']
    );

    $result = $client->submitBatch($urls);

    echo "IndexNow: submitted {$result->submitted} URLs, "
       . "status: {$result->message}\n";
}
Enter fullscreen mode Exit fullscreen mode

HTTP Response Codes

Code Meaning
200 URLs submitted successfully
202 URLs accepted, key validation pending
400 Invalid request
403 Key not valid or not matching
422 URLs don't belong to the host
429 Too many requests (rate limited)

Results

After implementing IndexNow on dailywatch.video, Bing indexing time dropped from 3-5 days to under 1 hour. The implementation took about 2 hours total, making it one of the highest-ROI SEO improvements available.

The full source code is available on GitHub. If you have questions about the implementation, drop them in the comments.

Top comments (0)