PluginProbe
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification / 5.9.0
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification v5.9.0
5.9.0 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 All 42 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.9.0, at docs/hooks-and-events.md

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