WP Tuts Hub

WordPress Tutorials, Themes and Plugins Hub
Advertisement

Browse

  • Home
  • Categories
    • WordPress Troubleshooting
    • WordPress Tips and Tricks
    • SSH (Secure Shell)
    • WordPress Insights
    • WordPress Common Facts
    • Securing WordPress
  • Series Tutorials
    • Advanced WordPress Topic
  • Privacy Policy
  • Terms of Use
  • Contact Me
  • About WP Tuts Hub
Home/Advanced WordPress Topic/Screen WordPress Comments with OpenAI Moderation API Before They Reach the Database
Advanced WordPress Topic
October 21, 2026

Screen WordPress Comments with OpenAI Moderation API Before They Reach the Database

Why the filter has to run before the insert

Most comment-spam tutorials bolt onto wp_insert_comment or a cron sweep. By then the row already exists, the spam is in your database, and every cleanup is a delete rather than a prevention. WordPress gives you a cleaner seam: the pre_comment_approved filter fires inside wp_new_comment() before the row is written, and its return value decides the stored status — 1 for approved, 0 for pending, 'spam' for the spam bin, 'trash' for the bin.

That filter is also the seam used by the REST comments controller, so anything posting to /wp-json/wp/v2/comments is screened by the same code path. One hook, two entry points.

This tutorial wires that filter to OpenAI’s Moderation endpoint with a hard timeout, a category-to-status policy, a failure policy you choose, a decision log, and per-submitter rate limiting. It is deliberately blocking: you cannot know the verdict before the insert without waiting for the verdict.

Prerequisites

  • WordPress 6.4 or newer, PHP 8.0+, and outbound HTTPS from the server (many managed hosts block it by default).
  • An OpenAI API key with access to the Moderation endpoint. omni-moderation-latest is free to call, which matters because comment volume is unpredictable.
  • Ability to edit wp-config.php and add a small mu-plugin.
  • A staging site. You are about to change what happens to every comment on the site.

Put the code in wp-content/mu-plugins/comment-moderation.php so it cannot be deactivated by accident from the plugins screen.

Step 1 Put the key and the policy in wp-config.php

Constants beat options for secrets: they are not autoloaded into every page request, they are not exposed by a settings-page mistake, and they are not written to the database backup that gets emailed around. Define them above the stop editing line.

// wp-config.php
// Never commit this file to a public repository.
define( 'WPTUTS_OPENAI_KEY', 'sk-proj-replace-me' );

// 'closed' = hold the comment for a human when the API is unreachable.
// 'open'   = let the comment through as normal when the API is unreachable.
define( 'WPTUTS_MODERATION_FAILURE_POLICY', 'closed' );

// Category score at or above this counts as a hit (0.00 - 1.00).
define( 'WPTUTS_MODERATION_THRESHOLD', 0.55 );

Why a threshold at all? The endpoint returns both a boolean flagged and per-category category_scores. The boolean uses OpenAI’s own cut-offs, which are tuned for general content, not for a blog comment box where a heated argument about politics is normal but a threat is not. Reading the scores yourself lets you tune the sensitivity per category instead of accepting one global switch.

Step 2 Build a moderation client with a hard timeout

Use wp_remote_post() rather than raw cURL so proxies, SSL certificates, and the WP_HTTP_BLOCK_EXTERNAL constant are handled consistently. The timeout is the important number: a comment form that hangs for thirty seconds is a denial-of-service on your own readers.

