indexnow.php

I’ll build this as a full inc/seo/indexnow.php module for your RX Theme: settings, key-file endpoint, URL submission, post hooks, bulk submission, cron queue, logging, and admin tools.

Below is an advanced indexnow.php file for your RX Theme. It includes: automatic key generation, dynamic key file serving, automatic submission on post publish/update/delete, queue system, cron retry, bulk admin tools, manual URL submit, logs, REST endpoint, sitemap submit, security sanitization, duplicate prevention, and plugin-conflict safety.

IndexNow requires an API key hosted on your site and URL submission to an IndexNow endpoint; Bing’s guide and IndexNow documentation both describe the key-hosting and bulk POST submission system.

Create this file:

rx-theme/inc/seo/indexnow.php

Then include it from functions.php:

require_once get_template_directory() . '/inc/seo/indexnow.php';
<?php
/**
 * RX Theme - Advanced IndexNow Integration
 *
 * File: inc/seo/indexnow.php
 *
 * Features:
 * - Auto-generate IndexNow API key
 * - Dynamically serve /{key}.txt
 * - Submit single URL and bulk URLs
 * - Auto-submit on publish/update/delete
 * - Queue system with WP-Cron retry
 * - Admin settings page under Tools
 * - Manual URL submission
 * - Bulk post/page submission
 * - Sitemap URL submission
 * - Submission logs
 * - REST API endpoint
 * - Duplicate URL cooldown protection
 * - Safe post type filtering
 * - Compatible with SEO plugins
 *
 * @package RX_Theme
 */

defined( 'ABSPATH' ) || exit;

if ( ! class_exists( 'RX_Theme_IndexNow' ) ) :

final class RX_Theme_IndexNow {

	/**
	 * Option names.
	 */
	const OPTION_KEY              = 'rx_indexnow_key';
	const OPTION_ENABLED          = 'rx_indexnow_enabled';
	const OPTION_ENDPOINT         = 'rx_indexnow_endpoint';
	const OPTION_POST_TYPES       = 'rx_indexnow_post_types';
	const OPTION_LOGS             = 'rx_indexnow_logs';
	const OPTION_QUEUE            = 'rx_indexnow_queue';
	const OPTION_LAST_SUBMITTED   = 'rx_indexnow_last_submitted';
	const OPTION_AUTO_SUBMIT      = 'rx_indexnow_auto_submit';
	const OPTION_SUBMIT_ON_DELETE = 'rx_indexnow_submit_on_delete';
	const OPTION_COOLDOWN         = 'rx_indexnow_cooldown_minutes';
	const OPTION_BATCH_SIZE       = 'rx_indexnow_batch_size';

	/**
	 * Cron hook.
	 */
	const CRON_HOOK = 'rx_indexnow_process_queue';

	/**
	 * REST namespace.
	 */
	const REST_NAMESPACE = 'rx-theme/v1';

	/**
	 * Default endpoint.
	 *
	 * IndexNow allows submission through API endpoints such as api.indexnow.org.
	 */
	const DEFAULT_ENDPOINT = 'https://api.indexnow.org/indexnow';

	/**
	 * Initialize hooks.
	 */
	public static function init() {

		add_action( 'after_setup_theme', array( __CLASS__, 'maybe_install_defaults' ) );

		add_action( 'init', array( __CLASS__, 'add_rewrite_rules' ) );
		add_filter( 'query_vars', array( __CLASS__, 'add_query_vars' ) );
		add_action( 'template_redirect', array( __CLASS__, 'serve_key_file' ) );

		add_action( 'transition_post_status', array( __CLASS__, 'handle_post_status_change' ), 10, 3 );
		add_action( 'post_updated', array( __CLASS__, 'handle_post_updated' ), 10, 3 );
		add_action( 'before_delete_post', array( __CLASS__, 'handle_post_delete' ), 10, 1 );

		add_action( self::CRON_HOOK, array( __CLASS__, 'process_queue' ) );

		add_action( 'admin_menu', array( __CLASS__, 'admin_menu' ) );
		add_action( 'admin_init', array( __CLASS__, 'register_settings' ) );
		add_action( 'admin_post_rx_indexnow_submit_manual', array( __CLASS__, 'admin_submit_manual' ) );
		add_action( 'admin_post_rx_indexnow_submit_sitemap', array( __CLASS__, 'admin_submit_sitemap' ) );
		add_action( 'admin_post_rx_indexnow_submit_recent', array( __CLASS__, 'admin_submit_recent' ) );
		add_action( 'admin_post_rx_indexnow_clear_logs', array( __CLASS__, 'admin_clear_logs' ) );
		add_action( 'admin_post_rx_indexnow_regenerate_key', array( __CLASS__, 'admin_regenerate_key' ) );
		add_action( 'admin_post_rx_indexnow_process_queue_now', array( __CLASS__, 'admin_process_queue_now' ) );

		add_action( 'rest_api_init', array( __CLASS__, 'register_rest_routes' ) );

		register_activation_hook_safe_rx_indexnow();
	}

