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.