formsieve Docs Support
All docs pages

Developer hooks and WP-CLI

Formsieve prefixes every hook, option and transient with formsieve_ (constants with FORMSIEVE_), bundles its own copy of the Formsieve core under the namespace Formsieve\WP\Core\… and adds the WP-CLI command wp formsieve. One set of hooks covers every form plugin Formsieve protects: $ctx->host() says which one handled the submission (gf for Gravity Forms, cf7 for Contact Form 7). Form IDs are unique only within one form plugin, so check the host before you compare a form ID.

The examples below are ready to paste into a small plugin or a must-use plugin (wp-content/mu-plugins/). Filters that run when a form is submitted also work from a theme's functions.php, but formsieve_booted fires on plugins_loaded, before any theme is loaded, so a listener for it must live in a plugin or mu-plugin.

Moving from Formsieve for Gravity Forms or Formsieve for CF7: their hooks were named fsv_gf_… and fsv_cf7_…. Rename them to formsieve_… and add a host check where the code was meant for one form plugin. The old names get no aliases (as of 2026-09-26).

Filters

Filter Arguments Return
formsieve_should_check bool $check, Context $ctx false skips the check (logged as should_check, delivered).
formsieve_payload array $payload (state, questions), Context $ctx The request body parts. Invalid values are ignored.
formsieve_thresholds array $thresholds (review, block), Context $ctx Thresholds between 0 and 1.
formsieve_verdict Verdict $verdict, Context $ctx A Verdict, before it is logged and applied (other types are ignored).
formsieve_api_base string $base_url, string $provider_id The base URL, after the FORMSIEVE_API_BASE constant.
formsieve_api_key string $stored_key The key. The FORMSIEVE_API_KEY constant wins over this filter.
formsieve_pipeline_stages Stage[] $stages The ordered pre-filter stages.
formsieve_cli_sample_context Context $ctx, int $form_id, string $kind (spam or ham) The Context used by wp formsieve test <form_id>; other types are ignored.
formsieve_validation_message string $message, Verdict $verdict, Context $ctx Gravity Forms only: the message shown when the form's action is Refuse it with a validation error; the result goes through wp_kses_post.
formsieve_tag_notification bool $tag, array $notification, array $form, array $entry Gravity Forms only: return false to keep the "[Possible spam NN%]" prefix off one notification.

Actions

Action Arguments When
formsieve_decided Verdict $verdict, Context $ctx After every decision has been logged (the verdict carries log_id()).
formsieve_before_request Request $request, Context $ctx Just before a live API call.
formsieve_feedback int $entry_ref, string $label (spam or ham), ?array $log_row After an administrator's correction. The log row's host column says which form plugin. For Gravity Forms, $entry_ref is the entry ID and $log_row is the entry's submission row (not a Re-check row), or null when the entry has no Formsieve log row. For Contact Form 7, $entry_ref is the Flamingo message ID (0 for a correction made in the Log tab on a row without a message); moving a Flamingo message that has no Formsieve log row fires nothing.
formsieve_tested array $result After Test connection sent its request (not when it was refused for missing consent or key).
formsieve_booted Plugin $plugin Once the plugin has started, on plugins_loaded.
formsieve_daily none The daily retention task (a cron event).

Context (Formsieve\WP\Core\Pipeline\Context) gives you the submission as the form plugin's module built it: host(), form_id(), fields(), email_domain(), message(), text(), settings(), setting( $key ), site() and more. It contains visitor data: never log or send it as a whole. Verdict (Formsieve\WP\Core\Classifier\Verdict) offers verdict(), is_allow(), is_review(), is_block(), p(), spam_percent(), category(), reason(), reason_code(), stage(), model(), model_status(), provider(), request_id(), latency_ms(), input_tokens(), cost_micro_usd(), is_checked(), is_demo(), log_id() and with( array $changes ).

Examples

Skip one Gravity Forms form entirely:

add_filter( 'formsieve_should_check', function ( $check, $ctx ) {
	return 'gf' === $ctx->host() && 12 === $ctx->form_id() ? false : $check;
}, 10, 2 );

Stricter blocking on one Contact Form 7 form:

add_filter( 'formsieve_thresholds', function ( $thresholds, $ctx ) {
	if ( 'cf7' === $ctx->host() && 5 === $ctx->form_id() ) {
		$thresholds['block'] = 0.95;
	}
	return $thresholds;
}, 10, 2 );

Never block on one form; send its would-be blocks to review:

add_filter( 'formsieve_verdict', function ( $verdict, $ctx ) {
	if ( 'gf' === $ctx->host() && 3 === $ctx->form_id() && $verdict->is_block() ) {
		return $verdict->with( array( 'verdict' => 'review' ) );
	}
	return $verdict;
}, 10, 2 );

Tell the model more about your site (keep visitor text out of everything except state.submission):

add_filter( 'formsieve_payload', function ( $payload, $ctx ) {
	$payload['state']['site']['description'] .= ' We never buy SEO, marketing or web design services.';
	return $payload;
}, 10, 2 );

Changing the questions changes what the thresholds mean; see Calibration.

Log blocked submissions somewhere else:

add_action( 'formsieve_decided', function ( $verdict, $ctx ) {
	if ( $verdict->is_block() && 'model' === $verdict->stage() ) {
		error_log( sprintf( 'Formsieve blocked %s form %d at %d%% (request %s)', $ctx->host(), $ctx->form_id(), $verdict->spam_percent(), $verdict->request_id() ) );
	}
}, 10, 2 );

Read the key from the environment:

add_filter( 'formsieve_api_key', function ( $stored ) {
	$key = getenv( 'FORMSIEVE_KEY' );
	return $key ? $key : $stored;
} );