	/**
	 * Install default options.
	 */
	public static function maybe_install_defaults() {

		if ( false === get_option( self::OPTION_KEY ) ) {
			update_option( self::OPTION_KEY, self::generate_key(), false );
		}

		if ( false === get_option( self::OPTION_ENABLED ) ) {
			update_option( self::OPTION_ENABLED, '1', false );
		}

		if ( false === get_option( self::OPTION_ENDPOINT ) ) {
			update_option( self::OPTION_ENDPOINT, self::DEFAULT_ENDPOINT, false );
		}

		if ( false === get_option( self::OPTION_POST_TYPES ) ) {
			update_option( self::OPTION_POST_TYPES, array( 'post', 'page' ), false );
		}

		if ( false === get_option( self::OPTION_AUTO_SUBMIT ) ) {
			update_option( self::OPTION_AUTO_SUBMIT, '1', false );
		}

		if ( false === get_option( self::OPTION_SUBMIT_ON_DELETE ) ) {
			update_option( self::OPTION_SUBMIT_ON_DELETE, '1', false );
		}

		if ( false === get_option( self::OPTION_COOLDOWN ) ) {
			update_option( self::OPTION_COOLDOWN, 10, false );
		}

		if ( false === get_option( self::OPTION_BATCH_SIZE ) ) {
			update_option( self::OPTION_BATCH_SIZE, 100, false );
		}

		if ( false === get_option( self::OPTION_LOGS ) ) {
			update_option( self::OPTION_LOGS, array(), false );
		}

		if ( false === get_option( self::OPTION_QUEUE ) ) {
			update_option( self::OPTION_QUEUE, array(), false );
		}

		if ( false === get_option( self::OPTION_LAST_SUBMITTED ) ) {
			update_option( self::OPTION_LAST_SUBMITTED, array(), false );
		}

		if ( ! wp_next_scheduled( self::CRON_HOOK ) ) {
			wp_schedule_event( time() + 300, 'hourly', self::CRON_HOOK );
		}
	}

	/**
	 * Generate IndexNow key.
	 *
	 * Key should be a safe alphanumeric/hyphen string.
	 */
	public static function generate_key() {
		return strtolower( wp_generate_password( 32, false, false ) );
	}

	/**
	 * Get API key.
	 */
	public static function get_key() {
		$key = (string) get_option( self::OPTION_KEY, '' );

		if ( empty( $key ) ) {
			$key = self::generate_key();
			update_option( self::OPTION_KEY, $key, false );
		}

		return sanitize_key( $key );
	}

	/**
	 * Get key file URL.
	 */
	public static function get_key_location() {
		return home_url( '/' . self::get_key() . '.txt' );
	}

	/**
	 * Get endpoint.
	 */
	public static function get_endpoint() {
		$endpoint = esc_url_raw( get_option( self::OPTION_ENDPOINT, self::DEFAULT_ENDPOINT ) );

		if ( empty( $endpoint ) ) {
			$endpoint = self::DEFAULT_ENDPOINT;
		}

		return $endpoint;
	}

	/**
	 * Is enabled?
	 */
	public static function is_enabled() {
		return '1' === (string) get_option( self::OPTION_ENABLED, '1' );
	}

	/**
	 * Add rewrite rule for dynamic key file.
	 */
	public static function add_rewrite_rules() {
		$key = self::get_key();

		if ( $key ) {
			add_rewrite_rule(
				'^' . preg_quote( $key, '/' ) . '\.txt$',
				'index.php?rx_indexnow_key_file=1',
				'top'
			);
		}
	}

	/**
	 * Add custom query var.
	 */
	public static function add_query_vars( $vars ) {
		$vars[] = 'rx_indexnow_key_file';
		return $vars;
	}

	/**
	 * Serve key file.
	 */
	public static function serve_key_file() {

		if ( '1' !== get_query_var( 'rx_indexnow_key_file' ) ) {
			return;
		}

		status_header( 200 );
		header( 'Content-Type: text/plain; charset=utf-8' );
		header( 'X-Robots-Tag: noindex, follow', true );
		header( 'Cache-Control: public, max-age=86400' );

		echo esc_html( self::get_key() );
		exit;
	}

	/**
	 * Validate URL before submission.
	 */
	public static function is_valid_submit_url( $url ) {

		$url = esc_url_raw( $url );

		if ( empty( $url ) || ! wp_http_validate_url( $url ) ) {
			return false;
		}

		$home_host = wp_parse_url( home_url(), PHP_URL_HOST );
		$url_host  = wp_parse_url( $url, PHP_URL_HOST );

		if ( empty( $home_host ) || empty( $url_host ) ) {
			return false;
		}

		return strtolower( $home_host ) === strtolower( $url_host );
	}

	/**
	 * Get allowed post types.
	 */
	public static function get_allowed_post_types() {
		$post_types = get_option( self::OPTION_POST_TYPES, array( 'post', 'page' ) );

		if ( ! is_array( $post_types ) ) {
			$post_types = array( 'post', 'page' );
		}

		$post_types = array_map( 'sanitize_key', $post_types );

		return apply_filters( 'rx_indexnow_allowed_post_types', $post_types );
	}

	/**
	 * Should submit post?
	 */
	public static function should_submit_post( $post_id ) {

		if ( ! self::is_enabled() ) {
			return false;
		}

		if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) {
			return false;
		}

		$post = get_post( $post_id );

		if ( ! $post ) {
			return false;
		}

		if ( 'publish' !== $post->post_status ) {
			return false;
		}

		if ( ! in_array( $post->post_type, self::get_allowed_post_types(), true ) ) {
			return false;
		}

		if ( 'private' === get_post_status( $post_id ) ) {
			return false;
		}

