/wp-content/plugins/double-opt-in/ or use Plugins > Add New > Upload Plugin.your-email).When a visitor submits your form, the plugin stores the submission and sends a confirmation email with a unique link. The original form mail is only delivered after the visitor clicks that link. This verifies that the email address is valid and belongs to the person who filled out the form.
Yes. The plugin tracks all data required for GDPR compliance: consent text, registration and confirmation timestamps, IP addresses, and form data. It integrates with WordPress Privacy Tools for personal data export and erasure requests. You can configure automatic data retention and anonymization policies.
The free Core plugin supports Contact Form 7 out of the box. Support for Avada Forms, Elementor Pro Forms, WPForms, and Gravity Forms is available through separate paid addon plugins (install alongside Core).
If you configured Double Opt-In on an Avada form before Core 5.0, a one-time notice appears in your WordPress admin with a "Claim free Avada grandfather license" button. One click installs the paid Avada addon with a permanent free license bound to your site. Your existing setup continues working with zero configuration changes. The free claim window is open until October 2026.
Yes. The plugin includes a visual drag & drop email editor with block-based design. You can choose from pre-built template presets or create your own. Placeholders like [doubleoptinlink], [doubleoptin_form_date], and form field values are replaced automatically.
Unconfirmed opt-ins are stored in the database and can be cleaned up automatically. You can configure the retention period for unconfirmed entries in the settings (e.g. delete after 30 days). In the Pro version, you can also send automatic reminder emails.
Yes. In the per-form settings, you can select a Confirmation Page. The user will be redirected there after clicking the confirmation link.
Yes. The plugin automatically disables CAPTCHA validation (Google reCAPTCHA, hCaptcha, CF7 Captcha by Forge12) when re-sending the original form mail after confirmation. This prevents false spam detections during the confirmation step. CAPTCHA is re-enabled immediately after the mail has been sent.
Yes. Use the Conditions setting in the per-form configuration. Enter the name of a form field (e.g. a checkbox). Double Opt-In will only be triggered when that field has a value.
Legacy approach (WordPress hook):
add_action( 'f12_cf7_doubleoptin_after_confirm', function( $hash, $optIn ) {
$data = maybe_unserialize( $optIn->get_content() );
}, 10, 2 );
Modern approach (typed event, since 4.0):
Use OptInConfirmedEvent via the EventDispatcher. The event provides getFormData(), getEmail(), getFormId(), and more. See docs/hooks-and-events.md for the complete reference.
Yes. When activated network-wide, the plugin creates database tables on all existing sites. New sites added to the network automatically get their own tables via the wp_initialize_site hook.
The free version requires at least one supported form plugin. However, developers can register custom form integrations using the f12_cf7_doubleoptin_register_integrations action hook. See the developer documentation for details.
The complete hook, filter, and event reference is available at docs/hooks-and-events.md inside the plugin directory. It covers all 18 action hooks, 23 filters, and 11 typed events with code examples.
Please visit forge12.com or contact us via the WordPress support forum.
f12_doi_enforce_consent_gate to switch the rejection off for a single form, and action f12_doi_consent_field_unknown to react to the mismatch yourself.[doubleoptoutlink] placeholder in confirmation and reminder mails pointed at your front page instead of your consent centre. It now resolves to the page you configured.f12_doi_health_checks filter, so this covers future addons without further work.nofollow.f12_doi_confirmation_output for anyone who wants to put their own markup on the confirmation page -- the plugin had no hook there at all.Requires PHP: 7.4 but shipped PHP 8 syntax (a nullsafe operator and a union return type), which would have caused a fatal error on a PHP 7.4 server. Both are gone — the whole plugin now parses and runs on 7.4.Requires at least and Requires PHP entirely, so WordPress could not block activation on an unsupported server. Both are now declared.manage_options capability — not just a nonce — and the privileged nonce is no longer emitted on every wp-admin page.= + - @).REMOTE_ADDR and only trusts X-Forwarded-For from proxies you configure via the new f12_doi_trusted_proxies filter — this prevents spoofing the opt-in rate limiter and the stored GDPR consent IP.<div> instead of a <p>, so alignment survives multi-line rich-text content (a block-level tag inside a <p> is invalid HTML and email clients dropped the alignment).getMissingRequiredFields() on the form-settings model returns the list of unconfigured fields and powers the page-level banner + master-toggle lock in the admin UI.admin_init and disables any that fail the completeness check. Idempotent, runs once per site.save and toggle endpoints reject any payload that would leave a form incomplete-but-enabled, with a structured error code the React UI surfaces inline.f12_doi_optin_pre_delete cascade hook fires before an opt-in is removed, allowing addons to delete their own per-submission artefacts (uploaded files, third-party form-plugin entries).FileStorage service + template-method base for file hand-off — CF7, Avada, Elementor, Gravity Forms, and WPForms now all delete uploaded files when the parent opt-in is deleted or expires.f12_doi_settings_dto_from_array / f12_doi_settings_dto_sanitize filter pair so addons can round-trip arbitrary keys cleanly./addon-settings/<id> is now the canonical mount point for Pro and free addons.enabled=true when a save is rejected by the completeness gate; the gate marker stays sticky until the user fixes the underlying problem.POST /f12-doi/v1/optout/page/generate — idempotently creates the Opt-Out landing page with both shortcodes, returns 409 with an edit link on title collision.GET /f12-doi/v1/consent-export/history?limit=N — recent audit-runs for the consent-export "Recent exports" card.GET /f12-doi/v1/consent-export/stream-info — live hint-counts: how many records this stream has exported already and when the last run was.DELETE /f12-doi/v1/consent-export/history and DELETE /f12-doi/v1/consent-export/history/<id> — bulk and per-run audit deletion.consent_text snapshot was lost when the runtime migrated from the legacy OptInFrontend to AbstractFormIntegration. Opt-in records since then displayed "Not recorded" instead of the actual configured consent text. The new buildOptInProperties() shared base now captures it (and the new consent_field) on every opt-in.FormData proxy now resolves getFormType correctly so consent-field plumbing works on CF7.form_fields are unwrapped in placeholder substitution — [doi_email] and friends now resolve in confirmation mails on Elementor forms regardless of nesting.href="#" in legacy templates no longer breaks the confirmation link in inline-styled mails.f12_cf7_doubleoptin_categories on manual file upload — both custom tables now verify on every update cycle.admin_init.f12_doi_settings_dto_from_array / f12_doi_settings_dto_sanitize filter pair — addons can round-trip arbitrary keys through the form settings DTO without monkey-patching the model.double-opt-in-avada). Existing free-plugin users with DOI configured on Avada forms get a free permanent grandfather license via a one-click claim button in admin.F12_DOI_CORE_API_VERSION = 4.3.0). See docs/addon-api.md.AddonLicenseRegistryInterface) — license providers (Pro bundle, standalone keys) grant entitlements; addons check isLicensed().MigrationRegistry) — addons register schema migrations; Core applies pending ones on admin_init.forms.pro-features mount point. With no Pro addon active, the tab disappears entirely instead of rendering an empty panel.dashboard.widget mount point. With the Analytics addon active, Top Forms / Activity / Conversion Rate cards appear; without it, the base dashboard shows totals + recent opt-ins only.fieldMapping save bug — placeholder mappings configured under Form Settings → Mapping were captured by the React form but silently dropped before persistence. Now correctly round-trips through the new symmetric f12_doi_settings_dto_from_array / f12_doi_settings_dto_sanitize filter pair.consent_text snapshot was lost when the runtime migrated from the legacy OptInFrontend to AbstractFormIntegration — opt-in records since then displayed "Not recorded" instead of the actual configured consent text. The new buildOptInProperties() shared base captures it (and the new consent_field) on every opt-in.important: '#doi-admin-root' config + Radix Portal container so popovers stay styled).@api; implementation details tagged @internal. Deprecation policy: 1 minor release of warning before removal.[doi_email], [doi_name], [doi_phone], etc.) not being replaced in confirmation emails for Avada forms. The Avada integration stores opt-in content in a nested structure ({data: {...}, field_labels: {...}}), but the placeholder replacement expected a flat field array. The nested data key is now extracted correctly before replacement.OptInFrontend::addPlaceholders() code path.[doubleoptin_privacy_url] system placeholder to the new AbstractFormIntegration::addSystemPlaceholders() method. This placeholder was available in the legacy code but was not ported to the 4.0.0 integration architecture, causing it to appear unreplaced in emails.data, field_labels, field_types, etc.) instead of actual form field values. The nested content structure is now unwrapped before rendering.getForms() method used the runtime isOptInEnabled() check (which evaluates $_GET['optin'] and $_POST condition fields) instead of reading the stored database value. This caused the toggle to display as "off" even when DOI was enabled, and clicking "enable" would actually disable it.isOptInEnabled() looked for form field values in $_POST[$condition], but Avada sends form data inside $_POST['formData'] as a URL-encoded string. The AvadaIntegration now overrides isOptInEnabled() to parse Avada's POST format correctly.AvadaIntegration::resolveRecipient() did not strip square brackets from the recipient field name (e.g. [email] → email), so the field was never matched in the form data. Now uses the same bracket-stripping logic as CF7Integration..doi-table not using full width on admin pages due to conflicting CSS rules. Table width now uses !important to ensure consistent layout.ConsentExportController and ConsentExportService have been removed from the free plugin and moved to the Pro plugin.doi_export_consent AJAX endpoint is no longer registered in the free plugin, preventing unauthorized access without a Pro license.f12_doi_database_page_after_forms action hook on the Database admin page, allowing extensions to render additional UI after the built-in database management forms.[quiz]), Acceptance checkboxes ([acceptance]), or other validated field types. CF7 re-ran all form validations when creating a WPCF7_Submission instance during confirmation, which failed because quiz answers and checkbox states are not available in a GET request context. Validation is now bypassed during confirmation mail delivery.CF7Frontend::sendDefaultMail() code path, which had the same issue.beforeSendConfirmationMail() now disables CF7 field validation (wpcf7_validate), spam detection (wpcf7_spam), and spam check (wpcf7_skip_spam_check) in addition to the existing CAPTCHA bypasses. All filters are properly restored in afterSendConfirmationMail()._load_textdomain_just_in_time notice).f12_cf7_doubleoptin_categories when plugin files are uploaded manually or the database is restored without custom tables.UNIQUE_EMAIL_DUPLICATE error code for distinguishing duplicate email rejections from other validation errors (e.g. MX check).OptInFrontend::afterSendDefaultMail() that prevented Google reCAPTCHA from being re-enabled after opt-in confirmation mail delivery (wpcf7_recaptcha_verifiy_response → wpcf7_recaptcha_verify_response).unique_email_rejected in the toast notification. It now displays a properly translated message.wpcf7_skip_mail.f12_cf7_doubleoptin_validate_recipient filter was not called in the legacy OptInFrontend::maybeCreateOptIn() path used by Elementor, so duplicate emails were never detected.doi_check_submission_error request was intercepted by its own XHR hook, causing a continuous polling cycle every ~800ms.hide_confirmation flag based on the validation error behavior (block/redirect vs. silent). The frontend uses this to hide form-plugin success messages when an error should be visible.OptInFrontend::maybeCreateOptIn() now calls the f12_cf7_doubleoptin_validate_recipient filter, enabling MX validation, domain blocklist, and unique email checks for all legacy form integrations (Elementor).?doi_error=rate_limit_ip) for context-specific content.<label class="toggle-label"> elements that were rendered as duplicate toggle buttons due to WordPress admin CSS.<label class="overlay"> elements (leftover from an older CSS-only toggle pattern).esc_attr_e() with echo esc_attr() for HTML for attribute values in the telemetry toggle..f12-checkbox-toggle for proper flex layout of toggle components.f12_cf7_doubleoptin_after_confirm hook. The hook was triggered twice per confirmation (once by the EventDispatcher bridge and once manually). It now fires exactly once with the original ($hash, $optIn) parameters.getFormData() method to OptInConfirmedEvent, providing direct access to submitted form field data via the typed event system.shouldBridgeToWordPress() to the Event base class, allowing individual events to opt out of automatic WordPress hook bridging to prevent duplicate hook calls.docs/hooks-and-events.md) with complete reference for all 18 action hooks, 23 filters, and 11 typed events.wp_initialize_site hook).WordPressHookBridge for backward compatibility between legacy hooks and typed events./wp-json/f12-doi/v1/email-templates).f12_cf7_doubleoptin_skip_option to allow skipping opt-ins if required.