function wptuts_moderate_text( string $text ): ?array {
	$key = defined( 'WPTUTS_OPENAI_KEY' ) ? WPTUTS_OPENAI_KEY : '';
	$text = trim( $text );

	if ( '' === $key || '' === $text ) {
		return null;
	}

	// Identical text (bots repeat themselves) should not cost a second round trip.
	$cache_key = 'wptuts_mod_' . md5( strtolower( $text ) );
	$cached    = get_transient( $cache_key );
	if ( is_array( $cached ) ) {
		return $cached;
	}

	$response = wp_remote_post(
		'https://api.openai.com/v1/moderations',
		array(
			'timeout'     => 3,
			'redirection' => 0,
			'blocking'    => true,
			'headers'     => array(
				'Authorization' => 'Bearer ' . $key,
				'Content-Type'  => 'application/json',
			),
			'body'        => wp_json_encode(
				array(
					'model' => 'omni-moderation-latest',
					'input' => mb_substr( $text, 0, 4000 ),
				)
			),
		)
	);

	if ( is_wp_error( $response ) ) {
		error_log( 'WPTuts moderation transport: ' . $response->get_error_message() );
		return null;
	}

	$code = (int) wp_remote_retrieve_response_code( $response );
	if ( 200 !== $code ) {
		error_log( 'WPTuts moderation HTTP ' . $code . ': ' . wp_remote_retrieve_body( $response ) );
		return null;
	}

	$body = json_decode( wp_remote_retrieve_body( $response ), true );
	if ( ! isset( $body['results'][0]['category_scores'] ) ) {
		return null;
	}

	set_transient( $cache_key, $body['results'][0], HOUR_IN_SECONDS );

	return $body['results'][0];
}

Returning null for every failure mode — no key, transport error, non-200, malformed body — keeps the caller simple: null means no verdict, and the failure policy decides what that means.

Step 3 Map categories to pending or spam

Split the categories into two tiers. Tier one is content you never want published and never want a moderator to accidentally approve: sexual content involving minors, threatening hate, threatening harassment, illicit violent instructions. Tier two is content that may be legitimate but needs a human eye: general harassment, hate, self-harm, sexual, violence, graphic violence.

function wptuts_comment_decision( array $result ): string {
	$threshold = defined( 'WPTUTS_MODERATION_THRESHOLD' ) ? (float) WPTUTS_MODERATION_THRESHOLD : 0.55;
	$scores    = (array) $result['category_scores'];

	$never_publish = array(
		'sexual/minors',
		'hate/threatening',
		'harassment/threatening',
		'illicit/violent',
	);

	$hold_for_human = array(
		'harassment',
		'hate',
		'self-harm',
		'self-harm/intent',
		'self-harm/instructions',
		'sexual',
		'violence',
		'violence/graphic',
	);

	foreach ( $never_publish as $category ) {
		if ( isset( $scores[ $category ] ) && $scores[ $category ] >= $threshold ) {
			return 'spam';
		}
	}

	foreach ( $hold_for_human as $category ) {
		if ( isset( $scores[ $category ] ) && $scores[ $category ] >= $threshold ) {
			return 'hold';
		}
	}

	return 'approve';
}

Note that 'spam' is a string, not a boolean. Returning true from the filter approves the comment; returning the string 'spam' is what routes it to the spam bin. Getting this wrong is the single most common bug in this integration.

Step 4 Hook the filter and rate-limit repeat submitters

Rate limiting belongs before the API call. A bot firing two hundred comments a minute should be stopped by a transient counter, not by two hundred billable requests. Hash the IP and email together so a shared office NAT does not lock out a whole building, and keep the window short.

function wptuts_submitter_is_flooding( array $commentdata ): bool {
	$fingerprint = ( $commentdata['comment_author_IP'] ?? '' ) . '|' . strtolower( $commentdata['comment_author_email'] ?? '' );
	$key         = 'wptuts_flood_' . md5( $fingerprint );
	$hits        = (int) get_transient( $key );

	if ( $hits >= 3 ) {
		return true;
	}

	set_transient( $key, $hits + 1, 10 * MINUTE_IN_SECONDS );

	return false;
}

add_filter( 'pre_comment_approved', 'wptuts_screen_comment', 10, 2 );

function wptuts_screen_comment( $approved, array $commentdata ) {
	// Trusted staff bypass the screen entirely.
	if ( ! empty( $commentdata['user_id'] ) && user_can( (int) $commentdata['user_id'], 'moderate_comments' ) ) {
		return $approved;
	}

	if ( wptuts_submitter_is_flooding( $commentdata ) ) {
		wptuts_log_decision( $commentdata, 'rate_limited' );
		return 0;
	}

	$text = trim(
		( $commentdata['comment_content'] ?? '' ) . ' ' .
		( $commentdata['comment_author'] ?? '' ) . ' ' .
		( $commentdata['comment_author_url'] ?? '' )
	);

	$result = wptuts_moderate_text( $text );

	if ( null === $result ) {
		$policy = defined( 'WPTUTS_MODERATION_FAILURE_POLICY' ) ? WPTUTS_MODERATION_FAILURE_POLICY : 'closed';
		wptuts_log_decision( $commentdata, 'api_failure' );
		return 'open' === $policy ? $approved : 0;
	}

	$decision = wptuts_comment_decision( $result );
	wptuts_log_decision( $commentdata, $decision, $result );

	if ( 'spam' === $decision ) {
		return 'spam';
	}

	return 'hold' === $decision ? 0 : $approved;
}