		/**
		 * Allow custom exclusion.
		 *
		 * Example:
		 * add_filter('rx_indexnow_should_submit_post', function($submit, $post_id) {
		 *     if ( has_category('noindex', $post_id) ) return false;
		 *     return $submit;
		 * }, 10, 2);
		 */
		return (bool) apply_filters( 'rx_indexnow_should_submit_post', true, $post_id );
	}

	/**
	 * Handle post status change.
	 */
	public static function handle_post_status_change( $new_status, $old_status, $post ) {

		if ( '1' !== (string) get_option( self::OPTION_AUTO_SUBMIT, '1' ) ) {
			return;
		}

		if ( ! $post instanceof WP_Post ) {
			return;
		}

		if ( 'publish' === $new_status && self::should_submit_post( $post->ID ) ) {
			self::queue_url( get_permalink( $post->ID ), 'post_status_change' );
		}
	}

	/**
	 * Handle post update.
	 */
	public static function handle_post_updated( $post_id, $post_after, $post_before ) {

		if ( '1' !== (string) get_option( self::OPTION_AUTO_SUBMIT, '1' ) ) {
			return;
		}

		if ( ! self::should_submit_post( $post_id ) ) {
			return;
		}

		if ( $post_after->post_modified_gmt === $post_before->post_modified_gmt ) {
			return;
		}

		self::queue_url( get_permalink( $post_id ), 'post_updated' );
	}

	/**
	 * Handle post delete.
	 */
	public static function handle_post_delete( $post_id ) {

		if ( '1' !== (string) get_option( self::OPTION_SUBMIT_ON_DELETE, '1' ) ) {
			return;
		}

		$post = get_post( $post_id );

		if ( ! $post ) {
			return;
		}

		if ( ! in_array( $post->post_type, self::get_allowed_post_types(), true ) ) {
			return;
		}

		$url = get_permalink( $post_id );

		if ( $url ) {
			self::queue_url( $url, 'post_deleted' );
		}
	}

	/**
	 * Queue a URL.
	 */
	public static function queue_url( $url, $source = 'manual' ) {

		$url = esc_url_raw( $url );

		if ( ! self::is_valid_submit_url( $url ) ) {
			self::log(
				$url,
				'skipped',
				'Invalid or external URL.',
				$source,
				0
			);
			return false;
		}

		if ( self::is_in_cooldown( $url ) ) {
			self::log(
				$url,
				'skipped',
				'URL is in cooldown window.',
				$source,
				0
			);
			return false;
		}

		$queue = get_option( self::OPTION_QUEUE, array() );

		if ( ! is_array( $queue ) ) {
			$queue = array();
		}

		$hash = md5( $url );

		$queue[ $hash ] = array(
			'url'      => $url,
			'source'   => sanitize_key( $source ),
			'attempts' => isset( $queue[ $hash ]['attempts'] ) ? absint( $queue[ $hash ]['attempts'] ) : 0,
			'created'  => isset( $queue[ $hash ]['created'] ) ? absint( $queue[ $hash ]['created'] ) : time(),
			'updated'  => time(),
		);

		update_option( self::OPTION_QUEUE, $queue, false );

		if ( ! wp_next_scheduled( self::CRON_HOOK ) ) {
			wp_schedule_single_event( time() + 60, self::CRON_HOOK );
		}

		return true;
	}

	/**
	 * Cooldown check.
	 */
	public static function is_in_cooldown( $url ) {

		$cooldown_minutes = absint( get_option( self::OPTION_COOLDOWN, 10 ) );

		if ( 0 === $cooldown_minutes ) {
			return false;
		}

		$last = get_option( self::OPTION_LAST_SUBMITTED, array() );

		if ( ! is_array( $last ) ) {
			$last = array();
		}

		$hash = md5( $url );

		if ( empty( $last[ $hash ] ) ) {
			return false;
		}

		return ( time() - absint( $last[ $hash ] ) ) < ( $cooldown_minutes * MINUTE_IN_SECONDS );
	}

	/**
	 * Mark URLs as submitted.
	 */
	public static function mark_submitted( array $urls ) {

		$last = get_option( self::OPTION_LAST_SUBMITTED, array() );

		if ( ! is_array( $last ) ) {
			$last = array();
		}

		foreach ( $urls as $url ) {
			$last[ md5( $url ) ] = time();
		}

		/**
		 * Prevent option from growing forever.
		 */
		if ( count( $last ) > 1000 ) {
			$last = array_slice( $last, -500, null, true );
		}

		update_option( self::OPTION_LAST_SUBMITTED, $last, false );
	}

	/**
	 * Process queue.
	 */
	public static function process_queue() {

		if ( ! self::is_enabled() ) {
			return;
		}

		$queue = get_option( self::OPTION_QUEUE, array() );

		if ( empty( $queue ) || ! is_array( $queue ) ) {
			return;
		}

		$batch_size = absint( get_option( self::OPTION_BATCH_SIZE, 100 ) );

		if ( $batch_size < 1 ) {
			$batch_size = 100;
		}

		$items = array_slice( $queue, 0, $batch_size, true );
		$urls  = array();

		foreach ( $items as $hash => $item ) {
			if ( ! empty( $item['url'] ) && self::is_valid_submit_url( $item['url'] ) ) {
				$urls[] = esc_url_raw( $item['url'] );
			}
		}

		if ( empty( $urls ) ) {
			foreach ( $items as $hash => $item ) {
				unset( $queue[ $hash ] );
			}
			update_option( self::OPTION_QUEUE, $queue, false );
			return;
		}

		$result = self::submit_urls( $urls, 'queue' );

		if ( true === $result['success'] ) {
			foreach ( $items as $hash => $item ) {
				unset( $queue[ $hash ] );
			}

			self::mark_submitted( $urls );
		} else {
			foreach ( $items as $hash => $item ) {
				$queue[ $hash ]['attempts'] = isset( $item['attempts'] ) ? absint( $item['attempts'] ) + 1 : 1;
				$queue[ $hash ]['updated']  = time();

				if ( $queue[ $hash ]['attempts'] >= 5 ) {
					self::log(
						$item['url'],
						'failed',
						'Removed from queue after 5 failed attempts.',
						'queue',
						0
					);
					unset( $queue[ $hash ] );
				}
			}
		}

		update_option( self::OPTION_QUEUE, $queue, false );
	}

	/**
	 * Submit URLs to IndexNow.
	 */
	public static function submit_urls( array $urls, $source = 'manual' ) {

		if ( ! self::is_enabled() ) {
			return array(
				'success' => false,
				'code'    => 0,
				'message' => 'IndexNow is disabled.',
			);
		}

		$clean_urls = array();

		foreach ( $urls as $url ) {
			$url = esc_url_raw( $url );

			if ( self::is_valid_submit_url( $url ) ) {
				$clean_urls[] = $url;
			}
		}

		$clean_urls = array_values( array_unique( $clean_urls ) );

		if ( empty( $clean_urls ) ) {
			return array(
				'success' => false,
				'code'    => 0,
				'message' => 'No valid URLs to submit.',
			);
		}

		$host = wp_parse_url( home_url(), PHP_URL_HOST );

		$payload = array(
			'host'        => $host,
			'key'         => self::get_key(),
			'keyLocation' => self::get_key_location(),
			'urlList'     => $clean_urls,
		);

		/**
		 * Allows modifying payload before remote request.
		 */
		$payload = apply_filters( 'rx_indexnow_payload', $payload, $clean_urls );

		$response = wp_remote_post(
			self::get_endpoint(),
			array(
				'timeout'     => 20,
				'redirection' => 3,
				'headers'     => array(
					'Content-Type' => 'application/json; charset=utf-8',
					'Accept'       => 'application/json',
				),
				'body'        => wp_json_encode( $payload ),
			)
		);

		if ( is_wp_error( $response ) ) {
			foreach ( $clean_urls as $url ) {
				self::log( $url, 'error', $response->get_error_message(), $source, 0 );
			}

			return array(
				'success' => false,
				'code'    => 0,
				'message' => $response->get_error_message(),
			);
		}

		$code = absint( wp_remote_retrieve_response_code( $response ) );
		$body = wp_remote_retrieve_body( $response );

		$success_codes = array( 200, 202 );

		$success = in_array( $code, $success_codes, true );

		foreach ( $clean_urls as $url ) {
			self::log(
				$url,
				$success ? 'submitted' : 'failed',
				$success ? 'Submitted to IndexNow.' : 'IndexNow rejected or failed the request. Response: ' . wp_strip_all_tags( $body ),
				$source,
				$code
			);
		}

		if ( $success ) {
			self::mark_submitted( $clean_urls );
		}

		return array(
			'success' => $success,
			'code'    => $code,
			'message' => $success ? 'Submitted successfully.' : 'Submission failed.',
			'body'    => $body,
		);
	}

	/**
	 * Submit one URL immediately.
	 */
	public static function submit_url_now( $url, $source = 'manual' ) {
		return self::submit_urls( array( $url ), $source );
	}

	/**
	 * Add log.
	 */
	public static function log( $url, $status, $message, $source = 'system', $code = 0 ) {

		$logs = get_option( self::OPTION_LOGS, array() );

		if ( ! is_array( $logs ) ) {
			$logs = array();
		}

		array_unshift(
			$logs,
			array(
				'time'    => current_time( 'mysql' ),
				'url'     => esc_url_raw( $url ),
				'status'  => sanitize_key( $status ),
				'message' => sanitize_text_field( $message ),
				'source'  => sanitize_key( $source ),
				'code'    => absint( $code ),
			)
		);

		$logs = array_slice( $logs, 0, 300 );

		update_option( self::OPTION_LOGS, $logs, false );
	}

	/**
	 * Admin menu.
	 */
	public static function admin_menu() {
		add_management_page(
			__( 'RX IndexNow', 'rx-theme' ),
			__( 'RX IndexNow', 'rx-theme' ),
			'manage_options',
			'rx-indexnow',
			array( __CLASS__, 'render_admin_page' )
		);
	}

	/**
	 * Register settings.
	 */
	public static function register_settings() {

		register_setting(
			'rx_indexnow_settings',
			self::OPTION_ENABLED,
			array(
				'type'              => 'string',
				'sanitize_callback' => array( __CLASS__, 'sanitize_checkbox' ),
				'default'           => '1',
			)
		);

		register_setting(
			'rx_indexnow_settings',
			self::OPTION_AUTO_SUBMIT,
			array(
				'type'              => 'string',
				'sanitize_callback' => array( __CLASS__, 'sanitize_checkbox' ),
				'default'           => '1',
			)
		);

		register_setting(
			'rx_indexnow_settings',
			self::OPTION_SUBMIT_ON_DELETE,
			array(
				'type'              => 'string',
				'sanitize_callback' => array( __CLASS__, 'sanitize_checkbox' ),
				'default'           => '1',
			)
		);

		register_setting(
			'rx_indexnow_settings',
			self::OPTION_ENDPOINT,
			array(
				'type'              => 'string',
				'sanitize_callback' => 'esc_url_raw',
				'default'           => self::DEFAULT_ENDPOINT,
			)
		);

		register_setting(
			'rx_indexnow_settings',
			self::OPTION_POST_TYPES,
			array(
				'type'              => 'array',
				'sanitize_callback' => array( __CLASS__, 'sanitize_post_types' ),
				'default'           => array( 'post', 'page' ),
			)
		);

		register_setting(
			'rx_indexnow_settings',
			self::OPTION_COOLDOWN,
			array(
				'type'              => 'integer',
				'sanitize_callback' => 'absint',
				'default'           => 10,
			)
		);

		register_setting(
			'rx_indexnow_settings',
			self::OPTION_BATCH_SIZE,
			array(
				'type'              => 'integer',
				'sanitize_callback' => 'absint',
				'default'           => 100,
			)
		);
	}

	/**
	 * Sanitize checkbox.
	 */
	public static function sanitize_checkbox( $value ) {
		return '1' === (string) $value ? '1' : '0';
	}

	/**
	 * Sanitize post types.
	 */
	public static function sanitize_post_types( $post_types ) {

		if ( ! is_array( $post_types ) ) {
			return array( 'post', 'page' );
		}

		$public_post_types = get_post_types(
			array(
				'public' => true,
			),
			'names'
		);

		$clean = array();

		foreach ( $post_types as $post_type ) {
			$post_type = sanitize_key( $post_type );

			if ( in_array( $post_type, $public_post_types, true ) ) {
				$clean[] = $post_type;
			}
		}

		return array_values( array_unique( $clean ) );
	}

	/**
	 * Admin page.
	 */
	public static function render_admin_page() {

		if ( ! current_user_can( 'manage_options' ) ) {
			wp_die( esc_html__( 'You do not have permission to access this page.', 'rx-theme' ) );
		}

		$logs       = get_option( self::OPTION_LOGS, array() );
		$queue      = get_option( self::OPTION_QUEUE, array() );
		$post_types = get_post_types(
			array(
				'public' => true,
			),
			'objects'
		);

		$selected_post_types = self::get_allowed_post_types();

		?>
		<div class="wrap">
			<h1><?php esc_html_e( 'RX Theme IndexNow', 'rx-theme' ); ?></h1>

			<?php if ( isset( $_GET['rx_indexnow_notice'] ) ) : ?>
				<div class="notice notice-success is-dismissible">
					<p><?php echo esc_html( wp_unslash( $_GET['rx_indexnow_notice'] ) ); ?></p>
				</div>
			<?php endif; ?>

			<div style="background:#fff;border:1px solid #ccd0d4;padding:15px;margin:15px 0;">
				<h2><?php esc_html_e( 'IndexNow Key Information', 'rx-theme' ); ?></h2>
				<p><strong><?php esc_html_e( 'API Key:', 'rx-theme' ); ?></strong> <code><?php echo esc_html( self::get_key() ); ?></code></p>
				<p><strong><?php esc_html_e( 'Key Location:', 'rx-theme' ); ?></strong> <a href="<?php echo esc_url( self::get_key_location() ); ?>" target="_blank" rel="noopener"><?php echo esc_html( self::get_key_location() ); ?></a></p>
				<p><strong><?php esc_html_e( 'Queue Count:', 'rx-theme' ); ?></strong> <?php echo esc_html( is_array( $queue ) ? count( $queue ) : 0 ); ?></p>

				<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>" style="display:inline-block;margin-right:10px;">
					<?php wp_nonce_field( 'rx_indexnow_regenerate_key' ); ?>
					<input type="hidden" name="action" value="rx_indexnow_regenerate_key">
					<?php submit_button( __( 'Regenerate Key', 'rx-theme' ), 'secondary', 'submit', false ); ?>
				</form>

				<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>" style="display:inline-block;">
					<?php wp_nonce_field( 'rx_indexnow_process_queue_now' ); ?>
					<input type="hidden" name="action" value="rx_indexnow_process_queue_now">
					<?php submit_button( __( 'Process Queue Now', 'rx-theme' ), 'secondary', 'submit', false ); ?>
				</form>
			</div>

			<form method="post" action="options.php">
				<?php settings_fields( 'rx_indexnow_settings' ); ?>

				<table class="form-table" role="presentation">
					<tr>
						<th scope="row"><?php esc_html_e( 'Enable IndexNow', 'rx-theme' ); ?></th>
						<td>
							<label>
								<input type="checkbox" name="<?php echo esc_attr( self::OPTION_ENABLED ); ?>" value="1" <?php checked( self::is_enabled() ); ?>>
								<?php esc_html_e( 'Enable IndexNow URL submission.', 'rx-theme' ); ?>
							</label>
						</td>
					</tr>

					<tr>
						<th scope="row"><?php esc_html_e( 'Auto Submit', 'rx-theme' ); ?></th>
						<td>
							<label>
								<input type="checkbox" name="<?php echo esc_attr( self::OPTION_AUTO_SUBMIT ); ?>" value="1" <?php checked( '1', get_option( self::OPTION_AUTO_SUBMIT, '1' ) ); ?>>
								<?php esc_html_e( 'Submit when posts/pages are published or updated.', 'rx-theme' ); ?>
							</label>
						</td>
					</tr>

					<tr>
						<th scope="row"><?php esc_html_e( 'Submit Deleted URLs', 'rx-theme' ); ?></th>
						<td>
							<label>
								<input type="checkbox" name="<?php echo esc_attr( self::OPTION_SUBMIT_ON_DELETE ); ?>" value="1" <?php checked( '1', get_option( self::OPTION_SUBMIT_ON_DELETE, '1' ) ); ?>>
								<?php esc_html_e( 'Notify IndexNow when a public post/page is deleted.', 'rx-theme' ); ?>
							</label>
						</td>
					</tr>

					<tr>
						<th scope="row"><?php esc_html_e( 'IndexNow Endpoint', 'rx-theme' ); ?></th>
						<td>
							<input type="url" class="regular-text" name="<?php echo esc_attr( self::OPTION_ENDPOINT ); ?>" value="<?php echo esc_attr( self::get_endpoint() ); ?>">
							<p class="description"><?php esc_html_e( 'Default: https://api.indexnow.org/indexnow', 'rx-theme' ); ?></p>
						</td>
					</tr>

					<tr>
						<th scope="row"><?php esc_html_e( 'Allowed Post Types', 'rx-theme' ); ?></th>
						<td>
							<?php foreach ( $post_types as $post_type ) : ?>
								<label style="display:block;margin-bottom:5px;">
									<input type="checkbox" name="<?php echo esc_attr( self::OPTION_POST_TYPES ); ?>[]" value="<?php echo esc_attr( $post_type->name ); ?>" <?php checked( in_array( $post_type->name, $selected_post_types, true ) ); ?>>
									<?php echo esc_html( $post_type->labels->singular_name ); ?> <code><?php echo esc_html( $post_type->name ); ?></code>
								</label>
							<?php endforeach; ?>
						</td>
					</tr>

					<tr>
						<th scope="row"><?php esc_html_e( 'Cooldown Minutes', 'rx-theme' ); ?></th>
						<td>
							<input type="number" min="0" name="<?php echo esc_attr( self::OPTION_COOLDOWN ); ?>" value="<?php echo esc_attr( absint( get_option( self::OPTION_COOLDOWN, 10 ) ) ); ?>">
							<p class="description"><?php esc_html_e( 'Prevents repeated submission of the same URL within this time window. Use 0 to disable.', 'rx-theme' ); ?></p>
						</td>
					</tr>

					<tr>
						<th scope="row"><?php esc_html_e( 'Batch Size', 'rx-theme' ); ?></th>
						<td>
							<input type="number" min="1" max="10000" name="<?php echo esc_attr( self::OPTION_BATCH_SIZE ); ?>" value="<?php echo esc_attr( absint( get_option( self::OPTION_BATCH_SIZE, 100 ) ) ); ?>">
							<p class="description"><?php esc_html_e( 'How many queued URLs to submit per cron run.', 'rx-theme' ); ?></p>
						</td>
					</tr>
				</table>

				<?php submit_button( __( 'Save Settings', 'rx-theme' ) ); ?>
			</form>

			<hr>

			<h2><?php esc_html_e( 'Manual URL Submit', 'rx-theme' ); ?></h2>
			<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>">
				<?php wp_nonce_field( 'rx_indexnow_submit_manual' ); ?>
				<input type="hidden" name="action" value="rx_indexnow_submit_manual">
				<p>
					<textarea name="rx_indexnow_urls" rows="6" class="large-text code" placeholder="<?php esc_attr_e( 'Enter one URL per line from your own website.', 'rx-theme' ); ?>"></textarea>
				</p>
				<?php submit_button( __( 'Submit URLs Now', 'rx-theme' ) ); ?>
			</form>

			<hr>

			<h2><?php esc_html_e( 'Submit Sitemap URLs', 'rx-theme' ); ?></h2>
			<p><?php esc_html_e( 'This reads your sitemap XML and queues URLs found inside it.', 'rx-theme' ); ?></p>
			<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>">
				<?php wp_nonce_field( 'rx_indexnow_submit_sitemap' ); ?>
				<input type="hidden" name="action" value="rx_indexnow_submit_sitemap">
				<input type="url" class="regular-text" name="rx_indexnow_sitemap" value="<?php echo esc_attr( home_url( '/sitemap.xml' ) ); ?>">
				<?php submit_button( __( 'Queue Sitemap URLs', 'rx-theme' ), 'secondary', 'submit', false ); ?>
			</form>

			<hr>

			<h2><?php esc_html_e( 'Submit Recent Content', 'rx-theme' ); ?></h2>
			<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>">
				<?php wp_nonce_field( 'rx_indexnow_submit_recent' ); ?>
				<input type="hidden" name="action" value="rx_indexnow_submit_recent">
				<label>
					<?php esc_html_e( 'Number of recent posts/pages:', 'rx-theme' ); ?>
					<input type="number" name="rx_indexnow_recent_count" min="1" max="500" value="50">
				</label>
				<?php submit_button( __( 'Queue Recent URLs', 'rx-theme' ), 'secondary', 'submit', false ); ?>
			</form>

			<hr>

			<h2><?php esc_html_e( 'Submission Logs', 'rx-theme' ); ?></h2>

			<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>" style="margin-bottom:15px;">
				<?php wp_nonce_field( 'rx_indexnow_clear_logs' ); ?>
				<input type="hidden" name="action" value="rx_indexnow_clear_logs">
				<?php submit_button( __( 'Clear Logs', 'rx-theme' ), 'delete', 'submit', false ); ?>
			</form>

			<table class="widefat striped">
				<thead>
					<tr>
						<th><?php esc_html_e( 'Time', 'rx-theme' ); ?></th>
						<th><?php esc_html_e( 'Status', 'rx-theme' ); ?></th>
						<th><?php esc_html_e( 'Code', 'rx-theme' ); ?></th>
						<th><?php esc_html_e( 'Source', 'rx-theme' ); ?></th>
						<th><?php esc_html_e( 'URL', 'rx-theme' ); ?></th>
						<th><?php esc_html_e( 'Message', 'rx-theme' ); ?></th>
					</tr>
				</thead>
				<tbody>
					<?php if ( empty( $logs ) ) : ?>
						<tr>
							<td colspan="6"><?php esc_html_e( 'No logs yet.', 'rx-theme' ); ?></td>
						</tr>
					<?php else : ?>
						<?php foreach ( $logs as $log ) : ?>
							<tr>
								<td><?php echo esc_html( $log['time'] ?? '' ); ?></td>
								<td><code><?php echo esc_html( $log['status'] ?? '' ); ?></code></td>
								<td><?php echo esc_html( $log['code'] ?? 0 ); ?></td>
								<td><?php echo esc_html( $log['source'] ?? '' ); ?></td>
								<td><a href="<?php echo esc_url( $log['url'] ?? '' ); ?>" target="_blank" rel="noopener"><?php echo esc_html( $log['url'] ?? '' ); ?></a></td>
								<td><?php echo esc_html( $log['message'] ?? '' ); ?></td>
							</tr>
						<?php endforeach; ?>
					<?php endif; ?>
				</tbody>
			</table>
		</div>
		<?php
	}

	/**
	 * Admin manual submit.
	 */
	public static function admin_submit_manual() {

		if ( ! current_user_can( 'manage_options' ) ) {
			wp_die( esc_html__( 'Permission denied.', 'rx-theme' ) );
		}

		check_admin_referer( 'rx_indexnow_submit_manual' );

		$raw = isset( $_POST['rx_indexnow_urls'] ) ? wp_unslash( $_POST['rx_indexnow_urls'] ) : '';
		$raw = sanitize_textarea_field( $raw );

		$urls = preg_split( '/\r\n|\r|\n/', $raw );
		$urls = array_filter( array_map( 'trim', $urls ) );

		$result = self::submit_urls( $urls, 'admin_manual' );

		$message = $result['success'] ? 'Manual URLs submitted successfully.' : 'Manual URL submission failed or had no valid URLs.';

		wp_safe_redirect(
			add_query_arg(
				array(
					'page'               => 'rx-indexnow',
					'rx_indexnow_notice' => rawurlencode( $message ),
				),
				admin_url( 'tools.php' )
			)
		);
		exit;
	}

	/**
	 * Admin queue sitemap URLs.
	 */
	public static function admin_submit_sitemap() {

		if ( ! current_user_can( 'manage_options' ) ) {
			wp_die( esc_html__( 'Permission denied.', 'rx-theme' ) );
		}

		check_admin_referer( 'rx_indexnow_submit_sitemap' );

		$sitemap = isset( $_POST['rx_indexnow_sitemap'] ) ? esc_url_raw( wp_unslash( $_POST['rx_indexnow_sitemap'] ) ) : '';

		$count = self::queue_sitemap_urls( $sitemap );

		wp_safe_redirect(
			add_query_arg(
				array(
					'page'               => 'rx-indexnow',
					'rx_indexnow_notice' => rawurlencode( sprintf( 'Queued %d sitemap URLs.', absint( $count ) ) ),
				),
				admin_url( 'tools.php' )
			)
		);
		exit;
	}

	/**
	 * Queue recent content.
	 */
	public static function admin_submit_recent() {

		if ( ! current_user_can( 'manage_options' ) ) {
			wp_die( esc_html__( 'Permission denied.', 'rx-theme' ) );
		}

		check_admin_referer( 'rx_indexnow_submit_recent' );

		$count = isset( $_POST['rx_indexnow_recent_count'] ) ? absint( $_POST['rx_indexnow_recent_count'] ) : 50;
		$count = min( max( $count, 1 ), 500 );

		$query = new WP_Query(
			array(
				'post_type'      => self::get_allowed_post_types(),
				'post_status'    => 'publish',
				'posts_per_page' => $count,
				'orderby'        => 'modified',
				'order'          => 'DESC',
				'fields'         => 'ids',
				'no_found_rows'  => true,
			)
		);

		$queued = 0;

		if ( $query->posts ) {
			foreach ( $query->posts as $post_id ) {
				if ( self::queue_url( get_permalink( $post_id ), 'admin_recent' ) ) {
					$queued++;
				}
			}
		}

		wp_safe_redirect(
			add_query_arg(
				array(
					'page'               => 'rx-indexnow',
					'rx_indexnow_notice' => rawurlencode( sprintf( 'Queued %d recent URLs.', absint( $queued ) ) ),
				),
				admin_url( 'tools.php' )
			)
		);
		exit;
	}

	/**
	 * Clear logs.
	 */
	public static function admin_clear_logs() {

		if ( ! current_user_can( 'manage_options' ) ) {
			wp_die( esc_html__( 'Permission denied.', 'rx-theme' ) );
		}

		check_admin_referer( 'rx_indexnow_clear_logs' );

		update_option( self::OPTION_LOGS, array(), false );

		wp_safe_redirect(
			add_query_arg(
				array(
					'page'               => 'rx-indexnow',
					'rx_indexnow_notice' => rawurlencode( 'Logs cleared.' ),
				),
				admin_url( 'tools.php' )
			)
		);
		exit;
	}

	/**
	 * Regenerate key.
	 */
	public static function admin_regenerate_key() {

		if ( ! current_user_can( 'manage_options' ) ) {
			wp_die( esc_html__( 'Permission denied.', 'rx-theme' ) );
		}

		check_admin_referer( 'rx_indexnow_regenerate_key' );

		update_option( self::OPTION_KEY, self::generate_key(), false );

		flush_rewrite_rules();

		wp_safe_redirect(
			add_query_arg(
				array(
					'page'               => 'rx-indexnow',
					'rx_indexnow_notice' => rawurlencode( 'IndexNow key regenerated. Visit Settings > Permalinks if the key URL does not open.' ),
				),
				admin_url( 'tools.php' )
			)
		);
		exit;
	}

	/**
	 * Process queue now.
	 */
	public static function admin_process_queue_now() {

		if ( ! current_user_can( 'manage_options' ) ) {
			wp_die( esc_html__( 'Permission denied.', 'rx-theme' ) );
		}

		check_admin_referer( 'rx_indexnow_process_queue_now' );

		self::process_queue();

		wp_safe_redirect(
			add_query_arg(
				array(
					'page'               => 'rx-indexnow',
					'rx_indexnow_notice' => rawurlencode( 'Queue processed.' ),
				),
				admin_url( 'tools.php' )
			)
		);
		exit;
	}

	/**
	 * Queue sitemap URLs.
	 */
	public static function queue_sitemap_urls( $sitemap_url ) {

		$sitemap_url = esc_url_raw( $sitemap_url );

		if ( ! self::is_valid_submit_url( $sitemap_url ) ) {
			return 0;
		}

		$response = wp_remote_get(
			$sitemap_url,
			array(
				'timeout'     => 20,
				'redirection' => 3,
				'headers'     => array(
					'Accept' => 'application/xml,text/xml,*/*',
				),
			)
		);

		if ( is_wp_error( $response ) ) {
			self::log( $sitemap_url, 'error', $response->get_error_message(), 'sitemap', 0 );
			return 0;
		}

		$code = absint( wp_remote_retrieve_response_code( $response ) );

		if ( 200 !== $code ) {
			self::log( $sitemap_url, 'failed', 'Could not fetch sitemap.', 'sitemap', $code );
			return 0;
		}

		$body = wp_remote_retrieve_body( $response );

		if ( empty( $body ) ) {
			return 0;
		}

		$urls = self::extract_urls_from_xml( $body );

		$queued = 0;

		foreach ( $urls as $url ) {
			if ( self::queue_url( $url, 'sitemap' ) ) {
				$queued++;
			}
		}

		return $queued;
	}

	/**
	 * Extract URLs from XML.
	 */
	public static function extract_urls_from_xml( $xml_body ) {

		$urls = array();

		if ( empty( $xml_body ) ) {
			return $urls;
		}

		/**
		 * Simple regex is used here because many hosts disable XML extensions.
		 */
		if ( preg_match_all( '/<loc>(.*?)<\/loc>/i', $xml_body, $matches ) ) {
			foreach ( $matches[1] as $loc ) {
				$loc = html_entity_decode( trim( wp_strip_all_tags( $loc ) ) );

				if ( self::is_valid_submit_url( $loc ) ) {
					$urls[] = esc_url_raw( $loc );
				}
			}
		}

		return array_values( array_unique( $urls ) );
	}

	/**
	 * REST routes.
	 */
	public static function register_rest_routes() {

		register_rest_route(
			self::REST_NAMESPACE,
			'/indexnow/submit',
			array(
				'methods'             => 'POST',
				'callback'            => array( __CLASS__, 'rest_submit_urls' ),
				'permission_callback' => function () {
					return current_user_can( 'manage_options' );
				},
				'args'                => array(
					'urls' => array(
						'required' => true,
						'type'     => 'array',
					),
				),
			)
		);

		register_rest_route(
			self::REST_NAMESPACE,
			'/indexnow/status',
			array(
				'methods'             => 'GET',
				'callback'            => array( __CLASS__, 'rest_status' ),
				'permission_callback' => function () {
					return current_user_can( 'manage_options' );
				},
			)
		);
	}

	/**
	 * REST submit.
	 */
	public static function rest_submit_urls( WP_REST_Request $request ) {

		$urls = $request->get_param( 'urls' );

		if ( ! is_array( $urls ) ) {
			return new WP_Error(
				'rx_indexnow_invalid_urls',
				__( 'URLs must be an array.', 'rx-theme' ),
				array( 'status' => 400 )
			);
		}

		$result = self::submit_urls( $urls, 'rest_api' );

		return rest_ensure_response( $result );
	}

	/**
	 * REST status.
	 */
	public static function rest_status() {

		$queue = get_option( self::OPTION_QUEUE, array() );
		$logs  = get_option( self::OPTION_LOGS, array() );

		return rest_ensure_response(
			array(
				'enabled'      => self::is_enabled(),
				'key'          => self::get_key(),
				'keyLocation'  => self::get_key_location(),
				'endpoint'     => self::get_endpoint(),
				'queue_count'  => is_array( $queue ) ? count( $queue ) : 0,
				'latest_logs'  => is_array( $logs ) ? array_slice( $logs, 0, 10 ) : array(),
				'post_types'   => self::get_allowed_post_types(),
				'auto_submit'  => get_option( self::OPTION_AUTO_SUBMIT, '1' ),
				'batch_size'   => absint( get_option( self::OPTION_BATCH_SIZE, 100 ) ),
			)
		);
	}
}

