Claude Skill

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

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download lonsdale201-wp-agent-skills-fluentcrm_fluentcrm-custom-optin-forms-52f6020.zip · 8 KB
Part of lonsdale201/wp-agent-skills — 226 skills

Install

skills CLI npx skills add https://github.com/Lonsdale201/wp-agent-skills/tree/main/fluentcrm/fluentcrm-custom-optin-forms
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install lonsdale201-wp-agent-skills@llmmart
Git 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

  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:

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

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.

No comments yet.

Reviews (0)

No reviews yet.

Related