Fail closed or fail open? Failing closed (holding the comment) protects readers but means an OpenAI outage silently swallows every comment until someone notices. Failing open keeps conversation flowing but lets a determined spammer through during that same outage. For a low-traffic personal blog, fail open. For a brand site with legal exposure, fail closed and monitor the log. The constant makes the choice explicit rather than accidental.

Step 5 Log every decision

Without a log you cannot answer the only question that matters after a false positive: what did the model actually score? Store hashed identifiers rather than raw IPs and emails, and store only a short excerpt of the comment so the log itself does not become a personal-data liability.

CREATE TABLE {$wpdb->prefix}wptuts_moderation_log (
  id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  created_at DATETIME NOT NULL,
  ip_hash CHAR(64) NOT NULL,
  email_hash CHAR(64) NOT NULL,
  decision VARCHAR(20) NOT NULL,
  top_category VARCHAR(40) DEFAULT NULL,
  top_score DECIMAL(5,4) DEFAULT NULL,
  excerpt VARCHAR(190) NOT NULL,
  PRIMARY KEY (id),
  KEY decision (decision),
  KEY created_at (created_at)
) {$charset_collate};

Create it once with dbDelta() guarded by an option flag, then write to it from the logger. Note the third argument to $wpdb->insert() — the format array — is a separate parameter, not part of the data array.

function wptuts_log_decision( array $commentdata, string $decision, ?array $result = null ): void {
	global $wpdb;

	$scores = ( is_array( $result ) && isset( $result['category_scores'] ) ) ? (array) $result['category_scores'] : array();
	arsort( $scores );

	$wpdb->insert(
		$wpdb->prefix . 'wptuts_moderation_log',
		array(
			'created_at'  => current_time( 'mysql', true ),
			'ip_hash'     => hash( 'sha256', (string) ( $commentdata['comment_author_IP'] ?? '' ) ),
			'email_hash'  => hash( 'sha256', strtolower( (string) ( $commentdata['comment_author_email'] ?? '' ) ) ),
			'decision'    => $decision,
			'top_category' => $scores ? (string) key( $scores ) : null,
			'top_score'   => $scores ? (float) current( $scores ) : null,
			'excerpt'     => mb_substr( wp_strip_all_tags( (string) ( $commentdata['comment_content'] ?? '' ) ), 0, 180 ),
		),
		array( '%s', '%s', '%s', '%s', '%s', '%f', '%s' )
	);
}

Query it weekly: SELECT decision, top_category, COUNT(*) FROM wp_wptuts_moderation_log GROUP BY decision, top_category ORDER BY 3 DESC; A category that fires constantly at 0.56 is a threshold problem, not a spam problem.

Step 6 Capability-safe admin overrides

The failure policy should be switchable without editing files, but only by someone who already has manage_options, and only through a nonce-checked POST. Never read a policy from $_GET, and never let a subscriber influence moderation behaviour.

add_action( 'admin_menu', function () {
	add_options_page( 'Comment Moderation', 'Comment Moderation', 'manage_options', 'wptuts-moderation', 'wptuts_render_settings' );
} );

function wptuts_render_settings(): void {
	if ( ! current_user_can( 'manage_options' ) ) {
		wp_die( esc_html__( 'You do not have permission to change moderation policy.', 'wptuts' ) );
	}

	if ( isset( $_POST['wptuts_policy'] ) ) {
		check_admin_referer( 'wptuts_save_policy' );
		$policy = ( 'open' === $_POST['wptuts_policy'] ) ? 'open' : 'closed';
		update_option( 'wptuts_moderation_policy', $policy );
		echo '<div class="notice notice-success"><p>Policy saved.</p></div>';
	}

	$policy = get_option( 'wptuts_moderation_policy', 'closed' );

	echo '<form method="post">';
	wp_nonce_field( 'wptuts_save_policy' );

	foreach ( array( 'closed' => 'Hold for a human when the API fails', 'open' => 'Publish when the API fails' ) as $value => $label ) {
		printf(
			'<p><label><input type="radio" name="wptuts_policy" value="%1$s" %2$s> %3$s</label></p>',
			esc_attr( $value ),
			checked( $policy, $value, false ),
			esc_html( $label )
		);
	}

	submit_button( 'Save policy' );
	echo '</form>';
}