endif;

/**
 * Theme-safe activation style setup.
 *
 * Themes do not normally have plugin activation hooks.
 * This function safely mimics setup on theme load.
 */
if ( ! function_exists( 'register_activation_hook_safe_rx_indexnow' ) ) {
	function register_activation_hook_safe_rx_indexnow() {
		add_action(
			'after_switch_theme',
			function () {
				RX_Theme_IndexNow::maybe_install_defaults();
				RX_Theme_IndexNow::add_rewrite_rules();
				flush_rewrite_rules();

				if ( ! wp_next_scheduled( RX_Theme_IndexNow::CRON_HOOK ) ) {
					wp_schedule_event( time() + 300, 'hourly', RX_Theme_IndexNow::CRON_HOOK );
				}
			}
		);
	}
}

/**
 * Unschedule cron on theme switch.
 */
add_action(
	'switch_theme',
	function () {
		$timestamp = wp_next_scheduled( RX_Theme_IndexNow::CRON_HOOK );

		if ( $timestamp ) {
			wp_unschedule_event( $timestamp, RX_Theme_IndexNow::CRON_HOOK );
		}

		flush_rewrite_rules();
	}
);

/**
 * Start RX IndexNow.
 */
RX_Theme_IndexNow::init();

Add this to functions.php

/**
 * RX Theme SEO Modules.
 */
$rx_indexnow_file = get_template_directory() . '/inc/seo/indexnow.php';

if ( file_exists( $rx_indexnow_file ) ) {
	require_once $rx_indexnow_file;
}

After adding the file

Go to WordPress Admin → Settings → Permalinks → Save Changes once. This refreshes rewrite rules so your key file URL works, like:

https://yourdomain.com/YOUR_INDEXNOW_KEY.txt

Admin panel will appear here:

Tools → RX IndexNow

This module submits URLs to IndexNow-supported search engines through the IndexNow protocol, but remember: IndexNow does not guarantee ranking or indexing; it only notifies search engines faster about changed URLs. Google’s normal indexing still depends mainly on sitemap, crawlability, internal links, page quality, and Search Console.

Leave a Reply

Your email address will not be published. Required fields are marked *