PluginProbe
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification / 5.13.0
Double Opt-In for Contact Form 7 – Secure, GDPR-Compliant Email Verification v5.13.0
5.12.0 5.13.0 5.13.1 5.11.0 5.10.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 All 47 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.13.0, at docs/hooks-and-events.md

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