| 1 |
# Developer Documentation: Hooks, Filters & Events |
| 2 |
|
| 3 |
> **Plugin:** Double Opt-In for Contact Form 7 & Avada |
| 4 |
> **Since:** 4.0.0 (Event System), 3.2.2 (getFormData on confirm) |
| 5 |
> **Last updated:** 2026-02-10 |
| 6 |
|
| 7 |
This document is the complete reference for integrating with the Double Opt-In plugin. |
| 8 |
There are two ways to hook into the plugin lifecycle: |
| 9 |
|
| 10 |
1. **WordPress Hooks** (`add_action` / `add_filter`) -- backward-compatible, works like any WP hook. |
| 11 |
2. **Typed Events** (via `EventDispatcherInterface`) -- introduced in 4.0, strongly typed, auto-completed by your IDE. |
| 12 |
|
| 13 |
Both approaches work side by side. For new code we recommend the typed event system. |
| 14 |
|
| 15 |
--- |
| 16 |
|
| 17 |
## Table of Contents |
| 18 |
|
| 19 |
- [](#quick-start-examplesQuick Start Examples](#quick-start-examples](#quick-start-examples) |
| 20 |
- [](#lifecycle-hooksLifecycle Hooks (do_action)](#lifecycle-hooks](#lifecycle-hooks) |
| 21 |
- [](#mail-hooksMail Hooks (do_action)](#mail-hooks](#mail-hooks) |
| 22 |
- [](#follow-up-hooksFollow-up Hooks](#follow-up-hooks](#follow-up-hooks) |
| 23 |
- [](#integration-hooksIntegration Hooks (do_action)](#integration-hooks](#integration-hooks) |
| 24 |
- [](#filtersFilters (apply_filters)](#filters](#filters) |
| 25 |
- [](#typed-eventsTyped Events](#typed-events](#typed-events) |
| 26 |
- [](#lifecycle-eventsLifecycle Events](#lifecycle-events](#lifecycle-events) |
| 27 |
- [](#form-eventsForm Events](#form-events](#form-events) |
| 28 |
- [](#mail-eventsMail Events](#mail-events](#mail-events) |
| 29 |
- [](#integration-eventsIntegration Events](#integration-events](#integration-events) |
| 30 |
- [](#migration-guideMigration Guide: Legacy to Events](#migration-guide](#migration-guide) |
| 31 |
|
| 32 |
--- |
| 33 |
|
| 34 |
## Quick Start Examples |
| 35 |
|
| 36 |
### After Opt-In Confirmation (Legacy Hook) |
| 37 |
|
| 38 |
```php |
| 39 |
add_action( 'f12_cf7_doubleoptin_after_confirm', function ( string $hash, $optIn ) { |
| 40 |
$formData = maybe_unserialize( $optIn->get_content() ); |
| 41 |
$email = $optIn->get_email(); |
| 42 |
$formId = $optIn->get_cf_form_id(); |
| 43 |
|
| 44 |
// Example: subscribe to newsletter |
| 45 |
my_newsletter_subscribe( $email, $formData['your-name'] ?? '' ); |
| 46 |
}, 10, 2 ); |
| 47 |
``` |
| 48 |
|
| 49 |
### After Opt-In Confirmation (Typed Event) |
| 50 |
|
| 51 |
```php |
| 52 |
use Forge12\DoubleOptIn\Events\Lifecycle\OptInConfirmedEvent; |
| 53 |
use Forge12\DoubleOptIn\Container\Container; |
| 54 |
use Forge12\DoubleOptIn\EventSystem\EventDispatcherInterface; |
| 55 |
|
| 56 |
add_action( 'plugins_loaded', function () { |
| 57 |
$container = Container::getInstance(); |
| 58 |
$dispatcher = $container->get( EventDispatcherInterface::class ); |
| 59 |
|
| 60 |
$dispatcher->addListener( |
| 61 |
OptInConfirmedEvent::class, |
| 62 |
function ( OptInConfirmedEvent $event ) { |
| 63 |
$formData = $event->getFormData(); |
| 64 |
$email = $event->getEmail(); |
| 65 |
$formId = $event->getFormId(); |
| 66 |
|
| 67 |
my_newsletter_subscribe( $email, $formData['your-name'] ?? '' ); |
| 68 |
} |
| 69 |
); |
| 70 |
} ); |
| 71 |
``` |
| 72 |
|
| 73 |
--- |
| 74 |
|
| 75 |
## Lifecycle Hooks |
| 76 |
|
| 77 |
### `f12_cf7_doubleoptin_before_confirm` |
| 78 |
|
| 79 |
Fires **before** the opt-in record is marked as confirmed in the database. |
| 80 |
|
| 81 |
| Parameter | Type | Description | |
| 82 |
|-----------|------|-------------| |
| 83 |
| `$hash` | `string` | The opt-in hash from the confirmation link | |
| 84 |
| `$optIn` | `OptIn` | The opt-in record (not yet confirmed) | |
| 85 |
|
| 86 |
```php |
| 87 |
add_action( 'f12_cf7_doubleoptin_before_confirm', function ( $hash, $optIn ) { |
| 88 |
// Example: log the confirmation attempt |
| 89 |
error_log( "Confirm attempt for: " . $optIn->get_email() ); |
| 90 |
}, 10, 2 ); |
| 91 |
``` |
| 92 |
|
| 93 |
### `f12_cf7_doubleoptin_after_confirm` |
| 94 |
|
| 95 |
Fires **after** the opt-in record has been confirmed and saved in the database. |
| 96 |
|
| 97 |
| Parameter | Type | Description | |
| 98 |
|-----------|------|-------------| |
| 99 |
| `$hash` | `string` | The opt-in hash | |
| 100 |
| `$optIn` | `OptIn` | The confirmed opt-in record | |
| 101 |
|
| 102 |
```php |
| 103 |
add_action( 'f12_cf7_doubleoptin_after_confirm', function ( $hash, $optIn ) { |
| 104 |
$formData = maybe_unserialize( $optIn->get_content() ); |
| 105 |
|
| 106 |
// Access individual form fields |
| 107 |
$name = $formData['your-name'] ?? ''; |
| 108 |
$email = $formData['your-email'] ?? ''; |
| 109 |
$phone = $formData['your-phone'] ?? ''; |
| 110 |
|
| 111 |
// Example: create a WooCommerce customer, sync to CRM, etc. |
| 112 |
}, 10, 2 ); |
| 113 |
``` |
| 114 |
|
| 115 |
### `f12_cf7_doubleoptin_already_confirmed` |
| 116 |
|
| 117 |
Fires when a user clicks a confirmation link that has already been used. |
| 118 |
|
| 119 |
| Parameter | Type | Description | |
| 120 |
|-----------|------|-------------| |
| 121 |
| `$hash` | `string` | The opt-in hash | |
| 122 |
| `$optIn` | `OptIn` | The already-confirmed opt-in record | |
| 123 |
|
| 124 |
### `f12_cf7_doubleoptin_token_expired` |
| 125 |
|
| 126 |
Fires when a confirmation link has expired (based on `token_expiry_hours` setting). |
| 127 |
|
| 128 |
| Parameter | Type | Description | |
| 129 |
|-----------|------|-------------| |
| 130 |
| `$hash` | `string` | The opt-in hash | |
| 131 |
| `$optIn` | `OptIn` | The expired opt-in record | |
| 132 |
|
| 133 |
### `f12_cf7_doubleoptin_sent` |
| 134 |
|
| 135 |
Fires when a new opt-in record is created and the confirmation email is sent. |
| 136 |
|
| 137 |
| Parameter | Type | Description | |
| 138 |
|-----------|------|-------------| |
| 139 |
| `$form` | `mixed` | The form object (CF7 or Avada) | |
| 140 |
| `$formId` | `int` | The form ID | |
| 141 |
|
| 142 |
### `f12_cf7_doubleoptin_creation_failed` |
| 143 |
|
| 144 |
Fires when an opt-in record could not be saved to the database. |
| 145 |
|
| 146 |
| Parameter | Type | Description | |
| 147 |
|-----------|------|-------------| |
| 148 |
| `$formId` | `int` | The form ID | |
| 149 |
| `$recipient` | `string` | The recipient email | |
| 150 |
|
| 151 |
### `f12_cf7_doubleoptin_rate_limited` |
| 152 |
|
| 153 |
Fires when a submission is blocked by rate limiting. |
| 154 |
|
| 155 |
| Parameter | Type | Description | |
| 156 |
|-----------|------|-------------| |
| 157 |
| `$type` | `string` | `'ip'` or `'email'` | |
| 158 |
| `$identifier` | `string` | The IP address or email that was rate-limited | |
| 159 |
| `$formId` | `int` | The form ID | |
| 160 |
|
| 161 |
### `f12_cf7_doubleoptin_recipient_invalid` |
| 162 |
|
| 163 |
Fires when recipient email validation fails (e.g. MX check in Pro). |
| 164 |
|
| 165 |
| Parameter | Type | Description | |
| 166 |
|-----------|------|-------------| |
| 167 |
| `$recipient` | `string` | The rejected email | |
| 168 |
| `$formId` | `int` | The form ID | |
| 169 |
| `$errorMsg` | `string` | The validation error message | |
| 170 |
|
| 171 |
### `f12_cf7_doubleoptin_consent_not_given` |
| 172 |
|
| 173 |
Fires when a submission is rejected because the form's configured |
| 174 |
acceptance field was not confirmed. Since 5.4.0 this fires for every |
| 175 |
integration; before that only for those extending `AbstractFormIntegration`. |
| 176 |
|
| 177 |
| Parameter | Type | Description | |
| 178 |
|-----------|------|-------------| |
| 179 |
| `$formId` | `int` | The form ID | |
| 180 |
| `$consentField` | `string` | The configured acceptance field | |
| 181 |
|
| 182 |
### `f12_doi_consent_field_unknown` |
| 183 |
|
| 184 |
**Since 5.4.0.** Fires when the configured acceptance field cannot be |
| 185 |
found on the form at all — renamed or deleted in the form builder. The |
| 186 |
submission is **accepted**; this is the deliberate safety valve, so that a |
| 187 |
settings mistake cannot take a site's registrations offline. The same |
| 188 |
condition is reported under Tools → Site Health. |
| 189 |
|
| 190 |
| Parameter | Type | Description | |
| 191 |
|-----------|------|-------------| |
| 192 |
| `$formId` | `int` | The form ID | |
| 193 |
| `$consentField` | `string` | The configured field that could not be found | |
| 194 |
| `$integration` | `string` | The integration identifier (`cf7`, `elementor`, …) | |
| 195 |
|
| 196 |
--- |
| 197 |
|
| 198 |
## Mail Hooks |
| 199 |
|
| 200 |
### `f12_cf7_doubleoptin_before_send_default_mail` |
| 201 |
|
| 202 |
Fires before the original form mail is sent after opt-in confirmation. Spam protection (reCAPTCHA, CF7 Captcha) is temporarily disabled at this point. |
| 203 |
|
| 204 |
| Parameter | Type | Description | |
| 205 |
|-----------|------|-------------| |
| 206 |
| `$optIn` | `OptIn` | The confirmed opt-in record | |
| 207 |
|
| 208 |
### `f12_cf7_doubleoptin_trigger_default_mail` |
| 209 |
|
| 210 |
Legacy trigger for the mail sending. Since 5.6.0 the core no longer fires it for opt-ins whose integration has a follow-up adapter (CF7, Elementor, Avada, WPForms, Gravity Forms) — their follow-up actions run through the follow-up coordinator instead. Firing it yourself is still safe: every listener checks the opt-in's integration, and managed opt-ins go through the coordinator, so actions that already ran are not repeated. |
| 211 |
|
| 212 |
| Parameter | Type | Description | |
| 213 |
|-----------|------|-------------| |
| 214 |
| `$optIn` | `OptIn` | The confirmed opt-in record | |
| 215 |
|
| 216 |
### `f12_cf7_doubleoptin_after_send_default_mail` |
| 217 |
|
| 218 |
Fires after the original form mail has been sent. Spam protection is re-enabled at this point. It fires after the follow-up attempt whatever its outcome — do not treat it as proof that the mail went out; read the follow-up status instead (see below). |
| 219 |
|
| 220 |
| Parameter | Type | Description | |
| 221 |
|-----------|------|-------------| |
| 222 |
| `$optIn` | `OptIn` | The confirmed opt-in record | |
| 223 |
|
| 224 |
--- |
| 225 |
|
| 226 |
## Follow-up Hooks |
| 227 |
|
| 228 |
A confirmed opt-in is not proof that the form's own actions (stored entry, notification mails, webhooks) ran. Since 5.6.0 each of them is a *follow-up action* with its own recorded status (`pending`, `running`, `succeeded`, `failed_retryable`, `failed_permanent`, `unknown`, `skipped`), shown in the opt-in detail view and written to the audit log (type `follow_up`). Only actions that demonstrably did not run are retried automatically; `unknown` is never retried without an administrator's explicit decision. |
| 229 |
|
| 230 |
### `f12_doi_register_follow_up_adapters` |
| 231 |
|
| 232 |
Register a follow-up adapter for a form integration that is not part of the Double Opt-In family. Fires once, on first use. |
| 233 |
|
| 234 |
| Parameter | Type | Description | |
| 235 |
|-----------|------|-------------| |
| 236 |
| `$registry` | `FollowUpAdapterRegistry` | Call `register( FollowUpAdapterInterface $adapter )` | |
| 237 |
|
| 238 |
```php |
| 239 |
add_action( 'f12_doi_register_follow_up_adapters', function ( $registry ) { |
| 240 |
$registry->register( new My_Form_FollowUp_Adapter() ); |
| 241 |
} ); |
| 242 |
``` |
| 243 |
|
| 244 |
The adapter plans one action per side effect (`planActions()`), executes the claimed ones and returns a `FollowUpResult` per action (`execute()`), and releases resources once everything is done (`onSettled()`). Return `FollowUpResult::unknown()` whenever you cannot tell whether a side effect happened. |
| 245 |
|
| 246 |
### `f12_doi_follow_up_backoff` (filter) |
| 247 |
|
| 248 |
Delays in seconds between automatic retries of actions that demonstrably did not run (e.g. the internal request never reached the server). The number of entries is the maximum number of automatic retries. Default `[60, 300, 1800]`; `[]` disables automatic retries. A manual retry from the admin starts a fresh budget: the schedule applies again from its first entry. |
| 249 |
|
| 250 |
```php |
| 251 |
add_filter( 'f12_doi_follow_up_backoff', fn() => array( 120, 600 ) ); |
| 252 |
``` |
| 253 |
|
| 254 |
Addon-specific (Elementor Forms addon): `f12_doi_elementor_replay_skip_validators` (validator classes) and `f12_doi_elementor_replay_skip_field_validators` (field types) name spam validators that are skipped for the ticket-authorised post-confirmation replay only — for a third-party CAPTCHA whose token cannot be verified twice. |
| 255 |
|
| 256 |
--- |
| 257 |
|
| 258 |
## Integration Hooks |
| 259 |
|
| 260 |
### `f12_cf7_doubleoptin_register_integrations` |
| 261 |
|
| 262 |
Fires during plugin initialization. Use this to register your own form integration. |
| 263 |
|
| 264 |
| Parameter | Type | Description | |
| 265 |
|-----------|------|-------------| |
| 266 |
| `$registry` | `FormIntegrationRegistry` | The integration registry | |
| 267 |
| `$container` | `Container` | The service container | |
| 268 |
|
| 269 |
```php |
| 270 |
add_action( 'f12_cf7_doubleoptin_register_integrations', function ( $registry, $container ) { |
| 271 |
$registry->register( new MyCustomFormIntegration( $container->get( LoggerInterface::class ) ) ); |
| 272 |
}, 10, 2 ); |
| 273 |
``` |
| 274 |
|
| 275 |
### `f12_cf7_doubleoptin_integration_registered` |
| 276 |
|
| 277 |
Fires after a form integration has been registered. |
| 278 |
|
| 279 |
| Parameter | Type | Description | |
| 280 |
|-----------|------|-------------| |
| 281 |
| `$integration` | `FormIntegrationInterface` | The registered integration | |
| 282 |
| `$identifier` | `string` | The integration identifier (e.g. `cf7`, `avada`) | |
| 283 |
|
| 284 |
### `f12_cf7_doubleoptin_integrations_initialized` |
| 285 |
|
| 286 |
Fires after all form integrations have been initialized. |
| 287 |
|
| 288 |
| Parameter | Type | Description | |
| 289 |
|-----------|------|-------------| |
| 290 |
| `$registry` | `FormIntegrationRegistry` | The registry with all integrations | |
| 291 |
|
| 292 |
### `f12_cf7_doubleoptin_register_event_listeners` |
| 293 |
|
| 294 |
Fires during event system setup. Register your typed event listeners here. |
| 295 |
|
| 296 |
| Parameter | Type | Description | |
| 297 |
|-----------|------|-------------| |
| 298 |
| `$dispatcher` | `EventDispatcherInterface` | The event dispatcher | |
| 299 |
| `$hookBridge` | `WordPressHookBridge` | The WordPress hook bridge | |
| 300 |
|
| 301 |
```php |
| 302 |
add_action( 'f12_cf7_doubleoptin_register_event_listeners', function ( $dispatcher, $hookBridge ) { |
| 303 |
$dispatcher->addListener( |
| 304 |
\Forge12\DoubleOptIn\Events\Lifecycle\OptInConfirmedEvent::class, |
| 305 |
function ( $event ) { |
| 306 |
// your logic |
| 307 |
} |
| 308 |
); |
| 309 |
}, 10, 2 ); |
| 310 |
``` |
| 311 |
|
| 312 |
--- |
| 313 |
|
| 314 |
## Filters |
| 315 |
|
| 316 |
### Form & Submission Filters |
| 317 |
|
| 318 |
| Filter | Parameters | Return | Description | |
| 319 |
|--------|-----------|--------|-------------| |
| 320 |
| `f12_cf7_doubleoptin_add_request_parameter` | `$fields` (array) | `array` | Modify submitted form fields before saving to database | |
| 321 |
| `f12_cf7_doubleoptin_skip_option` | `$skip` (bool), `$formId`, `$fields`, `$type` | `bool` | Return `true` to skip opt-in creation for this submission | |
| 322 |
| `f12_cf7_doubleoptin_show_validation_error` | `$show` (bool), `$error` (OptInError, since 5.6.2), `$formId` (int, since 5.6.2) | `bool` | Whether the form shows the reason for a refused submission instead of its own success message (default: `false`). A refused consent (`consent_not_given`) is always shown and never reaches this filter (since 5.6.2) | |
| 323 |
| `f12_cf7_doubleoptin_enable_error_notification` | `$enable` (bool) | `bool` | Return `false` to not load the frontend error toast at all (default: `true`, since 4.2.0) | |
| 324 |
| `f12_cf7_doubleoptin_error_message` | `$message` (string), `$error` (OptInError), `$formId` (int) | `string` | Customize the error message per error code (since 4.2.0) | |
| 325 |
| `f12_cf7_doubleoptin_validate_recipient` | `$valid` (bool), `$recipient`, `$formData` | `bool\|string` | Validate recipient email; return error string to reject | |
| 326 |
| `f12_cf7_doubleoptin_send_default_mail` | `$send` (bool), `$formId` | `bool` | Whether to send the original form mail after confirmation | |
| 327 |
| `f12_doi_enforce_consent_gate` | `$enforce` (bool), `$formId` (int), `$integration` (string) | `bool` | Return `false` to accept a submission whose configured acceptance field was not confirmed. The opt-in is then stored with a consent text nobody agreed to, so this is an escape hatch for an unforeseen edge case, not a setting (since 5.4.0) | |
| 328 |
|
| 329 |
### Mail Filters |
| 330 |
|
| 331 |
| Filter | Parameters | Return | Description | |
| 332 |
|--------|-----------|--------|-------------| |
| 333 |
| `f12_cf7_doubleoptin_body` | `$body` (string) | `string` | Modify the opt-in confirmation email body | |
| 334 |
| `f12-cf7-doubleoptin-cf7-args` | `$args` (array) | `array` | Modify mail arguments (subject, body, headers, attachments) | |
| 335 |
| `f12_cf7_doubleoptin_files_mail_1` | `$include` (bool), `$optIn` | `bool` | Include file attachments in the first confirmation mail | |
| 336 |
| `f12_cf7_doubleoptin_files_mail_2` | `$include` (bool), `$optIn` | `bool` | Include file attachments in the second confirmation mail | |
| 337 |
| `f12_cf7_doubleoptin_allowed_mime_types` | `$mimeTypes` (array) | `array` | Modify allowed MIME types for file uploads | |
| 338 |
|
| 339 |
### Settings Filters |
| 340 |
|
| 341 |
| Filter | Parameters | Return | Description | |
| 342 |
|--------|-----------|--------|-------------| |
| 343 |
| `f12_cf7_doubleoptin_save_form` | `$data` (array) | `array` | Modify form settings before saving | |
| 344 |
| `f12_cf7_doubleoptin_metadata_cf7` | `$metadata` (array) | `array` | Modify CF7 form metadata | |
| 345 |
| `f12_cf7_doubleoptin_metadata_avada` | `$metadata` (array) | `array` | Modify Avada form metadata | |
| 346 |
| `f12_doi_form_settings_data` | `$formData`, `$formId` | `array` | Modify form settings data before sending to frontend | |
| 347 |
| `f12_doi_form_settings_before_save` | `$settings`, `$storageId`, `$settingsData` | `FormSettingsDTO` | Modify FormSettingsDTO before saving | |
| 348 |
| `f12_doi_settings_dto_from_array` | `$dto`, `$data` | `FormSettingsDTO` | Modify DTO when creating from array | |
| 349 |
| `f12_doi_settings_dto_to_array` | `$array`, `$dto` | `array` | Modify array representation of DTO | |
| 350 |
| `f12_doi_is_pro_active` | `$isActive` (bool) | `bool` | Whether the Pro version is active | |
| 351 |
| `f12_cf7_doubleoptin_use_new_integration_system` | `$use` (bool) | `bool` | Enable/disable the new integration system | |
| 352 |
|
| 353 |
### Filter Examples |
| 354 |
|
| 355 |
```php |
| 356 |
// Skip opt-in for specific forms |
| 357 |
add_filter( 'f12_cf7_doubleoptin_skip_option', function ( $skip, $formId, $fields, $type ) { |
| 358 |
if ( $formId === 42 ) { |
| 359 |
return true; // skip opt-in for form #42 |
| 360 |
} |
| 361 |
return $skip; |
| 362 |
}, 10, 4 ); |
| 363 |
|
| 364 |
// Add custom fields to the stored data |
| 365 |
add_filter( 'f12_cf7_doubleoptin_add_request_parameter', function ( $fields ) { |
| 366 |
$fields['custom-tracking-id'] = uniqid( 'track_' ); |
| 367 |
return $fields; |
| 368 |
} ); |
| 369 |
|
| 370 |
// Custom recipient validation |
| 371 |
add_filter( 'f12_cf7_doubleoptin_validate_recipient', function ( $valid, $recipient, $formData ) { |
| 372 |
if ( str_ends_with( $recipient, '@blocked-domain.com' ) ) { |
| 373 |
return 'This email domain is not accepted.'; |
| 374 |
} |
| 375 |
return $valid; |
| 376 |
}, 10, 3 ); |
| 377 |
|
| 378 |
// Enable error display for users (default: false) |
| 379 |
add_filter( 'f12_cf7_doubleoptin_show_validation_error', '__return_true' ); |
| 380 |
|
| 381 |
// Customize error messages per error code |
| 382 |
add_filter( 'f12_cf7_doubleoptin_error_message', function ( $message, $error, $formId ) { |
| 383 |
if ( $error->getCode() === 'rate_limit_ip' ) { |
| 384 |
return 'Please wait a few minutes before trying again.'; |
| 385 |
} |
| 386 |
return $message; |
| 387 |
}, 10, 3 ); |
| 388 |
``` |
| 389 |
|
| 390 |
--- |
| 391 |
|
| 392 |
## Universal Error Notification System |
| 393 |
|
| 394 |
> **Since:** 4.2.0 |
| 395 |
|
| 396 |
The plugin provides a form-plugin-agnostic error notification system that works |
| 397 |
with **all** integrations (CF7, Avada, Gravity Forms, WPForms, Elementor, and |
| 398 |
any future integration) without requiring integration-specific error handling code. |
| 399 |
|
| 400 |
### How it works |
| 401 |
|
| 402 |
1. When `createOptIn()` fails, an `OptInError` is stored in a short-lived transient |
| 403 |
keyed by the client's IP + User-Agent (TTL: 60 seconds). |
| 404 |
2. A small frontend JS (loaded on every frontend page unless |
| 405 |
`f12_cf7_doubleoptin_enable_error_notification` returns `false`) listens for |
| 406 |
form submission events from all supported plugins. |
| 407 |
3. After form submission, the JS calls the AJAX endpoint |
| 408 |
`doi_check_submission_error` to check for a stored error. |
| 409 |
4. If an error exists, a toast notification is displayed. The transient is |
| 410 |
deleted after retrieval (one-time read). When the error is shown to the |
| 411 |
visitor (see below), the form plugin's success message is hidden and the |
| 412 |
toast stays until closed. |
| 413 |
|
| 414 |
### Showing the error in the form |
| 415 |
|
| 416 |
By default a refused submission is reported by the toast only; the form plugin |
| 417 |
still shows its own success message. To show the reason in the form instead — |
| 418 |
CF7 aborts with the message and keeps the input, Elementor answers with an |
| 419 |
error, WPForms and Gravity Forms hide their confirmation — add this to your |
| 420 |
theme's `functions.php`: |
| 421 |
|
| 422 |
```php |
| 423 |
add_filter( 'f12_cf7_doubleoptin_show_validation_error', '__return_true' ); |
| 424 |
|
| 425 |
// Or per error code (arguments since 5.6.2): |
| 426 |
add_filter( 'f12_cf7_doubleoptin_show_validation_error', function ( $show, $error, $formId ) { |
| 427 |
return $error->getCode() === 'unique_email_duplicate' ? true : $show; |
| 428 |
}, 10, 3 ); |
| 429 |
``` |
| 430 |
|
| 431 |
**A refused consent is always shown** (since 5.6.2). It is the one refusal the |
| 432 |
visitor caused and can fix — tick the box — and before 5.6.2 the form said |
| 433 |
"sent" while no mail was ever going to come. The MX Validator, Domain Blocklist |
| 434 |
and Unique Email add-ons switch the filter on for all errors while active. |
| 435 |
|
| 436 |
### Error codes |
| 437 |
|
| 438 |
| Code | Constant | Default message | |
| 439 |
|------|----------|----------------| |
| 440 |
| `submission_cancelled` | `OptInError::SUBMISSION_CANCELLED` | The form submission has been cancelled. | |
| 441 |
| `no_recipient` | `OptInError::NO_RECIPIENT` | No valid email address was found. | |
| 442 |
| `rate_limit_ip` | `OptInError::RATE_LIMIT_IP` | Too many requests. Please try again later. | |
| 443 |
| `rate_limit_email` | `OptInError::RATE_LIMIT_EMAIL` | Too many requests for this email address. Please try again later. | |
| 444 |
| `recipient_invalid` | `OptInError::RECIPIENT_INVALID` | The email address could not be verified. | |
| 445 |
| `save_failed` | `OptInError::SAVE_FAILED` | An error occurred. Please try again. | |
| 446 |
|
| 447 |
### Programmatic access |
| 448 |
|
| 449 |
```php |
| 450 |
use Forge12\DoubleOptIn\Integration\AbstractFormIntegration; |
| 451 |
|
| 452 |
// After a form submission, retrieve the last error (same request only) |
| 453 |
$error = AbstractFormIntegration::getLastError(); |
| 454 |
if ( $error ) { |
| 455 |
$code = $error->getCode(); // e.g. 'rate_limit_ip' |
| 456 |
$message = $error->getMessage(); // translated message |
| 457 |
$context = $error->getContext(); // ['ip' => '...', 'form_id' => 42] |
| 458 |
} |
| 459 |
``` |
| 460 |
|
| 461 |
### CSS customization |
| 462 |
|
| 463 |
The notification uses the class `.doi-error-notification`. Override styles in your |
| 464 |
theme to match your design: |
| 465 |
|
| 466 |
```css |
| 467 |
.doi-error-notification__content { |
| 468 |
border-left-color: #cc0000; /* custom accent color */ |
| 469 |
} |
| 470 |
``` |
| 471 |
|
| 472 |
--- |
| 473 |
|
| 474 |
## Typed Events |
| 475 |
|
| 476 |
All events extend `Forge12\DoubleOptIn\EventSystem\Event` and are dispatched via `EventDispatcherInterface`. |
| 477 |
|
| 478 |
### Lifecycle Events |
| 479 |
|
| 480 |
#### `OptInCreatedEvent` |
| 481 |
|
| 482 |
Dispatched when a new opt-in record is created. |
| 483 |
|
| 484 |
| Method | Return | Description | |
| 485 |
|--------|--------|-------------| |
| 486 |
| `getOptInId()` | `int` | The database record ID | |
| 487 |
| `getFormId()` | `int` | The form ID | |
| 488 |
| `getFormType()` | `string` | `'cf7'`, `'avada'`, etc. | |
| 489 |
| `getEmail()` | `string` | The subscriber email | |
| 490 |
| `getHash()` | `string` | The opt-in hash | |
| 491 |
| `getFormData()` | `array` | Submitted form fields | |
| 492 |
|
| 493 |
**WordPress hook:** `f12_cf7_doubleoptin_created` (auto-bridged) |
| 494 |
|
| 495 |
#### `OptInConfirmedEvent` |
| 496 |
|
| 497 |
Dispatched when an opt-in is confirmed via the confirmation link. |
| 498 |
|
| 499 |
| Method | Return | Description | |
| 500 |
|--------|--------|-------------| |
| 501 |
| `getOptInId()` | `int` | The database record ID | |
| 502 |
| `getHash()` | `string` | The opt-in hash | |
| 503 |
| `getEmail()` | `string` | The subscriber email | |
| 504 |
| `getConfirmedIp()` | `string` | IP address that confirmed | |
| 505 |
| `getFormId()` | `int` | The original form ID | |
| 506 |
| `getFormData()` | `array` | Submitted form fields (since 3.2.2) | |
| 507 |
|
| 508 |
**WordPress hook:** `f12_cf7_doubleoptin_after_confirm` (manually bridged, not auto-bridged, to preserve `($hash, $optIn)` signature) |
| 509 |
|
| 510 |
```php |
| 511 |
$dispatcher->addListener( OptInConfirmedEvent::class, function ( OptInConfirmedEvent $event ) { |
| 512 |
$data = $event->getFormData(); |
| 513 |
// ['your-name' => 'John Doe', 'your-email' => '[email protected]', ...] |
| 514 |
} ); |
| 515 |
``` |
| 516 |
|
| 517 |
#### `OptInDeletedEvent` |
| 518 |
|
| 519 |
Dispatched when an opt-in record is deleted. |
| 520 |
|
| 521 |
| Method | Return | Description | |
| 522 |
|--------|--------|-------------| |
| 523 |
| `getHash()` | `string` | The opt-in hash | |
| 524 |
| `getEmail()` | `string` | The subscriber email | |
| 525 |
| `getDeletedBy()` | `string` | `'admin'`, `'cron'`, or `'user'` | |
| 526 |
| `getRowsDeleted()` | `int` | Number of rows deleted | |
| 527 |
|
| 528 |
**WordPress hook:** `f12_cf7_doubleoptin_deleted` |
| 529 |
|
| 530 |
#### `OptInExpiredEvent` |
| 531 |
|
| 532 |
Dispatched during cleanup when expired records are removed. |
| 533 |
|
| 534 |
| Method | Return | Description | |
| 535 |
|--------|--------|-------------| |
| 536 |
| `getCleanupType()` | `string` | `'confirmed'` or `'unconfirmed'` | |
| 537 |
| `getRowsDeleted()` | `int` | Number of records deleted | |
| 538 |
| `getThreshold()` | `DateTimeImmutable` | The cutoff date | |
| 539 |
|
| 540 |
**WordPress hook:** `f12_cf7_doubleoptin_expired` |
| 541 |
|
| 542 |
--- |
| 543 |
|
| 544 |
### Form Events |
| 545 |
|
| 546 |
#### `FormSubmittedEvent` |
| 547 |
|
| 548 |
Dispatched when a form is submitted (before opt-in is created). |
| 549 |
|
| 550 |
| Method | Return | Description | |
| 551 |
|--------|--------|-------------| |
| 552 |
| `getFormId()` | `int` | The form ID | |
| 553 |
| `getFormType()` | `string` | The form type | |
| 554 |
| `getPostedData()` | `array` | Submitted form data | |
| 555 |
| `getUploadedFiles()` | `array` | Uploaded files | |
| 556 |
| `getFormUrl()` | `string` | Page URL where form was submitted | |
| 557 |
| `shouldCreateOptIn()` | `bool` | Whether opt-in will be created | |
| 558 |
| `skipOptInCreation($reason)` | `void` | Cancel opt-in creation | |
| 559 |
|
| 560 |
**WordPress hook:** `f12_cf7_doubleoptin_form_submitted` |
| 561 |
|
| 562 |
```php |
| 563 |
$dispatcher->addListener( FormSubmittedEvent::class, function ( FormSubmittedEvent $event ) { |
| 564 |
// Skip opt-in for logged-in admins |
| 565 |
if ( current_user_can( 'manage_options' ) ) { |
| 566 |
$event->skipOptInCreation( 'Admin user, no opt-in needed' ); |
| 567 |
} |
| 568 |
} ); |
| 569 |
``` |
| 570 |
|
| 571 |
#### `FormValidatedEvent` |
| 572 |
|
| 573 |
Dispatched after form validation is complete. |
| 574 |
|
| 575 |
| Method | Return | Description | |
| 576 |
|--------|--------|-------------| |
| 577 |
| `getFormId()` | `int` | The form ID | |
| 578 |
| `getFormType()` | `string` | The form type | |
| 579 |
| `isValid()` | `bool` | Whether validation passed | |
| 580 |
| `getRecipientEmail()` | `string` | The extracted email | |
| 581 |
| `getErrors()` | `array` | Validation errors | |
| 582 |
|
| 583 |
**WordPress hook:** `f12_cf7_doubleoptin_form_validated` |
| 584 |
|
| 585 |
--- |
| 586 |
|
| 587 |
### Mail Events |
| 588 |
|
| 589 |
#### `MailPreparingEvent` |
| 590 |
|
| 591 |
Dispatched before the opt-in confirmation email is sent. All properties are **mutable**. |
| 592 |
|
| 593 |
| Method | Return | Description | |
| 594 |
|--------|--------|-------------| |
| 595 |
| `getOptInId()` | `int` | The opt-in record ID | |
| 596 |
| `getRecipient()` / `setRecipient()` | `string` | Recipient email | |
| 597 |
| `getSubject()` / `setSubject()` | `string` | Email subject | |
| 598 |
| `getBody()` / `setBody()` | `string` | Email body (HTML) | |
| 599 |
| `getSender()` / `setSender()` | `string` | Sender email | |
| 600 |
| `getSenderName()` / `setSenderName()` | `string` | Sender display name | |
| 601 |
| `getHeaders()` / `addHeader()` | `array` | Email headers | |
| 602 |
| `getAttachments()` / `addAttachment()` | `array` | File attachments | |
| 603 |
| `shouldSend()` / `cancelSending()` | `bool` | Cancel sending | |
| 604 |
|
| 605 |
**WordPress hook:** `f12_cf7_doubleoptin_mail_preparing` |
| 606 |
|
| 607 |
```php |
| 608 |
$dispatcher->addListener( MailPreparingEvent::class, function ( MailPreparingEvent $event ) { |
| 609 |
$event->setSubject( 'Custom: ' . $event->getSubject() ) |
| 610 |
->addHeader( 'X-Custom-Header: my-value' ); |
| 611 |
} ); |
| 612 |
``` |
| 613 |
|
| 614 |
#### `MailSentEvent` |
| 615 |
|
| 616 |
Dispatched after a mail has been sent (or failed). |
| 617 |
|
| 618 |
| Method | Return | Description | |
| 619 |
|--------|--------|-------------| |
| 620 |
| `getOptInId()` | `int` | The opt-in record ID | |
| 621 |
| `getRecipient()` | `string` | Recipient email | |
| 622 |
| `getSubject()` | `string` | Email subject | |
| 623 |
| `wasSuccessful()` | `bool` | Whether sending succeeded | |
| 624 |
| `getMailType()` | `string` | `'optin'` or `'confirmation'` | |
| 625 |
|
| 626 |
**WordPress hook:** `f12_cf7_doubleoptin_mail_sent` |
| 627 |
|
| 628 |
#### `ReminderSentEvent` |
| 629 |
|
| 630 |
Dispatched after a reminder email is sent (Pro feature). |
| 631 |
|
| 632 |
| Method | Return | Description | |
| 633 |
|--------|--------|-------------| |
| 634 |
| `getOptInId()` | `int` | The opt-in record ID | |
| 635 |
| `getRecipient()` | `string` | Recipient email | |
| 636 |
| `getSubject()` | `string` | Email subject | |
| 637 |
| `wasSuccessful()` | `bool` | Whether sending succeeded | |
| 638 |
| `getTrigger()` | `string` | `'cron'` or `'manual'` | |
| 639 |
|
| 640 |
**WordPress hook:** `f12_cf7_doubleoptin_reminder_sent` |
| 641 |
|
| 642 |
--- |
| 643 |
|
| 644 |
### Integration Events |
| 645 |
|
| 646 |
#### `FormSubmissionEvent` |
| 647 |
|
| 648 |
Dispatched when a form integration processes a submission. Allows modifying form data or cancelling the opt-in. |
| 649 |
|
| 650 |
| Method | Return | Description | |
| 651 |
|--------|--------|-------------| |
| 652 |
| `getFormData()` / `setFormData()` | `FormDataInterface` | The normalized form data | |
| 653 |
| `getIntegrationId()` | `string` | e.g. `'cf7'`, `'avada'` | |
| 654 |
| `getFormId()` | `int` | The form ID | |
| 655 |
| `shouldSkipOptIn()` | `bool` | Whether to skip opt-in | |
| 656 |
| `skipOptIn($reason)` | `void` | Cancel opt-in creation | |
| 657 |
| `getField($key, $default)` | `mixed` | Get a single form field | |
| 658 |
| `hasField($key)` | `bool` | Check if field exists | |
| 659 |
|
| 660 |
**WordPress hook:** `f12_cf7_doubleoptin_form_submission` |
| 661 |
|
| 662 |
#### `IntegrationRegisteredEvent` |
| 663 |
|
| 664 |
Dispatched when a form integration is registered with the system. |
| 665 |
|
| 666 |
| Method | Return | Description | |
| 667 |
|--------|--------|-------------| |
| 668 |
| `getIntegrationId()` | `string` | The integration identifier | |
| 669 |
| `getName()` | `string` | The display name | |
| 670 |
| `isAvailable()` | `bool` | Whether the integration is available | |
| 671 |
|
| 672 |
**WordPress hook:** `f12_cf7_doubleoptin_integration_registered` |
| 673 |
|
| 674 |
--- |
| 675 |
|
| 676 |
## Migration Guide |
| 677 |
|
| 678 |
### Legacy Hook to Typed Event |
| 679 |
|
| 680 |
**Before (Legacy):** |
| 681 |
```php |
| 682 |
add_action( 'f12_cf7_doubleoptin_after_confirm', function ( $hash, $optIn ) { |
| 683 |
$email = $optIn->get_email(); |
| 684 |
$formData = maybe_unserialize( $optIn->get_content() ); |
| 685 |
my_sync( $email, $formData ); |
| 686 |
}, 10, 2 ); |
| 687 |
``` |
| 688 |
|
| 689 |
**After (Typed Event):** |
| 690 |
```php |
| 691 |
add_action( 'f12_cf7_doubleoptin_register_event_listeners', function ( $dispatcher ) { |
| 692 |
$dispatcher->addListener( |
| 693 |
\Forge12\DoubleOptIn\Events\Lifecycle\OptInConfirmedEvent::class, |
| 694 |
function ( \Forge12\DoubleOptIn\Events\Lifecycle\OptInConfirmedEvent $event ) { |
| 695 |
my_sync( $event->getEmail(), $event->getFormData() ); |
| 696 |
} |
| 697 |
); |
| 698 |
}, 10, 1 ); |
| 699 |
``` |
| 700 |
|
| 701 |
**Benefits of Typed Events:** |
| 702 |
- Full IDE autocompletion and type safety |
| 703 |
- `getFormData()` returns a clean array (no `maybe_unserialize` needed) |
| 704 |
- Events can be stopped with `$event->stopPropagation()` |
| 705 |
- Priority control via `addListener( ..., $priority )` |
| 706 |
- No dependency on the internal `OptIn` class |
| 707 |
|
| 708 |
### Both approaches work simultaneously |
| 709 |
|
| 710 |
The legacy `add_action('f12_cf7_doubleoptin_after_confirm', ...)` hook and the typed `OptInConfirmedEvent` listener are **not** mutually exclusive. Both fire during the same confirmation process. You can migrate incrementally. |
| 711 |
|