Word the validation message your way (Gravity Forms):

add_filter( 'formsieve_validation_message', function ( $message, $verdict, $ctx ) {
	return 'Sorry, this message could not be sent. Please call us instead.';
}, 10, 3 );

Never tag the notification that goes to your CRM mailbox (Gravity Forms):

add_filter( 'formsieve_tag_notification', function ( $tag, $notification, $form, $entry ) {
	return 'CRM import' === ( $notification['name'] ?? '' ) ? false : $tag;
}, 10, 4 );

Add your own pre-filter stage (it runs first here). The class is declared only when Formsieve is active, so the code cannot break the site when the plugin is deactivated:

add_action( 'plugins_loaded', function () {
	if ( ! interface_exists( 'Formsieve\WP\Core\Pipeline\Stage' ) ) {
		return;
	}

	final class My_Partner_Allowlist implements \Formsieve\WP\Core\Pipeline\Stage {
		public function id(): string {
			return 'partner_allowlist';
		}

		public function run( \Formsieve\WP\Core\Pipeline\Context $ctx, \Formsieve\WP\Core\Pipeline\Result $result ): void {
			if ( 'partner.example' === $ctx->email_domain() ) {
				$result->decided( 'allow', 'allowlist:partner' );
			}
		}
	}

	add_filter( 'formsieve_pipeline_stages', function ( $stages ) {
		array_unshift( $stages, new My_Partner_Allowlist() );
		return $stages;
	} );
}, 30 );

WP-CLI

wp formsieve test [<form_id>] [--host=gf|cf7] [--sample=spam|ham] [--yes] [--format=table|json]
wp formsieve stats [--days=<n>] [--format=table|json]
wp formsieve status [--refresh] [--format=table|json]
wp formsieve key set|verify|clear
wp formsieve migrate [--dry-run] [--status] [--cleanup]
  • test without a form ID sends the canned Test connection sample with the saved key (it does not count as an AI check) and prints the result; nothing is logged. Like the button, it sends nothing until consent is given. It exits with an error when the connection fails, so you can use it in monitoring.
  • test <form_id> builds a synthetic spam (default) or ham (--sample=ham) submission for that form and classifies it exactly like a real one: pre-filters, one API call (or Test mode), decision and one log row, marked as a WP-CLI sample (source = cli). --host says which form plugin the form belongs to (default: the first active one, Gravity Forms before Contact Form 7). Its API call counts as an AI check, but the sample is not counted as a submission. Outside Test mode it spends one AI check, so it needs --yes. The category field of its output is the raw category ID.
  • stats prints decision counts per band, API share, tokens, cost, latency and corrections for the last --days days (default 30), plus the month to date and the projection, with the same rules as the dashboard: the decision counts cover visitor submissions only, while API calls, tokens and cost include re-checks and WP-CLI samples. --format=json adds the daily breakdown, categories and breaker state.
  • status prints the key's plan, this month's AI checks and whether this site has a slot; --refresh asks Formsieve's service again instead of using the cached answer.
  • key set reads the key from standard input (printf '%s' "$FORMSIEVE_KEY" | wp formsieve key set), so it never appears in your shell history; key verify checks it; key clear removes it and frees this site's slot. The key is never printed in full.
  • migrate moves the data of Formsieve for Gravity Forms and Formsieve for CF7, as activation does (Moving from the two earlier plugins): --dry-run shows what would move, --status shows how far it got, --cleanup removes the old plugins' data now (only while they are inactive).

Data for developers

  • Decision log: table {$wpdb->prefix}formsieve_log, one table for every form plugin. Numbers and identifiers only (times in UTC); read it, but do not write to it. The host column says which form plugin the row belongs to (gf, cf7); form IDs and entry references are unique only within one host. The source column tells a visitor's submission (submission) from an administrator's Re-check (recheck, Gravity Forms) and a WP-CLI sample (cli); only submission rows count as submissions on the dashboard. category holds the raw ID (for example vendor_solicitation); \Formsieve\WP\Core\Admin\Format::category( $id ) returns the translated label the screens show. On Contact Form 7, when a form uses do_not_store, or the visitor leaves a consent_for:storage box unticked, the row has no IP or e-mail hash.
  • Options: formsieve_settings, formsieve_lists (the allow and block lists, not autoloaded), formsieve_route, formsieve_api_key (obfuscated, never read it directly) and more, all prefixed formsieve_.
  • Gravity Forms entry meta: fsv_score, fsv_verdict, fsv_category, fsv_reason, fsv_model, fsv_model_status, fsv_provider, fsv_latency_ms, fsv_request_id, fsv_request_id_kind. These names are unchanged since 1.0.0, so earlier entries keep their Formsieve details.
  • Contact Form 7: during a submission, Formsieve pushes its result into Contact Form 7's submission as formsieve: WPCF7_Submission::get_instance()->pull( 'formsieve' ) returns the verdict, the outcome, the note and the subject tag (numbers, identifiers and Formsieve's own texts only).
  • Flamingo messages: the Flamingo meta field formsieve holds the human-readable Formsieve note, for blocked, review-band and not-checked messages only (for every message when Notes is on); the post meta _fsv_cf7_verdict (name unchanged since 1.0.0) holds a summary array {log_id, form_id, verdict, percent, checked, demo, outcome} for every checked message.
  • REST (both require manage_options and the wp_rest nonce):
    • POST /wp-json/formsieve-wp/v1/test-connection backs the admin's Test connection button. It sends nothing until consent is given (error_code no_consent, sent: false) or without a key (no_key).
    • POST /wp-json/formsieve-wp/v1/log/<id>/label with label = spam or ham records an administrator's correction for one log row, as the Log tab does.