PluginProbe
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification / 5.8.1
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification v5.8.1
5.8.0 5.8.1 5.7.0 5.6.2 5.6.3 5.6.1 5.6.0 5.5.0 5.4.0 5.3.2 5.3.1 5.1.6 5.1.5 trunk 2.1.5 2.11 2.12 2.13 2.15 3.0.0 3.0.1 3.0.2 3.0.3 3.0.5 3.0.51 All 41 releases
double-opt-in / docs / hooks-and-events.md

hooks-and-events.md in Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification 5.8.1, at docs/hooks-and-events.md

752 lines 30.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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 **Global adapters (since 5.8.0).** An adapter that applies to every form — a webhook, a CRM sync — implements `GlobalFollowUpAdapterInterface` instead and is registered the same way. Its actions are added to the plan of every opt-in whose form has an adapter, stored under its own `getIntegration()` name, and get the same status, retry and admin view. Rules:
247
248 - every action id starts with `<getIntegration()>:` (e.g. `webhooks:42`); other ids are dropped when planning
249 - `execute()` and `onSettled()` receive only the adapter's own actions; the form adapter always executes first
250 - a failing `planActions()` or `execute()` affects only the adapter's own actions
251 - if the add-on is deactivated later, its open actions are recorded as `failed_permanent` with code `adapter_missing`; a manual retry runs them once it is back
252 - nothing runs after an opt-out, as for every other action
253
254 ### `f12_doi_follow_up_backoff` (filter)
255
256 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.
257
258 ```php
259 add_filter( 'f12_doi_follow_up_backoff', fn() => array( 120, 600 ) );
260 ```
261
262 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.
263
264 ---
265
266 ## Integration Hooks
267
268 ### `f12_cf7_doubleoptin_register_integrations`
269
270 Fires during plugin initialization. Use this to register your own form integration.
271
272 | Parameter | Type | Description |
273 |-----------|------|-------------|
274 | `$registry` | `FormIntegrationRegistry` | The integration registry |
275 | `$container` | `Container` | The service container |
276
277 ```php
278 add_action( 'f12_cf7_doubleoptin_register_integrations', function ( $registry, $container ) {
279 $registry->register( new MyCustomFormIntegration( $container->get( LoggerInterface::class ) ) );
280 }, 10, 2 );
281 ```
282
283 ### `f12_cf7_doubleoptin_integration_registered`
284
285 Fires after a form integration has been registered.
286
287 | Parameter | Type | Description |
288 |-----------|------|-------------|
289 | `$integration` | `FormIntegrationInterface` | The registered integration |
290 | `$identifier` | `string` | The integration identifier (e.g. `cf7`, `avada`) |
291
292 ### `f12_cf7_doubleoptin_integrations_initialized`
293
294 Fires after all form integrations have been initialized.
295
296 | Parameter | Type | Description |
297 |-----------|------|-------------|
298 | `$registry` | `FormIntegrationRegistry` | The registry with all integrations |
299
300 ### `f12_cf7_doubleoptin_register_event_listeners`
301
302 Fires during event system setup. Register your typed event listeners here.
303
304 | Parameter | Type | Description |
305 |-----------|------|-------------|
306 | `$dispatcher` | `EventDispatcherInterface` | The event dispatcher |
307 | `$hookBridge` | `WordPressHookBridge` | The WordPress hook bridge |
308
309 ```php
310 add_action( 'f12_cf7_doubleoptin_register_event_listeners', function ( $dispatcher, $hookBridge ) {
311 $dispatcher->addListener(
312 \Forge12\DoubleOptIn\Events\Lifecycle\OptInConfirmedEvent::class,
313 function ( $event ) {
314 // your logic
315 }
316 );
317 }, 10, 2 );
318 ```
319
320 ---
321
322 ## Filters
323
324 ### Form & Submission Filters
325
326 | Filter | Parameters | Return | Description |
327 |--------|-----------|--------|-------------|
328 | `f12_cf7_doubleoptin_add_request_parameter` | `$fields` (array) | `array` | Modify submitted form fields before saving to database |
329 | `f12_cf7_doubleoptin_skip_option` | `$skip` (bool), `$formId`, `$fields`, `$type` | `bool` | Return `true` to skip opt-in creation for this submission |
330 | `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) |
331 | `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) |
332 | `f12_cf7_doubleoptin_error_message` | `$message` (string), `$error` (OptInError), `$formId` (int) | `string` | Customize the error message per error code (since 4.2.0) |
333 | `f12_cf7_doubleoptin_validate_recipient` | `$valid` (bool), `$recipient`, `$formData` | `bool\|string` | Validate recipient email; return error string to reject |
334 | `f12_cf7_doubleoptin_send_default_mail` | `$send` (bool), `$formId` | `bool` | Whether to send the original form mail after confirmation |
335 | `f12_doi_submit_notice_data` | `$notice` (array: `masked`, `lines`, `inbox`, `actions`), `$context` (array: `form_id`, `optin_id`, `integration`) | `array` | The hint shown after a CF7 double opt-in submission. Add `lines`, or `actions`: links (`type` `link`, `url` must be https) or buttons (`id`, `label`, optional `wait` in seconds and scalar `data`). A button fires the DOM event `f12-doi-notice-action` on the form with `{ id, data, button, form }`. `$context` stays on the server; sign your own token into `data` if your script needs to refer back to the opt-in (since 5.8.0) |
336 | `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) |
337
338 ### Mail Filters
339
340 | Filter | Parameters | Return | Description |
341 |--------|-----------|--------|-------------|
342 | `f12_cf7_doubleoptin_body` | `$body` (string) | `string` | Modify the opt-in confirmation email body |
343 | `f12_doi_mail_headers` | `$headers` (string[] header lines), `$optInId` (int), `$kind` (`confirmation`, `resend` or `reminder`) | `string[]` | Add or change headers of the double opt-in's own mails — whichever integration sends them. Other mails of the same request are not passed through. Line breaks inside a line are removed. Without a callback the mail is left exactly as built (since 5.8.0) |
344 | `f12-cf7-doubleoptin-cf7-args` | `$args` (array) | `array` | Modify mail arguments (subject, body, headers, attachments) |
345 | `f12_cf7_doubleoptin_files_mail_1` | `$include` (bool), `$optIn` | `bool` | Include file attachments in the first confirmation mail |
346 | `f12_cf7_doubleoptin_files_mail_2` | `$include` (bool), `$optIn` | `bool` | Include file attachments in the second confirmation mail |
347 | `f12_cf7_doubleoptin_allowed_mime_types` | `$mimeTypes` (array) | `array` | Modify allowed MIME types for file uploads |
348
349 ### Settings Filters
350
351 | Filter | Parameters | Return | Description |
352 |--------|-----------|--------|-------------|
353 | `f12_cf7_doubleoptin_save_form` | `$data` (array) | `array` | Modify form settings before saving |
354 | `f12_cf7_doubleoptin_metadata_cf7` | `$metadata` (array) | `array` | Modify CF7 form metadata |
355 | `f12_cf7_doubleoptin_metadata_avada` | `$metadata` (array) | `array` | Modify Avada form metadata |
356 | `f12_doi_form_settings_data` | `$formData`, `$formId` | `array` | Modify form settings data before sending to frontend |
357 | `f12_doi_form_settings_before_save` | `$settings`, `$storageId`, `$settingsData` | `FormSettingsDTO` | Modify FormSettingsDTO before saving |
358 | `f12_doi_settings_dto_from_array` | `$dto`, `$data` | `FormSettingsDTO` | Modify DTO when creating from array |
359 | `f12_doi_settings_dto_to_array` | `$array`, `$dto` | `array` | Modify array representation of DTO |
360 | `f12_doi_is_pro_active` | `$isActive` (bool) | `bool` | Whether the Pro version is active |
361 | `f12_cf7_doubleoptin_use_new_integration_system` | `$use` (bool) | `bool` | Enable/disable the new integration system |
362
363 ### Filter Examples
364
365 ```php
366 // Skip opt-in for specific forms
367 add_filter( 'f12_cf7_doubleoptin_skip_option', function ( $skip, $formId, $fields, $type ) {
368 if ( $formId === 42 ) {
369 return true; // skip opt-in for form #42
370 }
371 return $skip;
372 }, 10, 4 );
373
374 // Add custom fields to the stored data
375 add_filter( 'f12_cf7_doubleoptin_add_request_parameter', function ( $fields ) {
376 $fields['custom-tracking-id'] = uniqid( 'track_' );
377 return $fields;
378 } );
379
380 // Custom recipient validation
381 add_filter( 'f12_cf7_doubleoptin_validate_recipient', function ( $valid, $recipient, $formData ) {
382 if ( str_ends_with( $recipient, '@blocked-domain.com' ) ) {
383 return 'This email domain is not accepted.';
384 }
385 return $valid;
386 }, 10, 3 );
387
388 // Enable error display for users (default: false)
389 add_filter( 'f12_cf7_doubleoptin_show_validation_error', '__return_true' );
390
391 // Customize error messages per error code
392 add_filter( 'f12_cf7_doubleoptin_error_message', function ( $message, $error, $formId ) {
393 if ( $error->getCode() === 'rate_limit_ip' ) {
394 return 'Please wait a few minutes before trying again.';
395 }
396 return $message;
397 }, 10, 3 );
398 ```
399
400 ---
401
402 ## Universal Error Notification System
403
404 > **Since:** 4.2.0
405
406 The plugin provides a form-plugin-agnostic error notification system that works
407 with **all** integrations (CF7, Avada, Gravity Forms, WPForms, Elementor, and
408 any future integration) without requiring integration-specific error handling code.
409
410 ### How it works
411
412 1. When `createOptIn()` fails, an `OptInError` is stored in a short-lived transient
413 keyed by the client's IP + User-Agent (TTL: 60 seconds).
414 2. A small frontend JS (loaded on every frontend page unless
415 `f12_cf7_doubleoptin_enable_error_notification` returns `false`) listens for
416 form submission events from all supported plugins.
417 3. After form submission, the JS calls the AJAX endpoint
418 `doi_check_submission_error` to check for a stored error.
419 4. If an error exists, a toast notification is displayed. The transient is
420 deleted after retrieval (one-time read). When the error is shown to the
421 visitor (see below), the form plugin's success message is hidden and the
422 toast stays until closed.
423
424 ### Showing the error in the form
425
426 By default a refused submission is reported by the toast only; the form plugin
427 still shows its own success message. To show the reason in the form instead —
428 CF7 aborts with the message and keeps the input, Elementor answers with an
429 error, WPForms and Gravity Forms hide their confirmation — add this to your
430 theme's `functions.php`:
431
432 ```php
433 add_filter( 'f12_cf7_doubleoptin_show_validation_error', '__return_true' );
434
435 // Or per error code (arguments since 5.6.2):
436 add_filter( 'f12_cf7_doubleoptin_show_validation_error', function ( $show, $error, $formId ) {
437 return $error->getCode() === 'unique_email_duplicate' ? true : $show;
438 }, 10, 3 );
439 ```
440
441 **A refused consent is always shown** (since 5.6.2). It is the one refusal the
442 visitor caused and can fix — tick the box — and before 5.6.2 the form said
443 "sent" while no mail was ever going to come. The MX Validator, Domain Blocklist
444 and Unique Email add-ons switch the filter on for all errors while active.
445
446 ### Error codes
447
448 | Code | Constant | Default message |
449 |------|----------|----------------|
450 | `submission_cancelled` | `OptInError::SUBMISSION_CANCELLED` | The form submission has been cancelled. |
451 | `no_recipient` | `OptInError::NO_RECIPIENT` | No valid email address was found. |
452 | `rate_limit_ip` | `OptInError::RATE_LIMIT_IP` | Too many requests. Please try again later. |
453 | `rate_limit_email` | `OptInError::RATE_LIMIT_EMAIL` | Too many requests for this email address. Please try again later. |
454 | `recipient_invalid` | `OptInError::RECIPIENT_INVALID` | The email address could not be verified. |
455 | `save_failed` | `OptInError::SAVE_FAILED` | An error occurred. Please try again. |
456
457 ### Programmatic access
458
459 ```php
460 use Forge12\DoubleOptIn\Integration\AbstractFormIntegration;
461
462 // After a form submission, retrieve the last error (same request only)
463 $error = AbstractFormIntegration::getLastError();
464 if ( $error ) {
465 $code = $error->getCode(); // e.g. 'rate_limit_ip'
466 $message = $error->getMessage(); // translated message
467 $context = $error->getContext(); // ['ip' => '...', 'form_id' => 42]
468 }
469 ```
470
471 ### CSS customization
472
473 The notification uses the class `.doi-error-notification`. Override styles in your
474 theme to match your design:
475
476 ```css
477 .doi-error-notification__content {
478 border-left-color: #cc0000; /* custom accent color */
479 }
480 ```
481
482 ---
483
484 ## Typed Events
485
486 All events extend `Forge12\DoubleOptIn\EventSystem\Event` and are dispatched via `EventDispatcherInterface`.
487
488 ### Lifecycle Events
489
490 #### `OptInCreatedEvent`
491
492 Dispatched when a new opt-in record is created.
493
494 | Method | Return | Description |
495 |--------|--------|-------------|
496 | `getOptInId()` | `int` | The database record ID |
497 | `getFormId()` | `int` | The form ID |
498 | `getFormType()` | `string` | `'cf7'`, `'avada'`, etc. |
499 | `getEmail()` | `string` | The subscriber email |
500 | `getHash()` | `string` | The opt-in hash |
501 | `getFormData()` | `array` | Submitted form fields |
502
503 **WordPress hook:** `f12_cf7_doubleoptin_created` (auto-bridged)
504
505 #### `OptInConfirmedEvent`
506
507 Dispatched when an opt-in is confirmed via the confirmation link.
508
509 | Method | Return | Description |
510 |--------|--------|-------------|
511 | `getOptInId()` | `int` | The database record ID |
512 | `getHash()` | `string` | The opt-in hash |
513 | `getEmail()` | `string` | The subscriber email |
514 | `getConfirmedIp()` | `string` | IP address that confirmed |
515 | `getFormId()` | `int` | The original form ID |
516 | `getFormData()` | `array` | Submitted form fields (since 3.2.2) |
517
518 **WordPress hook:** `f12_cf7_doubleoptin_after_confirm` (manually bridged, not auto-bridged, to preserve `($hash, $optIn)` signature)
519
520 ```php
521 $dispatcher->addListener( OptInConfirmedEvent::class, function ( OptInConfirmedEvent $event ) {
522 $data = $event->getFormData();
523 // ['your-name' => 'John Doe', 'your-email' => '[email protected]', ...]
524 } );
525 ```
526
527 #### `OptInDeletedEvent`
528
529 Dispatched when an opt-in record is deleted.
530
531 | Method | Return | Description |
532 |--------|--------|-------------|
533 | `getHash()` | `string` | The opt-in hash |
534 | `getEmail()` | `string` | The subscriber email |
535 | `getDeletedBy()` | `string` | `'admin'`, `'cron'`, or `'user'` |
536 | `getRowsDeleted()` | `int` | Number of rows deleted |
537
538 **WordPress hook:** `f12_cf7_doubleoptin_deleted`
539
540 #### `OptInExpiredEvent`
541
542 Dispatched during cleanup when expired records are removed.
543
544 | Method | Return | Description |
545 |--------|--------|-------------|
546 | `getCleanupType()` | `string` | `'confirmed'` or `'unconfirmed'` |
547 | `getRowsDeleted()` | `int` | Number of records deleted |
548 | `getThreshold()` | `DateTimeImmutable` | The cutoff date |
549
550 **WordPress hook:** `f12_cf7_doubleoptin_expired`
551
552 #### `OptInOptedOutEvent`
553
554 Dispatched when a confirmed consent is withdrawn (since 5.8.0). The core
555 never withdraws a consent itself: the Opt-Out add-on (1.5.0 or later) fires
556 this once per opt-in that actually changed. Listen here instead of depending
557 on the Opt-Out add-on.
558
559 | Method | Return | Description |
560 |--------|--------|-------------|
561 | `getOptInId()` | `int` | The opt-in ID |
562 | `getHash()` | `string` | The opt-in hash |
563 | `getEmail()` | `string` | The subscriber email |
564 | `getFormId()` | `int` | The form the consent was given in |
565 | `getSource()` | `string` | `'link'` (opt-out link), `'bulk'` (all consents of one address) or `'one-click'` (mailbox unsubscribe button) |
566
567 **WordPress hook:** `f12_doi_optin_opted_out` (receives the event object)
568
569 #### `OptInReOptedInEvent`
570
571 Dispatched when a withdrawn consent is given again from the subscriber's
572 list in the Opt-Out add-on (since 5.8.0).
573
574 | Method | Return | Description |
575 |--------|--------|-------------|
576 | `getOptInId()` | `int` | The opt-in ID |
577 | `getHash()` | `string` | The opt-in hash |
578 | `getEmail()` | `string` | The subscriber email |
579 | `getFormId()` | `int` | The form ID |
580
581 **WordPress hook:** `f12_doi_optin_reopted_in` (receives the event object)
582
583 ---
584
585 ### Form Events
586
587 #### `FormSubmittedEvent`
588
589 Dispatched when a form is submitted (before opt-in is created).
590
591 | Method | Return | Description |
592 |--------|--------|-------------|
593 | `getFormId()` | `int` | The form ID |
594 | `getFormType()` | `string` | The form type |
595 | `getPostedData()` | `array` | Submitted form data |
596 | `getUploadedFiles()` | `array` | Uploaded files |
597 | `getFormUrl()` | `string` | Page URL where form was submitted |
598 | `shouldCreateOptIn()` | `bool` | Whether opt-in will be created |
599 | `skipOptInCreation($reason)` | `void` | Cancel opt-in creation |
600
601 **WordPress hook:** `f12_cf7_doubleoptin_form_submitted`
602
603 ```php
604 $dispatcher->addListener( FormSubmittedEvent::class, function ( FormSubmittedEvent $event ) {
605 // Skip opt-in for logged-in admins
606 if ( current_user_can( 'manage_options' ) ) {
607 $event->skipOptInCreation( 'Admin user, no opt-in needed' );
608 }
609 } );
610 ```
611
612 #### `FormValidatedEvent`
613
614 Dispatched after form validation is complete.
615
616 | Method | Return | Description |
617 |--------|--------|-------------|
618 | `getFormId()` | `int` | The form ID |
619 | `getFormType()` | `string` | The form type |
620 | `isValid()` | `bool` | Whether validation passed |
621 | `getRecipientEmail()` | `string` | The extracted email |
622 | `getErrors()` | `array` | Validation errors |
623
624 **WordPress hook:** `f12_cf7_doubleoptin_form_validated`
625
626 ---
627
628 ### Mail Events
629
630 #### `MailPreparingEvent`
631
632 Dispatched before the opt-in confirmation email is sent. All properties are **mutable**.
633
634 | Method | Return | Description |
635 |--------|--------|-------------|
636 | `getOptInId()` | `int` | The opt-in record ID |
637 | `getRecipient()` / `setRecipient()` | `string` | Recipient email |
638 | `getSubject()` / `setSubject()` | `string` | Email subject |
639 | `getBody()` / `setBody()` | `string` | Email body (HTML) |
640 | `getSender()` / `setSender()` | `string` | Sender email |
641 | `getSenderName()` / `setSenderName()` | `string` | Sender display name |
642 | `getHeaders()` / `addHeader()` | `array` | Email headers |
643 | `getAttachments()` / `addAttachment()` | `array` | File attachments |
644 | `shouldSend()` / `cancelSending()` | `bool` | Cancel sending |
645
646 **WordPress hook:** `f12_cf7_doubleoptin_mail_preparing`
647
648 ```php
649 $dispatcher->addListener( MailPreparingEvent::class, function ( MailPreparingEvent $event ) {
650 $event->setSubject( 'Custom: ' . $event->getSubject() )
651 ->addHeader( 'X-Custom-Header: my-value' );
652 } );
653 ```
654
655 #### `MailSentEvent`
656
657 Dispatched after a mail has been sent (or failed).
658
659 | Method | Return | Description |
660 |--------|--------|-------------|
661 | `getOptInId()` | `int` | The opt-in record ID |
662 | `getRecipient()` | `string` | Recipient email |
663 | `getSubject()` | `string` | Email subject |
664 | `wasSuccessful()` | `bool` | Whether sending succeeded |
665 | `getMailType()` | `string` | `'optin'` or `'confirmation'` |
666
667 **WordPress hook:** `f12_cf7_doubleoptin_mail_sent`
668
669 #### `ReminderSentEvent`
670
671 Dispatched after a reminder email is sent (Pro feature).
672
673 | Method | Return | Description |
674 |--------|--------|-------------|
675 | `getOptInId()` | `int` | The opt-in record ID |
676 | `getRecipient()` | `string` | Recipient email |
677 | `getSubject()` | `string` | Email subject |
678 | `wasSuccessful()` | `bool` | Whether sending succeeded |
679 | `getTrigger()` | `string` | `'cron'` or `'manual'` |
680
681 **WordPress hook:** `f12_cf7_doubleoptin_reminder_sent`
682
683 ---
684
685 ### Integration Events
686
687 #### `FormSubmissionEvent`
688
689 Dispatched when a form integration processes a submission. Allows modifying form data or cancelling the opt-in.
690
691 | Method | Return | Description |
692 |--------|--------|-------------|
693 | `getFormData()` / `setFormData()` | `FormDataInterface` | The normalized form data |
694 | `getIntegrationId()` | `string` | e.g. `'cf7'`, `'avada'` |
695 | `getFormId()` | `int` | The form ID |
696 | `shouldSkipOptIn()` | `bool` | Whether to skip opt-in |
697 | `skipOptIn($reason)` | `void` | Cancel opt-in creation |
698 | `getField($key, $default)` | `mixed` | Get a single form field |
699 | `hasField($key)` | `bool` | Check if field exists |
700
701 **WordPress hook:** `f12_cf7_doubleoptin_form_submission`
702
703 #### `IntegrationRegisteredEvent`
704
705 Dispatched when a form integration is registered with the system.
706
707 | Method | Return | Description |
708 |--------|--------|-------------|
709 | `getIntegrationId()` | `string` | The integration identifier |
710 | `getName()` | `string` | The display name |
711 | `isAvailable()` | `bool` | Whether the integration is available |
712
713 **WordPress hook:** `f12_cf7_doubleoptin_integration_registered`
714
715 ---
716
717 ## Migration Guide
718
719 ### Legacy Hook to Typed Event
720
721 **Before (Legacy):**
722 ```php
723 add_action( 'f12_cf7_doubleoptin_after_confirm', function ( $hash, $optIn ) {
724 $email = $optIn->get_email();
725 $formData = maybe_unserialize( $optIn->get_content() );
726 my_sync( $email, $formData );
727 }, 10, 2 );
728 ```
729
730 **After (Typed Event):**
731 ```php
732 add_action( 'f12_cf7_doubleoptin_register_event_listeners', function ( $dispatcher ) {
733 $dispatcher->addListener(
734 \Forge12\DoubleOptIn\Events\Lifecycle\OptInConfirmedEvent::class,
735 function ( \Forge12\DoubleOptIn\Events\Lifecycle\OptInConfirmedEvent $event ) {
736 my_sync( $event->getEmail(), $event->getFormData() );
737 }
738 );
739 }, 10, 1 );
740 ```
741
742 **Benefits of Typed Events:**
743 - Full IDE autocompletion and type safety
744 - `getFormData()` returns a clean array (no `maybe_unserialize` needed)
745 - Events can be stopped with `$event->stopPropagation()`
746 - Priority control via `addListener( ..., $priority )`
747 - No dependency on the internal `OptIn` class
748
749 ### Both approaches work simultaneously
750
751 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.
752