Integrations
An integration lets a form hand something off to an external service. Ultivo Toolkit ships with three spam providers: Google reCAPTCHA v2, Google reCAPTCHA v3 and Cloudflare Turnstile.
Setting one up#
- Go to Forms → Integrations and pick a provider. The cards show what each one asks of your visitors; the one you select opens its key fields underneath, with a Keys saved or Keys missing status.
- That's it. Every form is protected by default.
You run one provider for the whole site: you pick reCAPTCHA or Turnstile, not both. Keys you already entered for the other providers stay stored, so switching back costs nothing.
A form can opt out: under Settings in the form builder there's a Spam Protection checkbox, on by default. Turn it off for a form that doesn't need it, an internal form behind a login, say. The widget renders just above the submit button, and nothing is loaded on the front end for a form with the checkbox off.
Choose None on the Integrations page to switch spam protection off site-wide. With no provider set up, nothing renders and nothing is verified: a half-configured captcha never blocks a form.
Which one to pick#
reCAPTCHA v2 shows the familiar "I'm not a robot" checkbox. Visible, well understood, one extra click for the visitor.
reCAPTCHA v3 is invisible and scores each submission between 0.0 (almost certainly a bot) and 1.0 (almost certainly human). Submissions below the threshold are refused. The threshold is site-wide and defaults to 0.5. A quick way to test the refusal path: set it to 1.0 temporarily, submit as a human, and watch it get rejected.
Cloudflare Turnstile is a privacy-friendly alternative. How visible it is (managed, non-interactive or invisible) is set in your Cloudflare dashboard, not here.
Testing locally#
Both providers tie a key to a domain, so a key from your live site will not work on localhost or a .local development domain: the widget refuses to render and tells you the domain is invalid. Either add the development hostname to the key's domain list, or use the test key pairs both providers publish: they work on any domain, and each has an "always passes" and an "always fails" variant, so you can exercise both paths.
When the provider is unreachable#
If Google or Cloudflare cannot be reached at all, the submission is allowed through. An outage at a third party should not lock every visitor out of your contact form. A submission the provider actively rejects is blocked, with a generic message: the provider's own error code is never shown to the visitor.
Webhooks (Pro)#
A webhook sends every submission to a URL of your choice as JSON. It works with Zapier, Make, n8n, or any endpoint you write yourself.
Unlike the spam providers above, a webhook is configured per form, not site-wide: open the form, go to Settings, and scroll to the Webhook block. Switch it on, enter the Webhook URL, and optionally a Signing Secret to verify that requests really came from your site.
The webhook fires after the entry is stored and after notifications have gone out, so a slow or failing endpoint never holds up a submission and never blocks the visitor's confirmation. The outcome of the last attempt is visible on the entry itself, as a Webhook column in the entries list, and as a line on the dashboard when something failed. Use Send a test on the Settings tab to fire a single request at the configured URL without waiting for a real submission.
The payload#
{ "form": { "id": 42, "key": "contact", "title": "Contact" }, "entry": { "id": 1337, "date": "2026-09-12T09:14:05+00:00", "admin_url": "https://..." }, "fields": { "email": { "label": "Email", "value": "roy@example.com" } }, "site": { "url": "https://example.com", "name": "Example" } }
With Save Entries turned off there is no entry to point to: entry.id is 0 and entry.admin_url is empty.
Verifying the signature#
If a Signing Secret is set, every request carries an X-Ultivo-Signature header:
X-Ultivo-Signature: sha256=<hmac-sha256 of the raw body>
The signature is computed over the raw request body, before it is parsed, so verify it against the raw bytes your endpoint received:
$payload = file_get_contents( 'php://input' ); $header = $_SERVER['HTTP_X_ULTIVO_SIGNATURE'] ?? ''; $secret = 'your-signing-secret'; $expected = 'sha256=' . hash_hmac( 'sha256', $payload, $secret ); if ( ! hash_equals( $expected, $header ) ) { http_response_code( 401 ); exit; } $data = json_decode( $payload, true );
Use hash_equals() rather than === to avoid a timing attack on the comparison.
Three boundaries to know#
- A file goes as its filename, never as its content. The fields object carries the label and the visitor's typed value; an uploaded file's value is the name the visitor chose, not the file itself.
- A failed webhook never holds up a submission. The entry is saved and the visitor sees their confirmation regardless of whether the request succeeds; only the recorded status changes.
- Only the standard ports work. WordPress itself accepts port 80, 443 and 8080 for a request like this, and refuses everything else with
A valid URL was not provided. That catches people out with a self-hosted receiver on an unusual port, an n8n instance on:5678for instance. Put such a receiver behind your normal web server, or allow the port yourself with thehttp_allowed_safe_portsfilter. - Internal addresses are refused. A URL that points at a local or internal address (such as
http://127.0.0.1/...) is never actually called: the request is refused at the moment it would go out, for a real submission and for Send a test alike. Saving such a URL is not blocked, so use Send a test to see what happens to it.
Registering your own integration#
Integrations are a registry, so you can add your own:
add_filter( 'ultivo/integrations', function ( $classes ) { $classes[] = My_Integration::class; return $classes; } ); class My_Integration extends \ULTIVO\Integration { public function __construct() { $this->id = 'my_captcha'; $this->label = 'My captcha'; $this->tagline = 'Short line for the card'; $this->icon = 'dashicons-shield'; $this->group = 'spam'; $this->settings = [ 'api_key' => [ 'label' => 'API key', 'type' => 'password', 'default' => '' ], ]; } public function render_field( array $form, array $conf ): string { return '<div class="my-captcha" data-key="' . esc_attr( $this->get( 'api_key' ) ) . '"></div>'; } public function verify( array $form, array $posted, array $conf ): ?string { return $this->looks_human() ? null : __( 'Please try again.', 'my-plugin' ); } }
Your integration appears as a card on the Forms → Integrations page, alongside the three built-in providers. Field types for $settings: text, password, number.
Two groups, two shapes#
$group is spam or action. A spam integration is site-wide: Integrations::for_form() returns the one provider the site has selected, and a form can only opt in or out. An action integration, webhook included, is configured per form instead, under the form's own Settings tab.
The four seams#
Implement only what you need: none of these are required:
| Method | When it runs |
|---|---|
enqueue_frontend( $form, $conf ) |
Assets, loaded only for forms that use this integration |
render_field( $form, $conf ) |
Markup placed just above the submit button |
verify( $form, $posted, $conf ) |
Before anything is saved. Return a string to refuse the submission, null to let it through |
dispatch( $form, $values, $entry_id, $conf ) |
After the entry is stored and notifications are sent. This is where an action integration does its work, and the webhook is the first one that uses it |
A spam provider fills in the first three and leaves dispatch() empty; an action integration does the opposite.
$conf is passed to all four. For a spam provider it is always empty, because the choice of provider is site-wide and a form only decides whether it takes part. For an action integration it carries that form's own settings, read from the form under actions.<id>.
That is where the two groups differ. A spam provider is configured once, under Forms → Integrations, and a form only opts in or out. An action integration has a single switch, the per-form one under Settings on the form itself. dispatch() runs once that switch is on and the integration reports itself as configured (is_configured()): an integration with no $settings at all, like the webhook, is always configured, while one with its own keys (a Mailchimp connection, say) only runs once those keys are filled in. Only an integration with $settings gets its own card on the Integrations page; the webhook has none, so it never appears there.
To give your own action integration per-form settings, add rows with the ultivo/form_settings_rows filter and save them with ultivo/form_settings_save, storing them under actions.<id> in the form. Anything you store there reaches dispatch() as $conf.
Keeping keys out of the database#
Filter a setting to read it from wp-config.php instead:
add_filter( 'ultivo/integrations/setting', function ( $value, $id, $key ) { if ( 'my_integration' === $id && 'api_key' === $key && defined( 'MY_API_KEY' ) ) { return MY_API_KEY; } return $value; }, 10, 3 );