fluentcrm-custom-optin-forms
Builds and audits public custom subscription forms that create or update FluentCRM contacts and carry them through double opt-in. Covers explicit consent, server-owned list/tag mapping, pending and suppressed status policy, createOrUpdate, sendDoubleOptinEmail, list-specific conf
Install
npx skills add https://github.com/Lonsdale201/wp-agent-skills/tree/main/fluentcrm/fluentcrm-custom-optin-forms
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install lonsdale201-wp-agent-skills@llmmart
git clone https://github.com/Lonsdale201/wp-agent-skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole lonsdale201/wp-agent-skills collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
FluentCRM custom subscription and double opt-in forms
Use this skill for a public form or API that owns its user interface but stores contacts in FluentCRM. Keep the transport, consent policy, CRM mutation, email request, and confirmation side effects separate.
Read implementation-contract.md before writing the endpoint or changing an existing contact's status.
Core contract
FluentCrmApi('contacts')->createOrUpdate() does not send a double opt-in email.
The canonical core flow is:
$contact = FluentCrmApi('contacts')->createOrUpdate([
'email' => sanitize_email($email),
'status' => 'pending',
'source' => 'my-plugin',
'lists' => $serverOwnedListIds,
'tags' => $serverOwnedTagIds,
], false, false);
if ($contact && $contact->status === 'pending') {
$contact->sendDoubleOptinEmail();
}
FluentCRM then builds its tokenized confirmation URL, handles the public
confirmation request, changes pending to subscribed, resumes applicable
funnels, records a system note, and fires
fluent_crm/subscriber_confirmed_via_double_optin. Do not create a second token,
confirmation table, or confirmation route around this lifecycle.
Double opt-in, global/list-specific email settings, and the confirmation hook are Free-core features. Site code and add-ons may filter post-confirmation routing, but a custom signup integration must not require Pro for the base flow.
Workflow
- Require a clear affirmative consent value. Store the exact consent text or policy version and submission timestamp in the application's audit store when that evidence matters; FluentCRM does not preserve the original form wording.
- Validate and normalize the email, then sanitize only explicitly mapped name
and custom fields. Never accept
status,user_id,source, list IDs, tag IDs, or detach operations directly from the public payload. - Resolve public choices through a server-owned map and verify every resulting list/tag exists. Passing strings to FluentCRM's attach helpers can create new lists/tags; numeric IDs are not an authorization boundary.
- Inspect the existing contact before writing and apply the status matrix below.
- Call
createOrUpdate()with$forceUpdate = false. Attach the DOI-driving list before sending so list-specific settings can be selected. - Call
sendDoubleOptinEmail()only when the resulting status ispending. - Return the same generic accepted response for existing, new, suppressed, and throttled addresses. Log bounded operational failures privately.
- Put verified-only tags or downstream work on the confirmation hook, not on the initial form request.
Existing-contact status matrix
| Existing status | Public opt-in behavior |
|---|---|
| none | Create as pending; send confirmation. |
pending |
Update safe fields/mappings; request a resend. Core suppresses successful resends for 150 seconds. |
subscribed |
Keep subscribed; update permitted mappings; do not demote or resend by default. |
transactional |
Explicit marketing consent may move it to pending; confirmation promotes it to subscribed. |
unsubscribed |
Only an explicit re-consent flow may call updateStatus('pending'), then send confirmation. Never force it directly to subscribed. |
bounced, complained, spammed |
Keep suppressed in a normal public form. Require a deliberate administrative/remediation policy before re-entry. |
The default non-forced createOrUpdate() protects subscribed and strict-status
contacts. Do not pass true for $forceUpdate merely to make a public form
"work"; that lets anyone resubscribe a suppressed address without proving
mailbox control.
Lists, tags, and list-specific DOI
Attach source/interest lists and tags before email only when they are valid for a pending contact. Their attach hooks fire immediately; they do not wait for confirmation. If a tag means "verified subscriber", attach it here instead:
add_action(
'fluent_crm/subscriber_confirmed_via_double_optin',
static function ($contact): void {
$marker = fluentcrm_get_subscriber_meta(
(int) $contact->id,
'_my_plugin_pending_optin'
);
if (!$marker) {
return;
}
fluentcrm_delete_subscriber_meta(
(int) $contact->id,
'_my_plugin_pending_optin'
);
$contact->attachTags([MY_PLUGIN_VERIFIED_TAG_ID]);
},
10,
1
);
In 3.1.13, the double opt-in sender uses the contact's latest list pivot row,
ordered by created_at DESC, id DESC, to select list-specific settings. An
already-attached list is a no-op and does not become latest again. Therefore:
- prefer one explicit DOI-driving list per form;
- do not assume arbitrary request order selects the template;
- test existing multi-list contacts when a specific template/redirect matters;
- fall back to global DOI settings when list precedence would be ambiguous.
The email configuration must contain a confirmation activation link. Use
#activate_link# or FluentCRM's supported activation-button SmartCode; never
construct the secure URL yourself.
Public endpoint security
An anonymous newsletter endpoint is intentionally public. A REST
permission_callback returning true can be correct for that narrow route, but it
does not make the handler safe. Enforce all of these inside the flow:
- required affirmative consent and strict field allowlists;
- server-owned list/tag/source/status mapping;
- per-IP and per-normalized-email throttling before creating contacts;
- bounded request size, field lengths, and work per request;
- honeypot, minimum-fill-time, CAPTCHA, or equivalent protection when abuse risk warrants it;
- generic responses that do not reveal whether an address already exists;
- no raw payloads, consent text, email addresses, or tokens in public errors;
- HTTPS and normal WordPress output escaping.
A WordPress REST nonce does not authenticate a logged-out visitor and is not a replacement for abuse controls. Core's 150-second resend guard is per existing contact after a successful send; it does not stop an attacker submitting many new addresses.
Send and confirmation semantics
Treat sendDoubleOptinEmail() === false as "not sent now", not as a failed
contact write. It can mean wrong status, the resend window, or invalid DOI email
configuration. A true return means the send path was invoked, not that remote
delivery or human intent was proven.
Scope confirmation callbacks with a plugin-owned marker (subscriber meta or the
application audit store); otherwise your callback also processes confirmations
started by unrelated forms/integrations. The confirmation hook fires only for the real pending to subscribed
transition. Replaying a link for an already subscribed contact does not fire it
again. Stale links cannot revive contacts currently in another suppressed
status. A clicked email link demonstrates link access, but automated mail
scanners can follow GET links; do not treat DOI as strong identity verification
for high-risk account actions.
Audit checklist
- Confirm
createOrUpdate()andsendDoubleOptinEmail()are separate calls. - Confirm list/tag IDs never come directly from request data.
- Confirm
forceUpdate = trueis absent from anonymous paths. - Test every status in the matrix, duplicate requests, and concurrent submits.
- Test missing/malformed DOI settings and a template without an activation link.
- Confirm pending contacts receive no marketing campaign email.
- Confirm pre-verification list/tag hooks cannot trigger unsafe side effects.
- Confirm the success response does not enumerate contacts.
- Confirm application throttling covers both existing and new addresses.
- Confirm confirmation and repeated/stale links produce the intended state.
Cross-references
- Use
fluentcrm-contact-modelsfor the underlying contact/list/tag API. - Use
wp-rest-apiwhen the form is transported through a custom REST route. - Use
fluentcrm-funnel-triggerwhen confirmation must start a custom automation.
References
- Official Contact PHP API: https://docs.fluentcrm.com/contact-php-api
- Official double opt-in settings: https://docs.fluentcrm.com/global-double-opt-in-settings
- Official contact statuses: https://docs.fluentcrm.com/fluentcrm-contacts-status
- Verified source paths:
fluent-crm/app/Api/Classes/Contacts.phpfluent-crm/app/Models/Subscriber.phpfluent-crm/app/Services/Libs/Mailer/Handler.phpfluent-crm/app/Hooks/Handlers/ExternalPages.phpfluent-crm/app/Services/ExternalIntegrations/FluentForm/Bootstrap.phpfluent-crm/app/Services/Helper.php
Files (wp-agent-skills)
-
agents
-
openai.yaml 331 B
interface: display_name: "FluentCRM custom opt-in forms" short_description: "Build secure FluentCRM double opt-in signup flows" default_prompt: "Use $fluentcrm-custom-optin-forms to design or audit a public custom signup flow with safe contact updates, lists, tags, abuse controls, and FluentCRM double opt-in confirmation."
-
-
references
-
implementation-contract.md 11.1 KB
# Custom opt-in implementation contract Read this reference when implementing the public transport and application service. The examples target PHP 7.4, WordPress 7.1, and FluentCRM 3.1.13. ## Responsibility map | Layer | Owns | |---|---| | Form/client | Accessible fields, affirmative consent UI, no trusted IDs/status. | | WordPress endpoint | Shape validation, request bounds, abuse controls, generic response. | | Application service | Field allowlist, list/tag mapping, status policy, idempotent CRM call. | | FluentCRM | Contact persistence, pivots/hooks, DOI email, secure confirmation URL, status transition. | | Application audit store | Consent wording/version, lawful basis when needed, submission correlation and retention. | Do not let the browser cross these boundaries. Hidden fields are still attacker-controlled. ## Transport-independent service pattern Keep configured list/tag IDs in trusted server configuration. If the form offers interests, map stable public slugs to those IDs before calling this service. ```php <?php final class Acme_FluentCRM_Optin_Service { /** * @param array $input Sanitized transport data. * @param int[] $configuredListIds Existing FluentCRM list IDs. * @param int[] $configuredTagIds Existing FluentCRM tag IDs. * @return array|WP_Error */ public function subscribe(array $input, array $configuredListIds, array $configuredTagIds) { if (!function_exists('FluentCrmApi')) { return new WP_Error('crm_unavailable', __('Subscription is temporarily unavailable.', 'acme')); } $email = sanitize_email((string) ($input['email'] ?? '')); if (!$email || !is_email($email) || empty($input['consent'])) { return new WP_Error('invalid_request', __('Please provide a valid email and consent.', 'acme')); } // These arrays came from server configuration, never from raw request IDs. $listIds = array_values(array_unique(array_filter(array_map('intval', $configuredListIds)))); $tagIds = array_values(array_unique(array_filter(array_map('intval', $configuredTagIds)))); // Validate configured IDs during settings save and again here if settings // can become stale after a list/tag is deleted. $listIds = $this->existingIds('lists', $listIds); $tagIds = $this->existingIds('tags', $tagIds); $api = FluentCrmApi('contacts'); $existing = $api->getContact($email); $previousStatus = $existing ? (string) $existing->status : ''; // Do not let a public form touch deliverability/complaint suppressions. if (in_array($previousStatus, ['bounced', 'complained', 'spammed'], true)) { return ['accepted' => true, 'mail_requested' => false]; } $contactData = [ 'email' => $email, 'first_name' => sanitize_text_field((string) ($input['first_name'] ?? '')), 'last_name' => sanitize_text_field((string) ($input['last_name'] ?? '')), 'status' => 'pending', 'lists' => $listIds, 'tags' => $tagIds, 'custom_values' => $this->allowedCustomValues($input), ]; // Preserve the acquisition source of existing contacts. if (!$existing) { $contactData['source'] = 'acme-newsletter'; } $contact = $api->createOrUpdate($contactData, false, false); if (!$contact) { return new WP_Error('crm_write_failed', __('Subscription is temporarily unavailable.', 'acme')); } // Non-forced createOrUpdate correctly preserves unsubscribed. The user's // affirmative submission starts re-consent, but confirmation still owns // the transition to subscribed. if ($previousStatus === 'unsubscribed' && $contact->status === 'unsubscribed') { $contact = $contact->updateStatus('pending'); } $mailRequested = false; if ($contact->status === 'pending') { // Scope the later confirmation hook to this integration. Store only // bounded workflow state here; keep full consent evidence in the // application's audit store. fluentcrm_update_subscriber_meta( (int) $contact->id, '_acme_pending_newsletter_optin', time() ); $mailRequested = (bool) $contact->sendDoubleOptinEmail(); } return [ 'accepted' => true, 'mail_requested' => $mailRequested, // Keep contact ID/status server-side; do not return them publicly. ]; } private function existingIds($apiKey, array $ids) { if (!$ids) { return []; } $rows = FluentCrmApi($apiKey)->getInstance() ->whereIn('id', $ids) ->get(['id']); return array_values(array_map('intval', $rows->pluck('id')->toArray())); } private function allowedCustomValues(array $input) { $values = []; if (isset($input['locale'])) { $values['preferred_locale'] = sanitize_key((string) $input['locale']); } return $values; } } ``` If the product's policy permits bounced/complained/spammed re-entry, implement a separate privileged remediation operation. Do not silently broaden the public service by passing `$forceUpdate = true`. ## Public REST route pattern The route is anonymous by design. Keep its authority narrow: it may only request a newsletter opt-in with server-selected mappings. ```php add_action('rest_api_init', static function (): void { register_rest_route('acme/v1', '/newsletter/subscribe', [ 'methods' => WP_REST_Server::CREATABLE, 'permission_callback' => '__return_true', 'args' => [ 'email' => [ 'required' => true, 'sanitize_callback' => 'sanitize_email', 'validate_callback' => static function ($value): bool { return is_string($value) && (bool) is_email($value); }, ], 'first_name' => [ 'sanitize_callback' => 'sanitize_text_field', ], 'last_name' => [ 'sanitize_callback' => 'sanitize_text_field', ], 'consent' => [ 'required' => true, 'sanitize_callback' => 'rest_sanitize_boolean', 'validate_callback' => static function ($value): bool { return rest_sanitize_boolean($value) === true; }, ], ], 'callback' => static function (WP_REST_Request $request) { $email = (string) $request->get_param('email'); // Implement a bounded store backed by a persistent cache or DB. // Hash personal data in rate-limit keys; do not use raw email/IP. if (acme_optin_rate_limited($email, $_SERVER['REMOTE_ADDR'] ?? '')) { return new WP_REST_Response([ 'message' => __('If eligible, check your inbox for the next step.', 'acme'), ], 202); } $result = (new Acme_FluentCRM_Optin_Service())->subscribe( $request->get_params(), acme_optin_list_ids(), acme_optin_tag_ids() ); if (is_wp_error($result)) { // Log only a bounded error code/correlation ID. Keep the public // response generic so it cannot enumerate contacts or config. acme_log_optin_error($result->get_error_code()); } return new WP_REST_Response([ 'message' => __('If eligible, check your inbox for the next step.', 'acme'), ], 202); }, ]); }); ``` `__return_true` is acceptable here because anyone may request this single, bounded operation. It would not be acceptable on contact lookup, list browsing, status mutation, arbitrary tagging, resend-by-contact-ID, or administrative routes. Do not rely on `X-WP-Nonce` for anonymous abuse prevention. Add application rate limits before the CRM write, and use a honeypot/CAPTCHA or edge protection when the site's threat model requires it. ## Confirmation-only work Use the core hook for state that must never exist on a merely pending contact: ```php add_action( 'fluent_crm/subscriber_confirmed_via_double_optin', static function ($contact): void { $requestedAt = (int) fluentcrm_get_subscriber_meta( (int) $contact->id, '_acme_pending_newsletter_optin' ); if (!$requestedAt) { return; } fluentcrm_delete_subscriber_meta( (int) $contact->id, '_acme_pending_newsletter_optin' ); $contact->attachTags([ACME_VERIFIED_NEWSLETTER_TAG_ID]); do_action('acme/newsletter_verified', (int) $contact->id); }, 10, 1 ); ``` Make the callback idempotent even though FluentCRM emits this hook only on the actual confirmation transition. Other integrations or maintenance tools may invoke your downstream event independently. Expire abandoned markers according to the application's retention policy; do not let per-contact workflow meta grow without cleanup. ## List-specific settings trap `Handler::sendDoubleOptInEmail()` calls `Helper::latestListIdOfSubscriber($contactId)`. That query selects one pivot row with `created_at DESC, id DESC`. Consequences: - one contact can have many lists but only one list-specific DOI config wins; - newly inserted lists with equal timestamps are resolved by the largest pivot ID, normally the last successful insert; - attaching an existing relationship uses `INSERT IGNORE`, so its timestamp and precedence do not change; - global settings are used when the winning list is configured to use global DOI or has no usable list-specific settings. Do not detach and reattach a list merely to force precedence: that fires removal and addition hooks and can alter automations. Prefer a clear single-list form, global DOI settings, or an explicitly designed filter-based customization. ## Smoke-test matrix Intercept email with `fluent_crm/is_simulated_mail`; never send test mail to a real address. 1. New address: pending, configured list/tag attached, exactly one DOI render. 2. Immediate duplicate: still pending, no duplicate pivot, resend returns false. 3. Confirmation URL: HTTP success and persisted status becomes subscribed. 4. Replayed confirmation: remains subscribed and confirmation-only work is not duplicated. 5. Existing subscribed: never demoted to pending. 6. Existing transactional: enters pending only after explicit marketing consent. 7. Existing unsubscribed: re-enters pending, never directly subscribed. 8. Bounced/complained/spammed: remains suppressed under the default policy. 9. Deleted/config-stale list/tag: no orphan or attacker-selected association. 10. Missing consent, invalid email, oversized payload, honeypot, and rate limit: no CRM mutation or mail request. 11. Missing activation link or invalid DOI configuration: private diagnostic, same generic public response. Clean up the test contact and its list/tag definitions after the run.
-
-
SKILL.md 9.5 KB
--- name: fluentcrm-custom-optin-forms description: >- Builds and audits public custom subscription forms that create or update FluentCRM contacts and carry them through double opt-in. Covers explicit consent, server-owned list/tag mapping, pending and suppressed status policy, createOrUpdate, sendDoubleOptinEmail, list-specific confirmation settings, generic responses, abuse controls, confirmation hooks, and verified-only automations. Use when implementing a newsletter, lead-magnet, registration, checkout, headless, REST, or AJAX signup flow that references FluentCrmApi, pending, double opt-in, subscriber_confirmed_via_double_optin, lists, or tags. metadata: wp-skills-author: "Soczó Kristóf" wp-skills-contact: "mailto:lonsdale201@hotmail.com" wp-skills-plugin: "fluent-crm" wp-skills-plugin-version-tested: "3.1.13" wp-skills-wp-version-tested: "7.1" wp-skills-php-min: "7.4" wp-skills-last-updated: "2026-08-25" --- # FluentCRM custom subscription and double opt-in forms Use this skill for a public form or API that owns its user interface but stores contacts in FluentCRM. Keep the transport, consent policy, CRM mutation, email request, and confirmation side effects separate. Read [implementation-contract.md](references/implementation-contract.md) before writing the endpoint or changing an existing contact's status. ## Core contract `FluentCrmApi('contacts')->createOrUpdate()` does not send a double opt-in email. The canonical core flow is: ```php $contact = FluentCrmApi('contacts')->createOrUpdate([ 'email' => sanitize_email($email), 'status' => 'pending', 'source' => 'my-plugin', 'lists' => $serverOwnedListIds, 'tags' => $serverOwnedTagIds, ], false, false); if ($contact && $contact->status === 'pending') { $contact->sendDoubleOptinEmail(); } ``` FluentCRM then builds its tokenized confirmation URL, handles the public confirmation request, changes `pending` to `subscribed`, resumes applicable funnels, records a system note, and fires `fluent_crm/subscriber_confirmed_via_double_optin`. Do not create a second token, confirmation table, or confirmation route around this lifecycle. Double opt-in, global/list-specific email settings, and the confirmation hook are Free-core features. Site code and add-ons may filter post-confirmation routing, but a custom signup integration must not require Pro for the base flow. ## Workflow 1. Require a clear affirmative consent value. Store the exact consent text or policy version and submission timestamp in the application's audit store when that evidence matters; FluentCRM does not preserve the original form wording. 2. Validate and normalize the email, then sanitize only explicitly mapped name and custom fields. Never accept `status`, `user_id`, `source`, list IDs, tag IDs, or detach operations directly from the public payload. 3. Resolve public choices through a server-owned map and verify every resulting list/tag exists. Passing strings to FluentCRM's attach helpers can create new lists/tags; numeric IDs are not an authorization boundary. 4. Inspect the existing contact before writing and apply the status matrix below. 5. Call `createOrUpdate()` with `$forceUpdate = false`. Attach the DOI-driving list before sending so list-specific settings can be selected. 6. Call `sendDoubleOptinEmail()` only when the resulting status is `pending`. 7. Return the same generic accepted response for existing, new, suppressed, and throttled addresses. Log bounded operational failures privately. 8. Put verified-only tags or downstream work on the confirmation hook, not on the initial form request. ## Existing-contact status matrix | Existing status | Public opt-in behavior | |---|---| | none | Create as `pending`; send confirmation. | | `pending` | Update safe fields/mappings; request a resend. Core suppresses successful resends for 150 seconds. | | `subscribed` | Keep subscribed; update permitted mappings; do not demote or resend by default. | | `transactional` | Explicit marketing consent may move it to `pending`; confirmation promotes it to subscribed. | | `unsubscribed` | Only an explicit re-consent flow may call `updateStatus('pending')`, then send confirmation. Never force it directly to subscribed. | | `bounced`, `complained`, `spammed` | Keep suppressed in a normal public form. Require a deliberate administrative/remediation policy before re-entry. | The default non-forced `createOrUpdate()` protects subscribed and strict-status contacts. Do not pass `true` for `$forceUpdate` merely to make a public form "work"; that lets anyone resubscribe a suppressed address without proving mailbox control. ## Lists, tags, and list-specific DOI Attach source/interest lists and tags before email only when they are valid for a pending contact. Their attach hooks fire immediately; they do not wait for confirmation. If a tag means "verified subscriber", attach it here instead: ```php add_action( 'fluent_crm/subscriber_confirmed_via_double_optin', static function ($contact): void { $marker = fluentcrm_get_subscriber_meta( (int) $contact->id, '_my_plugin_pending_optin' ); if (!$marker) { return; } fluentcrm_delete_subscriber_meta( (int) $contact->id, '_my_plugin_pending_optin' ); $contact->attachTags([MY_PLUGIN_VERIFIED_TAG_ID]); }, 10, 1 ); ``` In 3.1.13, the double opt-in sender uses the contact's latest list pivot row, ordered by `created_at DESC, id DESC`, to select list-specific settings. An already-attached list is a no-op and does not become latest again. Therefore: - prefer one explicit DOI-driving list per form; - do not assume arbitrary request order selects the template; - test existing multi-list contacts when a specific template/redirect matters; - fall back to global DOI settings when list precedence would be ambiguous. The email configuration must contain a confirmation activation link. Use `#activate_link#` or FluentCRM's supported activation-button SmartCode; never construct the secure URL yourself. ## Public endpoint security An anonymous newsletter endpoint is intentionally public. A REST `permission_callback` returning true can be correct for that narrow route, but it does not make the handler safe. Enforce all of these inside the flow: - required affirmative consent and strict field allowlists; - server-owned list/tag/source/status mapping; - per-IP and per-normalized-email throttling before creating contacts; - bounded request size, field lengths, and work per request; - honeypot, minimum-fill-time, CAPTCHA, or equivalent protection when abuse risk warrants it; - generic responses that do not reveal whether an address already exists; - no raw payloads, consent text, email addresses, or tokens in public errors; - HTTPS and normal WordPress output escaping. A WordPress REST nonce does not authenticate a logged-out visitor and is not a replacement for abuse controls. Core's 150-second resend guard is per existing contact after a successful send; it does not stop an attacker submitting many new addresses. ## Send and confirmation semantics Treat `sendDoubleOptinEmail() === false` as "not sent now", not as a failed contact write. It can mean wrong status, the resend window, or invalid DOI email configuration. A true return means the send path was invoked, not that remote delivery or human intent was proven. Scope confirmation callbacks with a plugin-owned marker (subscriber meta or the application audit store); otherwise your callback also processes confirmations started by unrelated forms/integrations. The confirmation hook fires only for the real `pending` to `subscribed` transition. Replaying a link for an already subscribed contact does not fire it again. Stale links cannot revive contacts currently in another suppressed status. A clicked email link demonstrates link access, but automated mail scanners can follow GET links; do not treat DOI as strong identity verification for high-risk account actions. ## Audit checklist - Confirm `createOrUpdate()` and `sendDoubleOptinEmail()` are separate calls. - Confirm list/tag IDs never come directly from request data. - Confirm `forceUpdate = true` is absent from anonymous paths. - Test every status in the matrix, duplicate requests, and concurrent submits. - Test missing/malformed DOI settings and a template without an activation link. - Confirm pending contacts receive no marketing campaign email. - Confirm pre-verification list/tag hooks cannot trigger unsafe side effects. - Confirm the success response does not enumerate contacts. - Confirm application throttling covers both existing and new addresses. - Confirm confirmation and repeated/stale links produce the intended state. ## Cross-references - Use `fluentcrm-contact-models` for the underlying contact/list/tag API. - Use `wp-rest-api` when the form is transported through a custom REST route. - Use `fluentcrm-funnel-trigger` when confirmation must start a custom automation. ## References - Official Contact PHP API: <https://docs.fluentcrm.com/contact-php-api> - Official double opt-in settings: <https://docs.fluentcrm.com/global-double-opt-in-settings> - Official contact statuses: <https://docs.fluentcrm.com/fluentcrm-contacts-status> - Verified source paths: - `fluent-crm/app/Api/Classes/Contacts.php` - `fluent-crm/app/Models/Subscriber.php` - `fluent-crm/app/Services/Libs/Mailer/Handler.php` - `fluent-crm/app/Hooks/Handlers/ExternalPages.php` - `fluent-crm/app/Services/ExternalIntegrations/FluentForm/Bootstrap.php` - `fluent-crm/app/Services/Helper.php`
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.