Then read get_option( 'wptuts_moderation_policy' ) in wptuts_screen_comment() and fall back to the constant only when the option is empty. Also register a privacy-policy entry with wp_add_privacy_policy_content() — you are sending comment text to a third party, and that belongs in the disclosure, not in a footnote.

Troubleshooting

  • HTTP 401 on every comment. The constant is defined after require_once ABSPATH . 'wp-settings.php';, or in a theme file. Move it into wp-config.php above the stop-editing line and confirm with defined( 'WPTUTS_OPENAI_KEY' ).
  • Comments vanish instead of going to spam. You returned true or 1 where you meant 'spam'. The filter accepts 1, 0, 'spam', 'trash', and WP_Error — nothing else.
  • Every submission times out. Outbound HTTPS is blocked. Check WP_HTTP_BLOCK_EXTERNAL and WP_ACCESSIBLE_HOSTS, then test with wp_remote_get( 'https://api.openai.com/v1/models' ) from WP-CLI.
  • HTTP 429. You are hitting the rate limit. The transient cache in step 2 handles repeated identical text; for genuinely high volume, batch the input array instead of sending one request per comment.
  • Staff comments get held. The user_can() bypass relies on commentdata['user_id'], which is only set for logged-in commenters. Anonymous staff are anonymous.
  • Nothing is logged. The table was never created because admin_init has not run since you deployed. Visit wp-admin once, or run the dbDelta() call from WP-CLI.

Next steps

The blocking approach costs latency on every submission. If that becomes a problem, invert the design: always insert as 0 (pending), then re-check asynchronously with wp_schedule_single_event() and promote to 1 or demote to 'spam' from the cron job. You keep the pre-database guarantee for the risky cases and move the common case off the request path.

From there, three extensions worth building: a dashboard widget that surfaces the last twenty hold decisions with their top category, a weekly digest email of threshold hits so you notice drift, and reuse of wptuts_moderate_text() on your contact form handler — the client function is deliberately decoupled from comments so it drops straight into any wp_remote_post workflow.

Share this:

  • Share on X (Opens in new window) X
  • Share on Facebook (Opens in new window) Facebook
  • Share on LinkedIn (Opens in new window) LinkedIn
  • Share on WhatsApp (Opens in new window) WhatsApp
  • Share on Reddit (Opens in new window) Reddit
  • Email a link to a friend (Opens in new window) Email

Related

Previous Article Build an AI Rewrite Tool in the Gutenberg Editor Sidebar (Streaming, Abortable, Nonce-Protected)
Next Article PHP2Shell Critical RCE: Protect WordPress Before and After the Patch

About the author

Md. Zubaer Ahammed
Md. Zubaer Ahammed

Md. Zubaer Ahammed is a senior full-stack developer building learning platforms and modern web systems with WordPress, Laravel, Python/Django, Vue, and React. More at https://zubaer.com/.

Related Posts

  • Server rack and laptop representing a critical PHP security alert for WordPress sites PHP2Shell Critical RCE: Protect WordPress Before and After the Patch
  • Build an AI Rewrite Tool in the Gutenberg Editor Sidebar (Streaming, Abortable, Nonce-Protected) — photo by artemtation | pixabay:243010 Build an AI Rewrite Tool in the Gutenberg Editor Sidebar (Streaming, Abortable, Nonce-Protected)

Leave a Reply

Cancel reply

WP Tuts Hub

WordPress tutorials, themes, and plugins — written for builders.

  • Portfolio
  • Calorie Calculator Pro
  • Contact
  • About
  • Privacy
  • Terms

© 2026 WP Tuts Hub · Md. Zubaer Ahammed